Skip to main content

Compute Engine Changelog

Coming Soon​

New Features​

  • Permutation groups, an optional entry: @cortex-js/compute-engine/group-theory. loadGroupTheory(ce) adds 26 operators to an engine: permutations and their products, inverses, powers, conjugates (b⁻¹ab) and commutators (a⁻¹b⁻¹ab); groups given by generators on an explicit action PermutationAction(n); orbits and transversals; the exact order, membership, point stabilizers, and the subgroup, index, equality, normality and abelian queries. The order and membership queries use an incremental Schreier–Sims algorithm: the order of S₁₀₀, generated by a transposition and a 100-cycle, takes about 30 ms. The loader options set resource limits, and a query can lower them with a named options dictionary. A query that reaches a limit returns an error such as Error("permutation-chain-orbit-edge-limit"), never a partial list or a false answer. An engine that does not load the entry does not have these operators. Contributed by @KingArth0r (#482).

Behavior Changes​

  • The join of types does not depend on the order of its operands. widen(nan, real, missing) gave missing | number and widen(missing, nan, real) gave missing | nan | real. Both now give missing | nan | real: nan beside another number type is kept as its own arm (widen(real, nan) is nan | real, not number, which also admits the complex numbers). A finite type joined with an infinity is still number. The join of +oo and -oo is signed_infinity, not infinity (which also admits ~oo), and a union of +oo and -oo no longer drops -oo. [NaN, 1] has the element type integer | nan whether it is built with ce.box() or ce.list().
  • A shader compile refuses a vars entry that names a variable of the compiled code. On glsl and wgsl, vars: { a: 'k' } beside a Block that declares k (or a Sum index, a loop variable, a function parameter named k) compiled to code that read the local k instead of the uniform. The compile now fails with a message that names the entry and the symbol. A self-mapped entry (vars: { T: 'T' }) is not affected.
  • A list argument at a declared point parameter is refused by the compiled code, as by the interpreter. With h(p: tuple<real, real>) := p[1] + p[2] and g(p) := h(p) + 1, g([x, y]) is an incompatible-type error in the interpreter, and compiled to code that gave a value. It now declines on every target.
  • A recursive function declared function is typed as the same function assigned without the declaration. With G, S and R declared function and then assigned G(a,b,c) = |\sin a| - 0.1, S(a,b,c) = \max(G(a,b,c), c - 3) and R(i) = \{i = 0: 0, R(i-1) + S(1, 2, R(i-1))\}, R(3) and R(x) were typed any (with a simpler S, a union of numbers, tuples and lists). They are now typed number. A recursive list builder declared function is typed list<number>, not collection<number>. The signature of such a function is derived as for a declaration (unknown) -> unknown, which checks the recursion.

Performance​

  • On the shader targets, a call that several user functions repeat is computed once in more cases. A large expansion was kept by reference: in Tycho's voronoi-diagram document, B, H, u, v and Z each called F(x, y) and the same sixteen weight terms again, so WebGPU drew the row about 2.1 times slower than WebGL. The function bodies are now written into the main body, where each shared call is computed once per pixel. The shader is also smaller (10.8 KB of WGSL instead of 12.3 KB). The size check that counts the code written is unchanged.
  • A common subexpression of a restriction's condition is shared with its value. T_3(t)\{-0.8 < \cos(T_3(t))\} (When(T_3(t), …)) computed T_3(t) twice on every target; it is now computed once. On the shader targets, a call that reads a uniform passed with a renamed vars entry (vars: { s: 'u_s' }) can now be shared too.
  • More shader operands use the built-in sin, cos, sign, max, min and clamp instead of the NaN-safe helpers: a product whose factors have declared ranges (sin(k t) with k: real<0..10>, t: real<-7..7>), a product with a sin, cos or tanh factor, a symbol with an assigned value that is compiled as that value, and a vec2 to vec4 operand whose components are all finite for certain (sin([t, k, 1]) with t, k declared real). A shader that calls only gcd no longer carries the lcm helper.
  • A function that reads a symbol holding a long symbolic expression is faster to call (ROADMAP CE-566). With X(l) := C_0[l] and C_0 assigned a Sum over a chain of lazy Map and Unique collections, each call of X derived the type of that chain again, although nothing it reads had changed. The type is now remembered until a binding it reads changes: 1 495 calls took 350 ms instead of 1 746 ms.

Resolved Issues​

  • A shader compile no longer writes out a chain of user functions whose branches all call the function below. With h_k(x) = \{x > c_k: 2h_{k-1}(x), 3h_{k-1}(x)\} (or a Which whose later conditions call it), the glsl and wgsl targets wrote the function bodies into the main body, where a call shared by two branches is written once in each, so the shader doubled at each level: 2.5 MB of GLSL and 388 s of compilation at depth 14. Such a chain now stays a set of shader functions that call each other.
  • Compiled code gave wrong values for a user function called with a list argument at a parameter that the interpreter binds whole. With R declared function and assigned (V, θ) ↦ [Σ V·[cos θ, -sin θ], …], R([x, y] + [-0.57, 2.8], φ) gave NaN on javascript and an empty interval on interval-js; the interpreter gives the value. The compiler read the parameter type that the body's use had narrowed (number), not the function's own signature. javascript now gives the interpreter's value, glsl and wgsl compile a copy of R with a vec2 parameter, and interval-js declines instead of giving a wrong interval.
  • A recursive function inside Mod declined on interval-js. f(x, n) := 1/\operatorname{mod}(f(x, n-1), 1) with f(x, 0) = x declined with "an object-domain absent ('missing') position". It compiles again.
  • A call through a parameter typed only by index reads compiles. In r(p) := (T + \lfloor p[1] \rfloor) \max(0, 0.5 - l(f(p))) with f(q) := \operatorname{mod}(q, 1) - 0.5, the reads p[1] typed p as a point, and the compiler passed it to f as a point, which Mod minus a number refuses. Such a type does not prove a point: the call is now compiled for the argument's own type (a list), as the interpreter evaluates it. d(B(A(P(x, y)))) in the Tycho document ra1p9naofs compiles on javascript and gives the interpreter's value.
  • On interval-js, a user function whose invariant part is subtracted computed it on every term. In \sum_{i=1}^{16} n(x, y, i) with n(x, y, i) := \exp(\ldots - F(x, y)), F(x, y) was computed 17 times per sample instead of once.
  • A letter that names a library operator is a free variable in an expression parsed in a scope. ce.parse('y=x+N', { scope: ce.createScope() }) had the unknowns ["x", "y"], and the same for D; read outside the scope, the name N found the library operator N. It now has ["N", "x", "y"], as the expression parsed without a scope, and as evaluate() and subs() already treated it.

0.171.0 2026-10-27​

Behavior Changes​

  • subs() of the variable of a derivative, a series or an indefinite integral gives the value at the point (ROADMAP CE-538). Before, D(x^2, x).subs({x: 3}) gave an invalid D(…, Error('expected-a-symbol')). It is now EvaluateAt(Function(D(x^2, x), x), 3), written \left.\left(\frac{\mathrm{d}x^2}{\mathrm{d}x}\right)\right|_{x=3}, which evaluates to 6 (a float under .N()).

    • This also applies inside a scoped expression: Sum(D(x^k, x), k=1..3) with x replaced by 2 evaluates to 17.
    • Integrate(a·x, x).subs({x: 3, a: 2}) evaluates to 9, and D(x^2, x).subs({x: x + 1}) to 2(x + 1).
    • Several variables are replaced at the same time, at one point that is a Tuple (|_{x=3, y=5}).
    • Only the variable itself is applied at a point. The replacement of another name is put in the derivative, the series or the integral before it is computed: D(x·y, x).subs({y: x}) is D(x·x, x), which evaluates to 2x.
    • A wildcard (_f) is still replaced inside the node. A value with no closed form stays an unevaluated EvaluateAt. The value of a Series at a point keeps its BigO remainder. A value that keeps a BigO term is NaN under .N(); Normal drops the remainder, before or after the substitution, and Normal now strips it before any numeric evaluation (Normal(Series(sin x, x, 0, 3)).N() was NaN).
  • EvaluateAt at a point has the value of Apply. The statements of the body run in order, and an If evaluates only the branch it selects. A point is never a partial application: when there are fewer points than required parameters, or more than a named callee accepts, the form stays unevaluated. A constant is its own value at any point: EvaluateAt(5, 3) is 5, and so is a named function that takes no argument.

    • A lazy callee receives the point as written, and the expression it returns is not evaluated again: EvaluateAt(p, Random()) for a lazy p that returns its operand gives Random() without a draw.
    • The form stays unevaluated only when a D, a Series or an Integrate with no closed form comes from the BODY: the identity at Series(e^{1/x}, x, 0, 3) is that series, and x ↦ x between 0 and an integral with no closed form is that integral.
    • A point that is a symbol whose value is a tuple is read at evaluation: with p := (2, 3), (x·y)|_{p} is 6.
    • EvaluateAt compiles: EvaluateAt(f, a) as the application f(a) and EvaluateAt(f, a, b) as f(b) − f(a), so the result of subs() on a derivative compiles to JavaScript. D of an EvaluateAt evaluates it first: the derivative in y of (x^2)|_{x=y} is 2y.
  • A function literal whose body is a derivative, a series or an indefinite integral of one parameter, applied to arguments that name another parameter, is a plain application. ((x, y) ↦ D(x·y, x))(3, x) is x (the derivative y at y = x of the caller); it was x + 3, a product rule mixed with the caller's x. ((x, a) ↦ ∫a·x dx)(x + 1, x) is x(x + 1)²/2; the point was lost. A function-valued parameter named like a user function no longer leaks its name: with w(x) := x + 100, (w ↦ D(w(x), x))(x ↦ 5·w(x)) is 5, and the same for Integrate and Series.

  • An indefinite integral under .N() computes its antiderivative. Integrate(Sin(x), x).N() is -cos(x) and (x ↦ ∫sin x dx)(1).N() is -0.5403…; both stayed an unevaluated Integrate (the numeric request sent the integral to the quadrature route, which has no bounds to use).

  • An evaluate handler written with anonymous parameters gets a signature with as many parameters. ce.declare('f', { evaluate: ['Function', ['Add', '_', 1]] }) is (unknown) -> unknown, not () -> unknown.

  • D(Length([x ↦ x]), x) is 0: an occurrence of the variable that a function literal binds as its parameter does not make the expression depend on the variable. Integrate(Apply(t ↦ t², x), x) is x³/3: an applied function literal in the integrand is applied before integration.

  • Canonicalizing a function literal whose raw body shares a subtree is linear in the number of distinct nodes. A body that shares the previous node twice at each level, at depth 24, took 4.9 s and takes 2 ms: the canonical form of a raw node is kept for the duration of one top-level canonicalization (ROADMAP CE-548).

  • A function literal whose body is D, Series or an indefinite Integrate of its parameter, applied to an argument that contains the parameter's name, gives the value at the argument: (x ↦ D(x^2, x))(x + 1) is 2(x + 1), not 2x. Under .N(), (x ↦ D(x^2, x))(1 + 2.5) is 7, not 2x.

  • The LaTeX of EvaluateAt parses back to the same EvaluateAt. \left.x^2\right|_{x=2} was Subscript(EvaluateAt(x^2), x = 2). The two-point form is now written with the name of the variable, \left.\left(\frac{x^3}{3}\right)\right|_{x=0}^{1}. In a bracket with no name for the variable and more free variables than coordinates in the point, the point or the bounds are for x: \left.kx\right|_{2}^{5} is 3k, while \left.\left(xy\right)\right|_{(1,2)} is 2. \left.f\right|_{A} and a bound that is a tuple keep their earlier parse.

  • Numeric integration reports an error that bounds the true error in more cases (ROADMAP CE-533, CE-376). The error reported for most integrals changes; smooth integrands use the same number of evaluations.

    • The error of a Gauss–Kronrod panel now also comes from null rules (Berntsen–Espelid), with no extra evaluations, so a kink or a jump inside a panel is counted. For the inner integral ∫₋₁₀¹⁰ n(x, 0.758125) dx of a Tycho document, the result was ± 5.7e-10 with a true error of 1.6e-5; it is now ± 9.3e-10 with a true error of 2.3e-11.
    • Far from 0, the rounding of the sample points to the doubles is counted: ∫ sin(10³·(x − a)) dx over [10⁶, 10⁶ + 0.01] was converged, ± 2.2e-13, for a true error of 2.3e-11; it is now not converged, ± 6.3e-10. Some accurate results are now reported as not converged for that reason (∫ e^(x − a) dx over [10⁶, 10⁶ + 0.01]).
    • With singularEndpoints, a singular point that is not exactly a double is found: ∫₋₁¹ |x − 0.3|^(−1/2) dx was ± 3.9e-10 for a true error of 1.3e-8 after 28,480 evaluations; it is now ± 4e-13 for 3e-14 after 6,117. A singular point a few doubles inside or outside an endpoint (for example |t − 0.1·3|^(−1/2) or |t − (0.7 − 0.4)|^(−1/2) on [0.3, 1]) now gives an error that bounds the true error.
    • An interval is no longer split at a jump, and a step next to a singular endpoint is no longer taken for a singularity: (x < 0.3 ? 0 : 1) + |x − c|^(−1/2) with c = 0.3 + 10⁻⁶ was converged, ± 3.1e-12, for a true error of 1e-6; it is now not converged, with an error that bounds the true error.
    • At a singular endpoint far from 0, the area between the endpoint and the first double above it, which no sample reaches, is now counted in the error (ROADMAP CE-552): (x − 10⁶)^(−1/2) on [10⁶, 10⁶ + 1] was ± 1.4e-5 for a true error of 2.3e-5 and is ± 5.7e-5. In a sweep of 180 such cases, the error was below the true error in 124 and is in none; the six cases that lose their converged status had a wrong error. A zero of the smooth factor a few doubles from the endpoint gets a fallback bound.
    • A step next to a slowly decaying endpoint singularity (x^(−0.95)·(x < s ? 2 : 1) on [0, 1]) no longer reports converged with an error below the true error (ROADMAP CE-547): the last panel next to the endpoint is compared with the model of the shells.
    • A pole 10⁴ to 10⁵ doubles outside an endpoint is detected (ROADMAP CE-558), and the error charged for a pole outside is the area the corner model misplaces, not the whole model area: (x − c)^(−1/4) on [10⁶, 10⁶ + 1] with c 1000 doubles below was ± 1.3e-7 and is ± 1.3e-10 for a true error of 1e-11. An exact zero of the integrand at the sample that gives the exponent of a singularity inside an endpoint no longer gives an infinite error (ROADMAP CE-557).
    • The semi-infinite oscillatory quadrature returns a finite estimate with an infinite error, not NaN, when one lobe cannot bound its error (ROADMAP CE-549).
    • The nodes of a Gauss–Kronrod panel are clamped to the panel: on a panel a few doubles wide, a node could round one double outside it, and √(x − 1) on [1, 1 + ε] gave NaN. An interval one double wide still reports 0 ± 0 (ROADMAP CE-561).
  • The interval library gives sound and tighter answers at poles, at domain boundaries and for powers (ROADMAP CE-522). These kernels are the ones that the code compiled with compile(…, { to: 'interval-js' }) calls, so every user of that target sees the change. The design review of the Enclosure value kind (see Features) found them: some answers excluded a true value, others were wider than necessary.

    • mod with a divisor interval that is not a point: mod([5, 5], [2, 4]) was [1, 1], which excludes Mod(5, 3) = 2 and Mod(5, 2.5) = 0. It is now the hull [0, 4] (a singular result with that value). When the quotient has one integer part over the whole box, the answer is the continuous range: mod([5, 5], [2.6, 4]) is [1, 2.4], rounded outward.
    • atanh at the boundary of its domain: atanh([0, 1]) was partial [0, 18.3684…], a clip of the argument to 1 − ε. It is now partial [0, +∞], and atanh([1, 1]) is empty.
    • A pole at an ENDPOINT gives a half-line. tan([0, Math.PI / 2]), cot([0, 1]), sec([0, Math.PI / 2]), csc([-1, 0]), gamma([0, 1]), powRational([-1, 0], -2, 3) and powRealBranch with a negative exponent were singular with no value (the whole line). They are now partial with the half-line beside the pole: gamma([0, 1]) is [1, +∞] and powRational([-1, 0], -2, 3) is [1, +∞]. A pole inside the interval still gives singular. A point at a pole has no value: gamma([0, 0]) and powRational([0, 0], -1, 3) are empty; tan, cot, sec and csc at a point that is their pole stay singular, because the interpreter answers ~oo there.
    • pow with a negative integer exponent over a base with a zero endpoint: pow([0, 1], -1) was partial [−∞, +∞] and is now partial [1, +∞]; pow([-1, 0], -1) is [−∞, −1], and pow([0, 0], -1) is empty.
    • powInterval with a negative base over an exponent interval: powInterval([-2, -2], [1, 2]) was empty, although (−2)^1 and (−2)^2 exist. It is now [−4, 4], the symmetric hull [−M, M] with M = max(|b|^c, |b|^d), which contains every real value of the power, also at an exponent that is a fraction with an odd denominator ((−2)^(5/3) ≈ −3.17). A base that crosses 0 takes the hull of its two parts: [-1, 1]^[1, 2] was partial [0, 1] and is [−1, 1]. At an exponent that is a POINT and not an integer, a negative base still has no value (powInterval([-2, -2], [1.5, 1.5]) is empty). Visible to Tycho's adaptive sampler (its rows 260 and 417): a box with x < 0 and an exponent interval that holds an integer is now an undecided hull that the sampler subdivides, instead of a blank region; at a non-integer point exponent the region stays blank.
    • pow with a negative integer exponent rounds outward at each step. For the double x = 1.9678349534779586e-103, pow([x, x], -3) was [1.3123025984121744e308, 1.3123025984121748e308], which excludes the true value (its nearest double is 1.3123025984121742e308). It now contains it. An exact power stays exact: pow([2, 2], -1) is [0.5, 0.5].
    • An angle in degrees, gradians or turns: the interval-js target multiplied the angle by the double nearest to the factor (Math.PI / 180, 2 * Math.PI), which can be below the true factor. In turn mode, the compiled cos(x) at the point 0.25 had two positive bounds and excluded the true value 0. The target now uses the exact factor (π/180, π/200, 2π, and its reciprocal for the inverse functions), enclosed outward as any other exact constant, also in the derivatives that the target compiles. The compiled cos and arcsin now contain the true value at a quarter turn in deg, grad and turn mode. The other targets keep the float factors.
    • fract([-1e-20, -1e-20]) was [1, 1], while the true fractional part is below 1. The subtraction x − floor(x) is now rounded outward; an exact case stays exact.
    • The other kernels with a pole now also give the half-line for a pole at an endpoint: coth([0, 1]) is [coth(1), +∞] (was singular with no value), and the same for csch, acoth, acsch and asech at 0 and for gammaln at a non-positive integer (gammaln([0, 1]) is [0, +∞]). A pole inside the interval, or a point at the pole, is still singular. asech([-1, 0]) is empty.
    • mod with a divisor interval: when the quotient has one integer part over the whole box, the answer takes no extra outward step: mod([1, 1], [2, 4]) is [1, 1] (was one ulp wider on each side).
    • Root(x, n) with a symbolic degree n, compiled to interval-js, reads the degree at run time: an integer degree keeps its exact value. With n = 6, x = [−64, −1] gives empty (was [−2, 2]); with n = 3, [−4, −1] (was [−4, 4]); a negative integer degree is the reciprocal root (n = −3, x = −8 gives −1/2). Another degree takes the power x^(1/n) as before.
  • Degrees refuses a complex number. An angle is real, and the signature of Degrees is (real) -> real. Degrees(2 + 3i) was (1/90 + i/60)·π; it is now an incompatible-type error, as Degrees((2 + 3i) ± 0.1) already was. A symbol declared complex that holds a real number still converts. NaN and the infinities are not changed.

  • The interval-js runner checks the value of an input whose type can hold an enclosure. Before, this target declined every expression with a value kind. Now, for a free symbol whose type is enclosure or a union with an enclosure arm, run() checks the value BEFORE it converts the inputs: a record { lo, hi } must have lo <= hi, no NaN and not the same infinity twice, and a number is a point. A union input is checked by arm: with y: number | enclosure, y: Infinity is accepted by the numeric arm. A refused value throws (it is not turned into an entire result). broadcast() and broadcastAsync() check every cell before the loop, so one refused cell rejects the whole call. The inputs of every other type are not changed, including the NaN that marks an absent value of a real | missing input.

  • On interval-js, a decline of an expression with an enclosure is not replaced by the interpreter, also with fallback: true: it throws with the message of the kind. The fallback of this target gives each input as the midpoint of its interval, and would give a number to a symbol typed enclosure. The other value kinds (Measurement, Quantity) keep the fallback.

  • The target of an Assign outside a Block is a free variable. The target is a write to a symbol of the enclosing scope, so unknowns and freeVariables list it. Before, any Assign, Declare or DefineFunction operand made its target local to the node that held it. Example: y=(k\coloneq2) (Equal(y, Assign(k, 2))) had the free variables ["y"] and now has ["k", "y"], as Assign(k, 2) alone always had. A Block still keeps the names it assigns or declares local: (k\coloneq2; kx) has the free variable x only. If(z > 0, Assign(z, 5), 0) lists z. The subs() of such a target is still open (ROADMAP CE-537).

Features​

  • Smoothstep(e0, e1, x) is defined in the engine. The shader targets already lowered it to the GLSL and WGSL built-in smoothstep, but the library had no definition: it stayed unevaluated, and the javascript target did not compile it. It now evaluates to t² (3 − 2t) with t = Clamp((x − e0) / (e1 − e0), 0, 1), exactly for exact operands (Smoothstep(0, 1, 1/3) is 7/27), and broadcasts over lists. The javascript and python targets compile it. When e0 = e1 the result is the step function (0 when x < e0, else 1), and when e0 > e1 the formula applies as written. The shader targets now write the formula out instead of calling the built-in smoothstep, which has no defined result when e0 ≥ e1, so every target gives the same value.

  • Enclosure, an interval value with interval arithmetic (ROADMAP CE-507 phase 3, #416). Enclosure(lo, hi) is a closed interval that holds ONE real number, whose value is somewhere in [lo, hi]. It is not the set Interval(lo, hi): Abs(Enclosure(-1, 2)) is Enclosure(0, 2), while Abs of the set is its cardinality. It is a library value kind, beside ResidueClass, Measurement and Quantity, with the kind type enclosure, and it is accepted wherever a real is accepted (admittedAs: 'real'). Its LaTeX is \mathrm{Enclosure}(a, b).

    • The canonical form orders the endpoints (Enclosure(2, 1) is Enclosure(1, 2)), reads one operand as a point (Enclosure(3) is Enclosure(3, 3)), and converts a set Interval (Enclosure(Interval(0, 1)) is Enclosure(0, 1)). An endpoint that is not an extended real, or is a value of a kind, is an incompatible-type error. A NaN endpoint, the same infinity twice and an empty set give the indeterminate value.
    • The arithmetic hooks compute with the endpoints, and keep them exact under evaluate(): Enclosure(0, 1) + Enclosure(1/3, 2) is Enclosure(1/3, 3), Enclosure(-1, 2) · Enclosure(3, 4) is Enclosure(-4, 8), and Enclosure(1, 2) − Enclosure(1, 2) is Enclosure(-1, 1) (the two operands are independent). A division by an enclosure with a zero endpoint gives a half-line (1 / Enclosure(0, 1) is Enclosure(1, +oo)), and with 0 inside the whole line.
    • A numeric function gives its image (the apply hook), with the critical points of the function: Sin(Enclosure(0, Pi)) is Enclosure(0, 1), Sin(Enclosure(0, 1)) is Enclosure(0, Sin(1)), Sqrt(Enclosure(-1, 4)) is Enclosure(0, 2) (the domain is clipped), Tan(Enclosure(0, Pi/2)) is Enclosure(0, +oo), and Ln(Enclosure(-2, -1)) is indeterminate. Under evaluate() with exact endpoints the endpoints are exact. Under .N(), the endpoints are converted to doubles OUTWARD (the largest double below lo, the smallest above hi) and the interval library computes the image: Enclosure(0, Pi).N() is Enclosure(0, 3.1415926535897936), and Sin(Enclosure(0, 1)).N() is Enclosure(0, 0.8414709848078966). The endpoints under .N() are machine doubles at every ce.precision. They are rigorous for the rational operations and best effort for the transcendental functions (ROADMAP CE-522.5).
    • The comparisons use the partial order of intervals: Enclosure(0, 1) < Enclosure(2, 3) is True, and Enclosure(0, 2) < Enclosure(1, 3) stays unevaluated. Two enclosures that touch decide only the non-strict comparison: Enclosure(0, 1) <= Enclosure(1, 2) is True and > is False, while < stays unevaluated. Enclosure(1, 1) = 1 is True; two enclosures that overlap may hold the same real, so Equal stays unevaluated.
    • Max, Min, Clamp, ElementMax and ElementMin give the hull of the extrema: Max(Enclosure(0, 2), Enclosure(1, 3)) is Enclosure(1, 3), and Max(Enclosure(0, 1), +oo) is +oo.
    • Sum, Product and Mean of a list of enclosures and numbers fold through the hooks: Mean([Enclosure(0, 1), Enclosure(1, 2), 3]) is Enclosure(4/3, 2). The other statistics stay unevaluated.
    • The code compiled for the interval-js target computes with an enclosure: an input typed enclosure is a { lo, hi } record, and the compiled code calls the same kernel of the interval library as .N() for each form, so the two give the same doubles. With x declared enclosure, compile(ce.parse('x + 1'), { to: 'interval-js' }) gives { kind: 'interval', value: { lo: 1, hi: 2 } } for run({ x: { lo: 0, hi: 1 } }). The other targets decline an enclosure with the message of the kind.
    • Measurement(Enclosure(0, 1), 0.1) and Quantity(Enclosure(0, 1), m) are incompatible-type errors.

    The rules are in doc/06-guide-augmenting.md, "Enclosures"; the design is section 4.9.2 of docs/plans/2026-10-26-value-kinds.md.

  • Two extensions of ArithmeticDefinition for a value kind that you declare.

    • The optional extremum(which, ops, context) hook gives the maximum (which is 'max') or the minimum of operands of which one at least is a value of the kind, for Max, Min, Clamp, ElementMax and ElementMin. The engine asks it before it orders the operands with compare, so a kind whose values can overlap can answer a value that is not one of the operands. undefined declines, and the engine orders the operands with compare as before.
    • compare can answer '<=' or '>=': the first value is less than (or greater than) or equal to the second, and the hook cannot say which. On a '<=' answer, LessEqual is True and Greater is False, Less and GreaterEqual stay unevaluated, and Equal asks the eq handlers. A reader that needs a total order (Sort, and Max/Min without an extremum hook) reads it as no answer.

Performance​

  • The shader targets call the built-in sin, cos, sign, max and min, and write clamp as min(max(x, lo), hi), for an operand that is finite for certain. The NaN-safe helpers that replaced them (_gpu_sin and the others) cost a bit test and a selection per call, about 10% of the frame time of a Tycho document on WebGPU, and on a finite operand they give the same bits as the built-in (measured on an Apple M5 Max over WebGPU, 39,200 inputs). An operand is finite for certain when its static type is a finite real, each symbol it reads has a declared type (a symbol declared real holds a finite value), it reads no name that the caller maps with functions or operators and no name that vars maps to other source, and it cannot overflow in f32: it uses only heads whose result does not grow past their operands (+, −, a product or quotient by a literal, sin, floor, abs, …). A name that vars maps to its own identifier (vars: { T: 'T' }, the way a host passes a uniform), with a declared type and not a constant, counts as a declared symbol. sin(x) and cos(T + 1.9k) with declared real symbols are now the built-ins, also with vars: { T: 'T' }; sin(u) with u: number, sin(x/y), sin(√x), sin(e^x), sin(xy), vars: { T: 'uT' }, a mapped constant and the parameter of a function whose type is inferred keep the helper. One limit: a sum or a product by a literal of values within that factor of the largest f32 (3.4·10³⁸) overflows, and the built-in then gives 0 on Apple GPUs where the helper gave NaN.

  • The unit of a quantity is decoded once for each boxed value. The arithmetic and the comparisons of quantities read the unit expression, its dimension and its scale from a cache on the boxed unit, and do not walk the unit again at each operation. The cache is public as decodedPayload(): the hooks of a value kind that you declare can decode the operands of a kind value once for each boxed expression in the same way.

  • The canonicalization of a binder keeps the cached types (ROADMAP CE-535). D, Sum, Integrate and the other binders declare their bound variable in a scope created for them. That declaration no longer invalidates the cached types and signs of every expression, because no name was read through the new scope before. A row of a Tycho document with D(PointY(S(u)), u), where S is a user function with a large body, typed S(u) again after each D; the replay of the canonical rebinds of the document 2ki2hjsouf went from 848 ms to 664 ms (median of 5).

Resolved Issues​

  • A sign cached during a call of a function literal is not kept after the call. With f := x ↦ …(x + 1)…, a sign of the body node x + 1 computed during f(3) (positive) was still served after the call returned, where x has no value. The removal of the call scope that holds the parameter values now invalidates the cached types, signs and values that are keyed on the engine's cache version.

  • A Block that declares a name and calls a function that reads the same name no longer prints "error canonicalizing Block: Cannot read properties of undefined (reading 'parent')", and the function's derived signature is correct (ROADMAP CE-542). Example: b is declared real, f is declared (real) -> unknown and assigned p ↦ sin(b·p), and Block(Declare(b), Assign(b, 1), f(0.5)) is boxed. The call reads the signature of f, which is derived by boxing f's body again in the scope where f is defined. That boxing read the names of the block being canonicalized, so the free b of f was taken for the block's local: the signature of f became (real) -> number instead of (real) -> real, and when the block was boxed in a scope from ce.createScope() the name resolution walked past the root scope and threw. The value was not affected: f reads the b of its own scope. A signature is now derived with none of the names of the construct being canonicalized, and those names shadow only inside the scopes of that construct. Found on the Tycho document g(x) with b = 1, where g calls f(x) = sin(b·x).

  • On Apple GPUs, the complex shader helpers gave a finite value for a NaN part. The built-ins sin and cos of a NaN give 0 on WebGL2 over ANGLE Metal and on WebGPU over Metal, so exp(1 + NaN·i) was (0, 0) on both shader targets, and so were sin, cos, sinh and cosh of a complex value with a NaN part (measured on an Apple M5 Max). The helpers (_gpu_cexp, _gpu_csin, _gpu_ccos, _gpu_csinh, _gpu_ccosh, and also _gpu_csqrt and _gpu_cln) now test each part first and give NaN, as the interpreter and the javascript target do.

  • A function body over a value kind keeps x/x in two more cases (ROADMAP CE-507.3 and CE-507.4). A parameter that the declared signature types with a value kind is typed so in the body, so that x/x is not folded to 1 before a kind value arrives. This did not happen with an overloaded signature: with g: ((residue) -> residue) & ((integer) -> integer) and the body x ↦ x/x, g(ResidueClass(2, 5)) was 1 and is now ResidueClass(1, 5) (the parameter is typed with the union of its types in the arms). It also did not happen for a recursive function literal assigned to a symbol declared with a function type: the literal was boxed again to bind its call to itself, and that step dropped the type.

  • N() of an integral whose integrand restricts a list gives one integral per element (Tycho row 459). With E = [0.1, 0.5], ∫_0^1 y·When(∫_{−1}^{1} 1/ElementMax(E, |x|) dx, 0 ≤ y ≤ 5) dy gave NaN, and ∫_0^1 \{0 ≤ y ≤ 5: 1/ElementMax(E, y+1)\} dy stayed unevaluated. Both now give a list, here [1 + ln 10, 1 + ln 2]. A When(L, c) or a Which with list values, alone or in a product, is distributed over the elements of the list before the integration. A condition that is a list of booleans is a mask, as before.

  • A nested numeric integral of piecewise functions converges. The body ∫_0^1 y·∫_{−10}^{10} n(x, y, ε) dx dy of Tycho's document neyret/2agcgxq87y, where n has a jump and kinks, took about 25 s under N() and stopped at a relative uncertainty of 3e-4, which did not always contain the true error. It now takes about 0.3 s and is correct to 1e-9, for six values of ε. Two causes: a Gauss–Kronrod panel did not see a jump or a kink between its last node and its end, and reported an error near zero; and the error of an inner integral was not added to the error of the outer one. A single integral now makes 15 more evaluations of its integrand.

  • The exact integral of a piecewise with a Nothing branch is no longer wrong. ∫_0^1 Which(y < 1/2, y, True, Nothing) dy gave 5/8, because the antiderivative of Nothing was taken as that of a constant. It now stays unevaluated, and N() gives NaN, as for a Which with no default clause. N() of ∫_0^1 Nothing dx is NaN, as for Missing.

  • A list combined with a piecewise evaluates to a list (Tycho row 452). [0, 1, 2, 3]·Which(x > 1, 0, True, 1) with x free evaluated to Which(1 < x, [0, 0, 0, 0], True, [0, 1, 2, 3]), which is not a List. It now evaluates to [Which(1 < x, 0, True, 0), Which(1 < x, 0, True, 1), …], the same values at each x. The same applies under Add, Abs and a bounded Sum, and to a Which or an If whose values are lists of one length, when the condition is undecided. A Which with no default clause is not changed. When such a Which is combined with an operand, the result now has a last branch for "no condition holds": 3 + Which(x > 1, 0) is Which(1 < x, 3, True, NaN), the value that substituting x first gives.

  • subs() replaces only the free occurrences of a name (Tycho row 464). Comprehension(j + 1, Element(j, Range(0, 2))).subs({j: [4, 4, 5]}) replaced the index and its binding site, and the index of a Sum, the parameter of a Function and a Block local became Error nodes. A binder now keeps its bound variables: j + Sum(j, Limits(j, 1, 3)) with {j: 10} gives 10 + Sum(j, Limits(j, 1, 3)). The occurrences replaced are exactly those that freeVariables reports, so the bounds of a Limits clause, which read the enclosing binding, are still replaced. A substitution can still capture a name: Sum(j + k, Limits(k, 1, 3)) with {j: k} gives Sum(2k, …); rename the index first to avoid this.

  • On wgsl and glsl, a call that several user functions repeat with the same arguments is computed once per pixel (Tycho row 467). Each user function was emitted as its own shader function, and the common-subexpression pass worked inside one function only. The Metal compiler under WebGPU does not merge identical calls across functions, so Tycho's voronoi-diagram drew about 2.2 times slower on WebGPU than on WebGL. The pure, non-recursive user functions of a shader are now substituted at the root, and a sum with literal bounds that calls one is written out, so the existing pass computes each shared call once. On that row, sqrt is computed 16 times per pixel (was 560) and sin 32 times (was 544). The shader text is about 50% larger. An expression that draws a random number, or a sum that stays a loop, is compiled as before.

  • A recursive function whose step passes its own call to Max or Min is no longer typed any (Tycho row 468, a regression in 0.170.0). With S(a, b) = Max(a, b) and R(i) = Which(i = 0, 0, True, R(i − 1) + S(1, R(i − 1))), R(3) was typed any. The type of a call whose body is Max or Min over numbers and tuples is now number on its tuple arm, since Max and Min read the coordinates of a tuple (Max(1, (2, 3)) is 3), so R(3) gets the same type as R without the call to S.

  • Compiling a Sum or a Product whose bounds are above 2^53 no longer hangs. compile(\sum_{k=2^{53}}^{2^{53}+2} \sin(x+k)) never returned on javascript, glsl, wgsl and interval-js, because at 2^53 the index k + 1 is k again. The compile now declines with out-of-range, the answer the interpreter gives for the same bounds.

  • Reading a chain of lazy Map/Zip collections through index lists is linear in the depth of the chain (Tycho row 453). A read such as C[1] of Map(W ↦ max(W[1], W[2]), Zip(A[I], Map(…))), nested 4 deep over 20 elements, computed 282 560 inner elements (about 17 s). It now computes 160. A pure lazy collection keeps each element that a read by index computes, until a symbol it depends on is assigned again.

  • On interval-js, run() throws a ReferenceError instead of answering entire. A ReferenceError means that the compiled code names something that is not defined, for example a helper of an operator's compileHelpers that reads a variable from outside its own body. The compileHelpers documentation says that such a read throws on every thread, and the javascript target already threw. The function that createIntervalRuntime().load() returns for a stored result does the same. Other run-time errors still answer entire ("cannot bound"). A helper of compileHelpers is defined for run() on interval-js, which is now tested.

  • On wgsl, a piecewise branch that draws a random value compiles to an if statement when the branch draws once and nothing else in the same statement draws (Tycho row 466), so only the branch that is taken draws, as in the interpreter and on glsl. \operatorname{WithRandomSeed}(s, \{x<0: \operatorname{Random}(), 0\}) compiles, and so does 1 + it. Other shapes still decline, because a hoisted if runs ahead of the rest of its statement and could change the order of the draws. 0.170.0 already used the if form, but also for a branch that draws twice, such as (\operatorname{Random}(), \cot(\operatorname{Random}())), where it drew the second value first; that shape declines again. (The 0.170.0 note that such a branch no longer compiles on wgsl describes the release before.)

  • On glsl and wgsl, a random draw bound to a temporary no longer comes before a draw written before it. A lowering that uses an operand twice (\cot, \operatorname{Remainder}, the components of a vector in \max) binds an operand that draws to a temporary ahead of its statement, and a Sum or Product hoists its loop or its terms the same way. So (\operatorname{Random}(), \cot(\operatorname{Random}())) drew the operand of \cot first, and (\operatorname{Random}(), \sum_{k=1}^{n} \operatorname{Random}()) drew the loop first, unlike the interpreter and the javascript target. Such a shape now declines, and the interpreter evaluates it; a statement whose draws are bound in their written order (\operatorname{Variance} of a list of draws, a Sum that is written first) still compiles.

  • An interval value for interval arithmetic (#416): the Enclosure value kind (see Features). With this third phase, the value kinds of #477 are complete: the mechanism and ResidueClass (phase 1), Measurement and Quantity (phase 2), and Enclosure (phase 3).

0.170.0 2026-10-26​

Breaking Changes​

  • The package is ESM only, and Node 22.12 or later is required. The UMD builds (dist/umd-min/*.cjs), the require conditions of the exports map and the ComputeEngine script-tag global are gone. A CommonJS consumer keeps calling require('@cortex-js/compute-engine'): Node loads the ESM entry through require() since Node 22.12 (and 20.19), and no entry has a top-level await. engines.node is >=22.12.0 (it was >=22.3.0). A CommonJS Jest setup that imports the package loads it through Jest's own module loader, not Node's, and needs a transform exception for the package or an ESM-capable runner. The published files drop from 61 MB to 24 MB.
  • A program that loads only @cortex-js/compute-engine/core has the javascript and interval-js compilation targets. The GPU targets (glsl, wgsl) and the source-only python target are no longer bundled in that entry (together 408 KB minified). To use one there, import the target class from @cortex-js/compute-engine/compile and register it: ce.registerCompilationTarget('glsl', new GLSLTarget()). Once the full entry, …/epsil or the epsil CLI is loaded anywhere in the program, every engine has all five targets, an engine made earlier from …/core included. An unregistered target name fails with a message that lists the registered ones.

Packaging​

  • One copy of each class across the entries. Every entry except runtime and kernel-worker is now built in one code-splitting invocation, so the engine and the modules it shares with …/numerics, …/latex-syntax and …/interval are emitted once into shared chunks. A host that imported …/core and …/compile loaded two copies of the engine (compile.js was 3.5 MB on its own), and a NumericValue made from …/numerics failed the engine's instanceof checks. One effect: a JavaScriptTarget imported from …/compile compiled n(n-1)(n-2)/6, boxed by the main entry, to 0.16666666666666666 * … (559.9999999999999 at n = 16); it is now (…) / 6, which gives 560 (#480, reported by enumeratio). runtime and kernel-worker stay single self-contained files.
  • ce.registerCompilationTarget(name, target) and ce.unregisterCompilationTarget(name) are public. The compile guide documented the first under that name, but only the internal _registerCompilationTarget existed.

Behavior Changes​

  • The code that a caller's operator compile handler writes no longer goes to the worker pool (ROADMAP CE-518, Tycho row 460). Such code can call a function that exists only on the thread that compiled it, for example globalThis.myFloorIndex(…). The result had shippable: true, so broadcastAsync() sent it to the workers, where every cell threw: each value was NaN, and the batch still reported every cell as done. Now a result with code from a caller handler has shippable: false and runs on the calling thread (reason: 'not-shippable'). This also holds for a handler attached in place to a built-in definition (ce.lookupDefinition('At').operator.compile = …). The handlers of the standard library are not affected. To send such code to the pool again, set compileShippable: true on the definition, and give the functions that the code calls in compileHelpers (see Features).
  • A point parameter accepts a list that can hold both points and lists. With k: (tuple<number, number>) -> number and y declared list<P | list<P>>, type A = list<A | P> or type B = list<B> | P (P is tuple<number, number>), k(y) maps by the depth of the value: a point gives a number, [(1, 2), (3, 4)] gives [3, 7] and [[(1, 2)]] gives [[3]]. These calls were refused for every value. A value in which one list holds both a point and a list, such as [(1, 2), [(3, 4)]], still gives an incompatible-type error (the compiled call gives NaN), and the type of the call includes error for it.
  • A point parameter accepts an empty list. k([]) is typed list<number> (it was number, for the value []), and a symbol declared list<never> that holds [] gives [] (it was refused).
  • A point beside an operand that may be a list is typed as a point or a list of points (CE-504). With L: list<unknown>, (x, y)·(2·L[1]) was typed tuple<number, number>, while L := [[1, 2]] gives [(6, 8), (12, 16)]; it is now tuple<number, number> | list<tuple<number, number> | list<any>>. With b: broadcastable<number>, (x, y)·b and (x, y)/b are broadcastable<tuple<number, number>>, and (x, y) + b is broadcastable<error>. A list of points beside a collection pairs each point with an element: p·t with t: tensor<integer> is list<broadcastable<tuple<…>>>; p·v with v: vector<integer^2> is list<tuple<…>>. Compiled JavaScript gives the interpreter's value for a list of points times or divided by a tensor of numbers (it gave wrong values for a matrix), and refuses an operand whose cells may be points.
  • A list of ranges of the same length is accepted as a matrix by the linear-algebra functions. Determinant([1..2, 3..4]) is -2, and Inverse, MatrixMultiply, Dot, LinearSolve and the other functions whose parameter is a matrix read each range as a row; they refused the list with incompatible-type. The list keeps its list<range> type, so [1..2, 3..4]^{-1} and \cdot still apply element by element.
  • A recursive alias of lists of points is accepted at a point parameter. With A = list<A> | list<tuple<number, number>> and y: A, k(y) maps by the depth of the value ([(1, 2), (3, 4)] gives [3, 7]); it was refused. A union of a list of points and a list of lists of points (list<P> | list<list<P>>) also maps by the depth of the value, and the compiled call of a point-or-point-list union now gives the same values as the interpreter (it was refused).
  • An element-wise operation over an operand of type unknown holds a ragged list or a point (CE-502). With L: list<unknown>, 2·L[1] was typed broadcastable<number>, which does not hold [[1], [2, 3]] or (1, 2) in L[1]; it is now list<list<any> | number | tuple> | number | tuple. The same holds for k(L[1]) with k: (real) -> real, and for a call of an undeclared function. A comparison over such an operand can be one boolean ([1, 2] = L[1] compares two whole lists). Compiled code is unchanged. A valueless symbol used as a number is still inferred number.
  • A function with an inferred signature, called with a string or a boolean, is typed from its body (CE-490). With f := x ↦ (-x, x), f("s") was typed tuple<number, number> and is error; with g := x ↦ x + 1, g("s") was number and is error.
  • A parameter used in arithmetic before an index read has a sound signature (CE-503). (P, t) ↦ t·P/(P[1] − 1) was (P: unknown, t: unknown) -> never and is (P: indexed_collection<number>, t: unknown) -> list<number>; applied to ([3, 2], 2) it gives [3, 2], not a list of errors.
  • A product of a list of points and a point is one no-product-between-points error at evaluation, as it already was when boxed (CE-491). With pd := x ↦ x·x[1], pd([(3, 2), (1, 4)]) was a list of two errors.
  • The terms of a sum or a product with a residue class that are not classes are combined by evaluate() (CE-507). x + x + ResidueClass(1, 5) evaluates to 2x + ResidueClass(1, 5) (it was returned as written; only simplify() combined it), and (√2 + π + ResidueClass(1, 5)).N() is 4.5558… + ResidueClass(1, 5) (the two floats were left apart). The rules for the classes themselves do not change.
  • The signatures of the arithmetic operators no longer name residue. Multiply is (number*) -> number again (it was ((number | residue)*) -> number), and Divide, Negate, Power, Root, Sqrt, Square and Subtract lost their residue arm in the same way. They accept a residue class, and a value of any kind that has the matching hook, by a rule that reads the declared kinds, not by their signature. A host that reads these signature strings sees the change, and an incompatible-type error of these operators no longer names residue as an expected type. An engine built without the ResidueClass definition no longer accepts a residue operand at these operators.
  • Two values of a kind written next to each other are a product, as two residue classes are: with GF8 a head of the kind type gf8, \operatorname{GF8}(3)\operatorname{GF8}(5) parses to a Multiply, not to a Tuple. A list of such values is typed list<gf8^2> (it was list<gf8>).
  • Sum, Product and Reduce over residue classes are typed residue. Sum([c1, c2]) was typed number and Map(Negate, [c1, c2]) vector<2>; they are residue and list<residue^2>.
  • A measurement is typed measurement, and a quantity is typed quantity (CE-507). Measurement(2.5, 0.1) was typed with the type of its nominal (real; integer, rational or complex for other nominals), and Quantity(2, m) was typed value. measurement and quantity are new primitive types of the type language. They are kind types: each one is a subtype of value and is disjoint from the number types, so ce.type('measurement').matches('real') is false. A host that compares type strings sees the change, and a host that declared a type named measurement or quantity must rename it. The result type of an operator with a measurement operand changes in the same way: Floor(2.5 ± 0.1) was typed integer and is measurement | number (a propagated uncertainty of 0 gives a plain number), 2 + (2.5 ± 0.1) is measurement, and Max(2 ± 0.1, 2) is integer<2..2> | measurement.
  • A symbol or a function with a body that is declared over a number type refuses a measurement. With s: real (or number, integer), ce.assign('s', Measurement(2, 0.1)) throws; it assigned the measurement. With f: (real) -> real and f := x ↦ x^2, f(2 ± 0.1) is an incompatible-type error; it was 4 ± 0.4, from a body that the canonical form could have changed (x/x is 1 for a real parameter). Declare the parameter real | measurement to accept one. An operator of your own declared over real with an evaluate handler (h) now leaves h(5 ± 0.1) unevaluated: the measurement does not know the derivative of h, so it does not call the handler (it gave 2 · (5 ± 0.1) for evaluate: ([x]) => x.mul(2)). An operator that handles a measurement itself names measurement in its parameter type, and its handler runs.
  • A quantity is refused at every operator that does not accept it by type. The arithmetic operators, the comparisons, Max, Min, Clamp, Sort, the operators with a value parameter (UnitConvert, List), the 24 trigonometric functions and Abs accept a quantity. Every other operator gives an incompatible-type error at boxing: Round(2.7 m), Floor(2 m), Sign(2 m), Exp(2 m), Hypot(2 m, 3) and InverseHaversine(2 m) stayed unevaluated or gave a wrong value, and Mean([2 m, 4 m]) stayed unevaluated.
  • The trigonometric functions and Abs name quantity in their signatures. The 24 trigonometric, hyperbolic and inverse functions (Sin to Arcsch) are (complex | quantity) -> number (a function that accepted infinity or signed_infinity keeps that arm), and Abs is (complex | infinity | quantity) -> number. So Arctan(2 deg) folds the angle to Arctan(π/90) as the other functions do (it stayed Arctan(2 deg)), Abs(-2 m) is 2 m, typed quantity (it stayed unevaluated, typed value), and Sin(2 m) is typed number (it was value) and stays unevaluated. The incompatible-type error of these functions names the quantity arm as an expected type (Sin("a") expects complex | quantity), and so does the type of Derivative(Sin).
  • A measurement propagates its uncertainty through every operator with a known derivative, and stays unevaluated where the rule has no answer. The rule is f(a) ± |f′(a)|·σ, and the quadrature √(Σ |∂f/∂xᵢ|²·σᵢ²) for several uncertain operands, with the modulus of each partial derivative. A propagated uncertainty of 0 gives the bare value (Cos(0 ± 0.1) is 1, as before). The rules by operator:
    • A step function (Floor, Ceil, Round, Truncate, Sign, Heaviside, PrimePi) of a real measurement gives the bare value of the function at the nominal: Floor(2.5 ± 0.1) is 2. When a jump of the function lies within one σ of the nominal, the call stays unevaluated: Floor(2.95 ± 0.1) was 2. A complex nominal at Sign stays unevaluated.
    • Mod and Fract propagate with the slope between their jumps: Mod(5 ± 0.1, 3) is 2 ± 0.1 (it was 2), and Mod(7, 3 ± 0.1) is 1 ± 0.2. A jump within one σ leaves the call unevaluated.
    • An infinite value or an infinite derivative at the nominal leaves the call unevaluated: Sqrt(0 ± 0.1) was 0, Arcsin(1 ± 0.1) was π/2; Ln(0 ± 0.1), Gamma(0 ± 0.1) and Tan(π/2 ± 0.1) are unevaluated too. A float nominal near a pole (Tan(1.5707963 ± 0.1)) gives the large finite value with its large uncertainty.
    • A real nominal whose value is not real keeps an uncertainty: Arccos(2.5 ± 0.1) is 1.5668i ± 0.0436 (it was the bare 1.5668i), and Ln(-1 ± 0.1) is πi ± 0.1.
    • Hypot, Degrees, Haversine, InverseHaversine, Factorial, Log with an uncertain base, Arctan2, Root with an uncertain index, Norm with an uncertain order, Round with an uncertain step, PreIncrement, PreDecrement, and the multilinear operators Dot, Cross, MatrixMultiply, HadamardProduct and Trace propagate every uncertain operand. Dot([2.5 ± 0.1, 2, 3], [1, 2, 3]) is 15.5 ± 0.1.
    • Binomial, Choose, Pochhammer, Determinant, Inverse, Eigenvalues, ContinuedFraction, ComplexRoots, Arg, Argument and BaseForm stay unevaluated with a measurement operand, and so does an operator whose derivative the engine does not know (Zeta, Beta, the order of BesselJ). Binomial(5 ± 0.1, 2) was (5 ± 0.1)·((5 ± 0.1) − 1)/2 and Determinant([[1 ± 0.1, 2], [3, 4]]) was -2 ± 0.4: these formulas use the same measurement more than once, and the arithmetic of measurements counts each occurrence as an independent error.
    • Mean, Median, Variance, PopulationVariance, StandardDeviation and PopulationStandardDeviation of a list that holds measurements give the statistic of the nominals with the propagated uncertainty of the independent data. The other statistics (Skewness, Kurtosis, …) stay unevaluated.
    • Real, Imaginary, Conjugate and Abs keep the uncertainty of the operand: Abs(0 ± 0.1) is 0 ± 0.1.
  • A comparison of measurements uses the nominals. The uncertainties take no part: 1 ± 0.1 = 1 ± 0.5 is True, as before.
    • Max and Min decide by nominal and keep the first operand of a tie: Max(1 ± 0.1, 1 ± 0.5) is 1 ± 0.1 (it stayed unevaluated), and Max(2 ± 0.1, 2) is 2 ± 0.1 (it was 2).
    • Clamp(x, lo, hi) returns x when x equals a bound: Clamp(3 ± 0.1, 0, 3) is 3 ± 0.1 (it was 3).
    • ElementMax and ElementMin compare as Max and Min do.
    • A measurement with a complex nominal has no order: (2 + 3i) ± 0.1 < 3 is False, as 2 + 3i < 3 is, and Max of it stays unevaluated. Its equality is the equality of the nominal: Equal((2 + 3i) ± 0.1, 2 + 3i) is True.
    • A measurement is not exact: Max(3/2 ± 1/10, Indeterminate) is NaN, not Indeterminate.
  • A comparison of quantities converts to the finer unit and uses the engine tolerance there, also in Max, Min, Clamp, ElementMax, ElementMin and Sort. Max(1 m, 1.000000000001 m) is 1 m: the extrema compared with a tolerance of zero. The tolerance applies in the finer of the two units, so Equal(1 m, 100.0000000001 cm) stays False. A quantity with a magnitude that is a float is not exact: Max(1.5 m, Indeterminate) is NaN.
  • The methods .add(), .mul(), .div(), .pow(), .sub() and .sqrt() combine measurements and quantities as the evaluation does. M.add(M2) for M = 3 ± 0.1 and M2 = 2 ± 0.1 is 5 ± 0.141 (it was the sum (3 ± 0.1) + (2 ± 0.1)), and (2 m).add(30 cm) is 2.3 m (it was 30 cm + 2 m). .neg() does not call the negate hook of a kind: c.neg() is Negate(c), and Negate(c).evaluate() negates the value.
  • .re and .im of a measurement are the parts of its nominal, as before: Measurement(2.5, 0.1).re is 2.5. The numeric facts of a measurement (isPositive, sgn, isInteger) are undefined; they were the facts of the nominal (Measurement(4, 0.1).isInteger was true). So the canonical forms of the numbers do not apply to a measurement.
  • The uncertainties of several occurrences of one measurement are independent. The engine does not know that two occurrences of M are the same measurement: with M = 1 ± 0.1, Sin(M)^2 + Cos(M)^2 simplifies to 1 ± 0.13 (it simplified to 1), and M − M is 0 ± 0.141. The operators that use an operand more than once in their own formula (Binomial, Determinant, …) stay unevaluated for this reason.
  • A physical constant opens the quantity rules only when it is read. The value of SpeedOfLight and of the other physical constants is a quantity. An engine that never makes a quantity does not pay the cost of the quantity rules: the constants made while the libraries load do not count, and each constant makes its value again when it is first read.
  • Compiling an expression with a measurement or a quantity fails with a message that names the kind type ("holds a value of the kind type 'measurement'"). It failed before with "Could not compile Measurement". Neither kind has a compiled representation.

Features​

  • An operator compile handler can give the functions its code calls (ROADMAP CE-518). The new compileHelpers field of an operator definition maps a name to a function. The engine copies the source of each function into the compiled code, as const <name> = (<source>);, so the code carries its helpers to a worker of the pool and to a stored result. With compileShippable: true, the result can then run on the pool:

    ce.declare('Wrap', {
    signature: '(number, number) -> number',
    compileShippable: true,
    compileHelpers: { wrapIndex: (i, n) => ((i % n) + n) % n },
    compile: (args, compile, { language }) =>
    language === 'javascript'
    ? `wrapIndex(${compile(args[0])}, ${compile(args[1])})`
    : undefined,
    });

    A helper must not read a variable from outside its own body, and its name is an identifier that does not start with _. A built-in function, a bound function or a method has no source that can be copied, and the declaration refuses it. The compile contract of a value kind has the same field, arithmetic.compile.type.helpers (ROADMAP CE-507.1); hostNames there now names only the functions of the host that the code does not carry.

  • broadcastAsync() reports the cells that threw. The new failed field of BroadcastAsyncResult counts the cells, among the first done, whose evaluation threw, on the calling thread and on the workers. The value of such a cell is NaN, as for a cell whose value is not a number, so this count is the only way to tell the two apart. done keeps its meaning: a cell that threw was evaluated.

  • An operator can declare the arithmetic of the values it constructs: a value kind (ROADMAP CE-507, #477). With MyMod declared (integer, integer) -> residue, x + MyMod(2, 4) + MyMod(1, 4) and MyMod(2, 4) - MyMod(2, 4) stayed as they were: only ResidueClass values were combined, and no definition could give the arithmetic of another head. The new arithmetic field of an operator definition gives it:

    • The field (ArithmeticDefinition) has the hooks add and multiply (they answer folds, KindFold: the operands they combined and the value they make), negate, divide, power (also called for Sqrt, Root and Square), compare (one order for the comparisons, Max, Min, Sort, Clamp and the methods isEqual(), isLess(), …), apply (called in place of the handler of another operator that accepted the value as a number), and the fields admittedAs, resultType and compile. The new public types are ArithmeticDefinition, KindOperands, KindOperand, KindFold, ArithmeticContext and ArithmeticCompile. With MyMod declared with add, multiply and negate, x + MyMod(2, 4) + MyMod(1, 4) is x + MyMod(3, 4), and MyMod(2, 4) - MyMod(2, 4) is MyMod(0, 4), in evaluate() and simplify().
    • The result type of an operator with an arithmetic field must be a kind type: residue, or a nominal type defined as exactly value and declared with { mint: false }, such as ce.declareType('gf8', 'value', { mint: false }). Such a nominal type is a subtype of value and expression, and is disjoint from every other type, as residue is: the engine keeps the number rules (x - x = 0, x/x = 1, like terms) away from its values and from a symbol declared with it. Any other result type (integer, value, residue | integer, a nominal type with another definition) is refused when the operator is declared, with an error that names the rule.
    • The mint option of ce.declareType() is public. { mint: false } declares a type without a constructor of the same name; a kind uses it, because its values are made by its own operator.
    • admittedAs: 'real' or 'number' lets every operator that accepts a real (or a number) accept the values, and apply then computes the result: a measurement kind can propagate its error through Sin and Floor. admittedAs: 'none' (the default, and the rule of residue) admits the values at the arithmetic operators only.
    • compile gives a compiled representation to a kind type (compile.type) and to the literals of a head (compile.value). Code compiled with it reports the kind types in the new CompilationResult.valueKinds, and the bindings of a kind type in StoredEntryPlan.kinds; it is returned without the number conversion of run(), and createJavaScriptRuntime().load() refuses it, because the run-time check of a kind is a function of the host. Without compile, an expression with a value of a kind declines, as a residue class does.

    ResidueClass now declares its arithmetic with this field. The design, which also covers Quantity, Measurement and an interval value (#416) in later phases, is docs/plans/2026-10-26-value-kinds.md. The guide is doc/06-guide-augmenting.md, "Declaring a Value Kind"; the type rules are in doc/08-guide-types.md, "Kind Types".

  • Measurement and Quantity are value kinds (ROADMAP CE-507 phase 2, CE-508, #477). Their arithmetic, their order and their propagation of an uncertainty are now the hooks of their arithmetic field, and the operators no longer test the names Measurement and Quantity. The visible changes are listed under Behavior Changes and Resolved Issues.

    • Measurement has the kind type measurement and is accepted where a number is accepted (admittedAs: 'number'). An operator that accepted the measurement as a number calls the apply hook of the kind, which computes the value at the nominal and propagates the uncertainty.
    • Quantity has the kind type quantity and is accepted only by the arithmetic operators, the comparisons, the operators that name quantity and the operators with a value parameter (admittedAs: 'none'). Its add hook makes one sum for each physical dimension, in the unit with the largest scale, and reads how each term was written to add temperatures with an offset: 20 degC − 5 degC is 15 K, and 20 degC + 5 degC is 571.3 K, as before.
    • ArithmeticDefinition.compare can answer 'unordered': the two values are known and have no order (a measurement with a complex nominal). The order operators then answer False, Equal and NotEqual ask the eq handlers, and Max, Min and Sort stay unevaluated.
    • ArithmeticDefinition.isExact(value) says whether a value of the kind is exact, for the rule that an inexact operand turns Indeterminate into NaN. Absent: the values are exact.
    • ArithmeticDefinition.isNaN(value) says whether a value of the kind stands for no value, as the number NaN does (a measurement with a NaN nominal, a quantity with a NaN magnitude). Such a value follows the rule of NaN: Max, Min, ElementMax, ElementMin and Clamp give NaN, the order operators give False, and Equal gives False, also for the value and itself. Absent: no value of the kind is NaN.
    • KindOperands.written holds the operands as they were written on every route: under simplify(), the operands before their simplification. So a kind whose negate gives a value and whose add reads Negate(b) in written gives the same difference under evaluate(), .N() and simplify().
    • ArithmeticDefinition.numericValue(value) gives the number that a value of the kind stands for. re, im, bignumRe, bignumIm and valueOf() of the value read it. The fact queries (isPositive, sgn, …) do not.
    • ArithmeticContext.tolerance gives the compare hook the tolerance of .is(): the tolerance argument of the call, or engine.tolerance. So M.is(2.6, 0.2) for M = 2.5 ± 0.1 uses the tolerance 0.2. It is undefined for the other comparisons.

    The rules are in doc/06-guide-augmenting.md, "Measurements and Quantities"; the decisions are in section 13 of docs/plans/2026-10-26-value-kinds.md.

  • factor() accepts variable and generator options, and a new factorDetailed() returns a structured result. factor(p, { variable, generator }) factors over an explicit number field, as Factor(p, x, generator) does. factorDetailed() returns the exact factors with their multiplicities, the scalar content, whether the factorization is complete, and the reason and stage of a refusal; with statistics: true it also returns the work counts of each step. Both are exported from the main and the core entry points. No MathJSON operator is added.

  • Number-field factoring lifts at unramified prime ideals of any residue degree. It used to need an inert prime, and ten inputs of the extended number-field corpus were refused because none was found. The factors are recovered with an exact lattice reduction and the recombination is certified. A weighted scaling of the generator and the variable and tighter bounds on the coefficients of a divisor reduce coefficient growth. When the coefficients are rational, the norm fallback skips the unshifted norm, which is always repeated. The statistics keep the reason the direct method failed before a norm fallback.

  • Number-field factoring reads x^n monomials directly when it converts an expanded polynomial of high degree, and no longer refuses such an input because the conversion went over its budget.

Resolved Issues​

  • A coordinate of a list of points inside PointList compiles to a list (CE-514, Tycho row 447). On the javascript target, (v ↦ PointList(PointY(v), PointX(v)))([(1, 2, 3), (4, 5, 6)]) compiled to [NaN, NaN]; it now gives [[2, 1], [5, 4]], as evaluate() does. The same holds for a named function called with a list of points. A PointX/PointY/PointZ component whose operand is not provably one point is a zip source when its value is an array and one slot otherwise, decided at run time. The type of the call is now a list of points too: f(L) for a list L of two points with integer coordinates is a list<tuple<integer, integer>^2>, where it was tuple<unknown, unknown>.
  • A call of a function whose signature was inferred is typed from its body with each parameter typed as its argument, when the type inferred for the result does not describe the call (CE-514). The result inferred for v ↦ PointList(PointY(v), PointX(v)) is one point, and a call with a list of points evaluates to a list of points. The call was typed one point; it is now typed from the body. This applies only when the inferred result is not a scalar and the walk of the body bounded by it gives no type, and only for arguments that are numbers or lists of numbers or points. A result type that the host declares, or that the literal writes, stays the bound, and a function that reads a symbol from outside its parameters keeps its inferred result, so a call after that symbol changes type is still refused (CE-473).
  • Five shader fixes found by comparing WebGL2 and WebGPU on a GPU (CE-509 to CE-513, Tycho rows 454 to 458).
    • On wgsl, tanh calls a helper that answers sign(x) when |x| > 10. On WebGPU over Metal the built-in returned 0 at x = 44 and NaN from x = 45, where the value is 1. Coth is 1.0 / tanh(x) on both targets: the quotient cosh(x) / sinh(x) was NaN above about 89 in f32.
    • On wgsl, a constant above the f32 maximum (\exp(100) + x, 10^{40} + x) is written _gpu_inf(), and a constant that rounds to the maximum is written as the maximum. WebGPU rejected the whole shader for such a literal.
    • On glsl, Mod stays in the range of the divisor. The built-in mod gave mod(1e8, 3.0) = -2 and mod(-1e6, 1000.0) = 1000 on WebGL2 over ANGLE Metal; they are now 1 and 0. On both targets Mod(x, 1) is fract(fract(x)), so a tiny negative x gives 0 instead of 1.0.
    • On both targets, Mod calls a _gpu_fmod helper, which receives each operand once. On wgsl the inline formula wrote the dividend three times and the divisor five times.
    • On wgsl, a piecewise arm that calls a user function, or a special function with a loop (Zeta, HurwitzZeta, PolyLog, LerchPhi, BesselJ, Mandelbrot, …), becomes a branch of an if statement. select computes both arms: with g(x) = \sum_{i=1}^{300}\cos(ix), \{x<0 : g(x), 0\} took 5.05 ms on WebGPU against 2.36 ms with an if.
  • sin, cos and tan of a quotient π·u / d compile to the exact half-turn forms (sinpi, cospi, tanpi) on every target, as π·u already did. \sin(2\pi s / P) at s = 2, P = -2 is now exactly 0 in the compiled code; it was 2.4e-16 in JavaScript, so a test \sin(…) > 0 was true in the compiled code and false in the interpreter. e^{i\pi u/d} and the hyperbolic functions of i\pi u/d take the exact forms too.
  • On wgsl, a piecewise branch that draws a random value no longer compiles (it is evaluated by the interpreter instead). WGSL select computes every branch, so a draw in a branch that was not taken still advanced the random stream and changed every later draw in the shader. glsl is unchanged: its ?: evaluates only the branch that is taken.
  • On glsl, Mod of a scalar by a vector compiles (Mod(1, v) broadcasts the scalar); the built-in mod has no such overload, and it was refused.
  • On glsl and wgsl, Sin, Cos, Sec, Csc, Cot, Sign, Smoothstep and Heaviside give NaN when the operand is NaN (CE-520), as the javascript target does. On Apple GPUs the built-ins sin, cos, sign and smoothstep of a NaN gave 0 (so \sec x gave +∞), and the GLSL Heaviside gave 0.5. They now call helpers that test the bit pattern of the operand, as Max and Min do (CE-519). \tan(\pi x) on the shader targets is now +∞ at every pole, as on the other compiled targets; it was +∞ or −∞ depending on the sign of the zero cosine, and not the same on WebGL2 and WebGPU.
  • On glsl and wgsl, Max, Min, Clamp, ElementMax and ElementMin give NaN when an operand is NaN (CE-519, Tycho row 465), as the interpreter and the javascript target do. The shader built-ins return the other operand on Apple GPUs, so \max(0, \sqrt{1 - x^2}) at x = 2 was 0. The targets now call helpers that test the bit pattern of each operand (a test a != a was removed by the compiler on WebGPU over Metal) and return the NaN operand itself. Clamp(x, lo, hi) with lo > hi now gives hi on glsl as on the other targets (the built-in clamp left it undefined). A vector Clamp with scalar bounds, and an ElementMax or ElementMin of a vector and a scalar in any position, now compile on both targets: the scalar is broadcast, as in the interpreter. They were refused on wgsl, and on glsl when the scalar came first.
  • A part of a loop body printed or evaluated outside its operator keeps its index (CE-498). ReplaceAt([1,2,3], i, 9) taken from Loop(…, Element(i, Range(1, 3))) printed an incompatible-type error, with i read as the imaginary unit; it now prints ReplaceAt([1,2,3], i, 9).
  • An undeclared recursive pair that builds a list is typed -> list<number> in every install order (CE-38). Assigning G := (n, K) ↦ F(n, K) before F gave -> list<any>.
  • A symbol declared list<residue> is accepted by the arithmetic operators (CE-507). With L: list<residue>, L + L, -L and L · 2 were incompatible-type errors when boxed, while a list literal of classes was accepted. They are typed list<residue> and are computed element by element.
  • A boxed function literal mapped over residue classes keeps the rules of the classes (CE-507). ce.function('Map', [ce.box(x ↦ x/x), [ResidueClass(2, 4), ResidueClass(3, 4)]]) gave [1, 1]: the body was simplified to 1 before the type of the elements was known, and 2 has no inverse modulo 4. It now gives [ResidueClass(2, 4) / ResidueClass(2, 4), ResidueClass(1, 4)], as ce.box() and ce.parse() of the same expression do.
  • Measurements and quantities: wrong values and wrong types (CE-508, fixed by making Measurement and Quantity value kinds, CE-507).
    • Quantity(2, m) − Quantity(2, m) simplifies to 0 m. It simplified to the number 0, without the unit.
    • Quantity(2, m) + Quantity(30, cm) simplifies to 2.3 m, and Quantity(2, m) · Quantity(3, s) to 6 m·s. simplify() returned them unchanged.
    • x + Quantity(2, m) + Quantity(3, m) evaluates to x + 5 m. It stayed unchanged, because one operand was not a quantity.
    • Measurement(2, 0.1) − Measurement(2, 0.1) simplifies to Measurement(0, 0.141…), as it evaluates. It simplified to 0.
    • ResidueClass(2, 5) + Quantity(3, m) is typed quantity | residue. It was typed list<list<any> | residue> | residue.
    • A comparison that is not decided keeps the measurement: Less(Measurement(2, 0.1), x) evaluates to itself. It evaluated to Less(2, x), without the uncertainty, and Equal did the same.
    • A measurement with a complex nominal at an operator whose parameter is real is an incompatible-type error at evaluation: ContinuedFraction((2 + 3i) ± 0.1) was [2], and Floor, Mod, Hypot, Degrees and the other operators with a real parameter read the real part.
    • A unit is no longer read as a free symbol: InverseHaversine(Quantity(2, m)) evaluated to 2·arcsin(√2·√m). A quantity at an operator that does not accept it is now an incompatible-type error at boxing.
    • Measurement(2, 0.1).isLess(3) is true and .isEqual(2) is true. The methods answered undefined, while Less and Equal answered True.
    • A complex nominal keeps its uncertainty through every operator with a derivative: Arctan((2 + 3i) ± 0.1) is (1.4099 + 0.2291i) ± 0.0079. The uncertainty was dropped for some operators and kept for others, depending on how the derivative was printed.
    • Log2(Measurement(2 + 3i, 0.1)) is (1.8502 + 1.4179i) ± 0.0400. It was 1, a wrong value: the logarithms read only the real part of a complex nominal.
    • Two uncertain complex operands add the moduli of their contributions: Measurement(1, 0.1) · Measurement(i, 0.1) is i ± 0.1414. The two contributions cancelled, and the result was the bare i.
    • Power(-2, Measurement(2, 0.1)) is 4 ± 1.3, propagated with the principal logarithm of the base. It was the exact 4, without an uncertainty. An uncertain exponent of the base 0 leaves the power unevaluated.

0.169.2 2026-10-26​

Behavior Changes​

  • An open record type is no longer a dictionary of its listed field types (CE-4, CE-10). record{a: integer} was a subtype of dictionary<integer> and is not now, because the record can hold other fields; the relation is now transitive. A dictionary value such as {a: 1} still matches dictionary<integer>. A read by a key known only at evaluation on an open record is typed any.
  • A declared type with unknown at a nested position refines from each assignment (CE-3.5): t: tuple<unknown, integer> := ("x", 2) was tuple<unknown, integer> and is tuple<string, integer>.
  • The static type of a block local no longer depends on statement order (CE-43): mutate(v); v = [1.5, 2] typed v as list<real>; it is vector<real^2>, as in the other order. An assignment inside a match case or a while let body widens the local: let xs = [1,2,3]; while let [h, ...] = xs { xs = Rest(xs) }; xs was typed vector<integer^3> for the value [], and is list<integer>.
  • A let-bound function literal's effects follow a later conformance (CE-62.2), and so do its callers.
  • A numeric tuple at a scalar parameter is a tuple of numbers (CE-47): h((1, 2)) with h := x ↦ 2x was any and is tuple<number, number>. A tuple with a tuple or string component is any; h(((1, 2), 3)) was typed number for the value ((2, 4), 6).
  • Map over elements whose depth the type does not state admits nested lists: Map(k, L) with k: (real) -> real and L: list<unknown> (or list<any>, list<list<unknown>>) was typed list<real>, which did not hold a ragged or nested element, and is list<indexed_collection<any> | real>. An operand typed any at a declared scalar parameter types the same on the box and derived routes (CE-485).
  • A nominal list-of-points type is not read through its definition (CE-488): k(x) with k: (tuple<number, number>) -> number and x of a nominal list<tuple<…>> type was typed list<number> and is error, as its value is.
  • An access proved to be out of range is typed by its marker alone (CE-42.3): At(["a","b"], 9) was missing | string and is missing; M[7][1] over a matrix was integer | nan and is nan. Arithmetic over a proved-absent row or point is Missing.
  • A local declared any keeps its type (CE-444), and an operand typed any at a generic collection parameter no longer fixes the element type from the other operands: ReplaceAt(ys, 2, "z") with ys: any was list<string> and is list<any>.
  • An absent value of a symbol declared any gives NaN in arithmetic on every route: with p: any assigned Missing, p + 1 was Missing and is NaN, as subs and compiled code already gave.

Resolved Issues​

  • subs() on a structural expression that is a lone symbol keeps a constant name in the replacement. With x declared real, the structural x with x replaced by the structural t + i gave t + (0 + 1i) in 0.168.0 to 0.169.1, so a sum with an index i over an inlined f(x) = x gave complex values: with X = [1, 2, 3], Σ_{i=1}^{3} f(X + i) gave [3+3i, 6+3i, 9+3i]. It gives [9, 12, 15] again. A requested form (subs(…, { canonical: true })) is still honored.

  • The count of a comprehension matches its cells when a clause can fail. [j for i in 1..3 if bad(i), j in []], where the guard gives an error, had a count of 0 but yields 3 error cells; its count is now 3.

  • Testing whether a comprehension is finite no longer runs user code. The test does not evaluate a guard or an impure domain, so let r = [i for i in 1..3, j in filter(1..i, keep)] no longer calls keep.

  • A Map, Filter or Reduce callback follows a reassignment of its source. With L := [1, 2] and m = Map(x ↦ x + 1, L), a later L := [["a"]] made m.evaluate() a list of incompatible-type errors; it now gives the value that a fresh Map gives.

  • Range(n) with n < 1 counts down from 1 on every route. A raw Range(0) and the compiled JavaScript Range(0) were empty; they are now [1, 0], as the canonical form already was.

  • A coefficient times an ellipsis product keeps the product whole. 3(2·4·…·2n) evaluated, expanded and simplified to 3·2·4·…·2·n or 48·…·n; it now stays 3·(2·4·…·2n). The same holds in a sum: x + 3(2·4·…·2n) was x + 48·(…·n), and like terms still collect ((2·4·…·2n) + (2·4·…·2n) is 2(2·4·…·2n)).

  • The type of Negate, Sqrt and the other element-wise functions of a list of range rows holds its value. Negate([1..3, 4..6]) was typed list<range> for a matrix of integers; it is now list<list<integer>>. Sum([1..2, 3..4]) is typed integer | list<integer> (it was number, for the value [4, 6]).

  • An undeclared recursive pair that builds a list is typed -> list<number> (CE-38), and a function literal boxed before the function it calls is typed once that function is assigned: g := (n, K) ↦ f(n, K) boxed before f := (n, K) ↦ n + K was -> unknown and is -> number.

  • A polymorphic callback over rows (CE-30): Map(k, [[x ↦ x + 1]]) with k: ((integer) -> T) -> T was matrix<unknown^(1x1)> and is matrix<number^(1x1)>.

  • The meet and overlap of chains of list, set and tuple aliases (CE-449) finish in under 0.25 s at depth 32; they took several seconds, or did not finish.

0.169.1 2026-10-25​

Behavior Changes​

  • references and referencedFunctions list a name that already has a value or a definition. They give the dependencies of an expression, for example the edges between the cells of a notebook, and they lost an edge as soon as the cell it points to was evaluated. After ce.assign('a', 5), g(x) := x + a references a (it gave []). Once f is defined by f(x) := …, by ce.assign('f', …) or declared with { signature: … }, g(x) := f(x) + a references a and f (it gave ["a"]), and referencedFunctions is ["f"]. A user function passed as an operand is referenced too: Map(L, f) references L and f. The names of a library (Add, Sin, Pi, …: the standard library, a library given to the constructor, or one loaded with ce.loadLibrary()) and constants are still left out. unknowns and freeVariables are unchanged: they list only the symbols with no value.

Resolved Issues​

  • subs() on a structural expression keeps a constant name in the replacement. With x declared real, the structural x + 1 with x replaced by the structural X + i gave X + (0 + 1i) + 1 in 0.168.0 and 0.169.0: a declared symbol of a structural expression is bound, and its replacement was boxed canonically. It gives X + i + 1 again, in the structural form. An undeclared x was not affected.
  • A function that reads a function declared with an unknown result compiles. With f declared (real | signed_infinity | nan) -> unknown and assigned x ↦ x + A cos(x) + d, the compile of a function whose body reads f as a value (g(t) := f'(t) + 1) was refused: "the symbol 'f' that its body reads holds a value of type (ceArg_0: unknown) -> number". The check now reads the signature that the definition derives with the declared parameter types, -> nan | real | signed_infinity, which is the type ce.assign() accepted. A later change of the type of a symbol that f reads (A assigned 2i) is still refused.

0.169.0 2026-10-25​

Behavior Changes​

  • A finite range of integers and the list of its integers are the same element of a collection. Set(Range(1, 3), [1, 2, 3]) has one element, [1, 2, 3] is an element of [Range(1, 3)], and IndexOf, Unique, Tally, Union and Contains treat the two as one. Range(1, 3), Range(1, 3, 1) and [1, 2, 3] are the same for isSame and have the same hash. The rule applies to a range whose bounds and step are integer literals and that has at most 10,000 elements. Equal is unchanged.
  • A range that is an element of a collection stays a range. [1..3, 4] evaluates to [Range(1, 3), 4] (it was [[1,2,3], 4]), and Tuple(Range(1, 3), 4) to (Range(1, 3), 4), so the value agrees with the type tuple<range, integer>. A symbol declared list<range> holds [Range(1, 3)] (it held [[1, 2, 3]], which is not a list of ranges). The same applies to a Set element, a cell of a broadcast result (g(s) = 1..s; g([2, 3]) is [Range(1, 2), Range(1, 3)]) and a spread element. A range on its own is unchanged, and Length, At, Flatten, Sum and Map give the same values. Other lazy elements, such as a Map or a comprehension, are still listed.
  • LUDecomposition, QRDecomposition and CholeskyDecomposition of a matrix of exact rational entries give exact factors under evaluate(). The LU of [[4, 3], [6, 3]] is ([[0,1],[1,0]], [[1,0],[2/3,1]], [[6,3],[0,1]]) (the lower factor held 0.6666666666666666), and the QR of [[3, 4], [4, 3]] is ([[3/5,4/5],[4/5,-3/5]], [[5,24/5],[0,7/5]]). .N() and a matrix with a float entry still give floats, with the same permutation. A matrix with an exact entry that is not rational, such as √2, now stays unevaluated under evaluate() (it gave floats); .N() decomposes it. The result types state the exact cells: rational for the LU factors, real for QR and Cholesky.
  • The R factor of QRDecomposition has a non-negative diagonal on the exact and the float route. The float route gave R[0][0] = -5 for [[3, 4], [4, 3]], with Q negated to match; it now gives 5.
  • CholeskyDecomposition of a matrix that is not symmetric is the error expected-symmetric-matrix. It read only the lower triangle, so [[4, 0], [2, 3]] gave a factor L with L·Lᵀ = [[4, 2], [2, 3]]. A float matrix that is symmetric within rounding error is accepted.
  • Inverse, MatrixPower and Determinant no longer claim rational cells. A float that holds a whole number (2.0) is typed as an integer, so the claim was wrong for Inverse([[2.0, 1], [1, 3]]), typed matrix<rational^(2x2)> with float cells. The inverse of an integer matrix is now typed matrix<real^(2x2)>, and the determinant of a rational matrix real. The values do not change.
  • A type alias with no fixed point is refused when it is declared. ce.declareType('nn', '!nn', { alias: true }) throws the error alias-without-fixed-point, and the DeclareType and Epsil type alias routes give an invalid-type-declaration error. Recursive aliases such as tree = list<tree> | integer, and aliases that have a fixed point such as !a | any, are still accepted.
  • An Epsil program whose result is a finite comprehension gives a List. [do { k * 2 } for k in 1..3] and let xs = [if k > 1 { k } else { 0 } for k in 1..3]; xs gave an unevaluated Comprehension; they give [2, 4, 6] and [0, 2, 3]. An infinite comprehension stays as it is.
  • \operatorname{implies} and \operatorname{iff} parse as Implies and Equivalent. p \operatorname{implies} q was a product of the symbols implies, p and q. \mathrm{…}, \text{…} and \keyword{…} spellings of the two words parse the same way.
  • The type of a call checks the arguments against a declared signature on every route. With k declared (tuple<number, number>) -> number, the type of a function whose body calls k(3) is error, as the value is; it was number. A list of points at a declared point parameter maps on that route too, so the derived type of k(P) is list<number>.

New Features​

  • A type handler can read whether the engine is strict. The engine view that a type handler receives (PureEngineView) has a strict property, the same value as ce.strict.

Resolved Issues​

  • The structural form keeps a constant name as written again. In 0.168.0, ce.box(['Power', 'i', 2], { form: 'structural' }) gave Power(Complex(0, 1), 2), e became ExponentialE, and Half became 1/2, so M(i) = i^2 read structurally had no parameter named i. A symbol whose canonical form is another expression (i, ImaginaryUnit, e, Half) now keeps its name and is unbound, typed unknown, as in 0.167.0. Every other symbol still binds to its definition: ["Add", "t", "t"] with t: tuple<integer, integer> is typed tuple<integer, integer>. The canonical form and the value are unchanged: the canonical form of the structural i^2 is -1.
  • A compile refused because a captured symbol changed type names that symbol. The message gives the symbol and both of its types, for example "the symbol 'd' that its body reads has the type real, but the body of 'x_0' was typed when 'd' had the type integer".
  • A partial application keeps its argument inside a held operand. With f(a, b) := If(b, a[1] + a[2], 42), f((1, 2)) gave (_) => If(_, At(a, 1) + At(a, 2), 42), and applying it to True gave a[1] + a[2] instead of 3. With m(a, b) := a + b, m(b) applied to 1 gave 2 instead of b + 1: the free parameter was renamed by name and captured the argument b.
  • The type of a call holds its value in more cases.
    • The derived type of At is error for a base that cannot be indexed (a number, a boolean, a set, an interval), as the boxed At is. So with h(a, t) := a[1] + a[2] + t, the partial application h(3) is typed error, as its value is, not (t: unknown) -> number.
    • With k declared (tuple<number, number>) -> number and U(L) := k(First(L)), U([[(1, 2), (3, 4)]]) was typed number for the value [3, 7]. A call whose argument is outside an inferred parameter type is now typed from the body.
    • A union of lists of points maps at a declared point parameter, so Map(L ↦ k(L), [[(1, 2), (3, 4)], [(5, 6)]]) is typed list<list<number>> (it was list<number>).
    • The type of a call in the body of a function refuses a matrix at a declared list<integer> parameter, as the boxed call does.
  • A Text run whose words are a keyword round-trips through LaTeX. Text(p, "and", q) was written p\text{and}q, which parses as And(p, q). It is now written p\text{{}and}q, which prints the same and parses back as text.
  • An intersection with a never arm is a subtype of never. integer & never was not a subtype of never, nor of itself, so ce.declareType('q', 'integer & never', { alias: true }) threw signature "(integer & never) -> integer & never" does not match.
  • A product with a scalar factor of inferred or unknown type has number cells (CE-481). L·u with L: list<integer> and u inferred number was list<integer> and is list<number>; x ↦ a·x with a := [1, 2, 3] was (x: unknown) -> vector<integer^3>, which did not hold [0.5, 1, 1.5], and is (x: unknown) -> vector<3>.
  • A call over a captured symbol of type unknown no longer claims a scalar result (CE-478). With g := s ↦ 2s and f := t ↦ g(y), f was typed (t: unknown) -> number, and after y := [1, 2, 3] the value of f(1) was [2, 4, 6]. It is now (t: unknown) -> any, which holds a list, a tuple or a ragged list in y.
  • The signature of a function that calls itself from a Map callback holds its values (CE-479). m(n) := Map(x ↦ If(x < 1, 0, m(x − 1)), [n]) was -> list<number> (m(1) is [[0]]) and is -> list<integer | list>. The same holds for a mutually recursive pair, in either assignment order.
  • Epsil: a function called before a later let of a name its body reads is typed for both bindings (CE-480). In let a = g(); let i = 2; function g() { i^2 }, g was typed () -> integer<0..> for the value -1 (the first call reads the constant i); it is now () -> number.

0.168.0 2026-10-25​

Breaking Changes​

  • The complexPromotion compile option is removed. It was deprecated in 0.115.0 as an alias of mode: 'complex', with a one-time console warning. The option is no longer on CompilationOptions, CompileTarget or the options of compile(), and a JavaScript caller that still passes it gets no warning: the key is ignored and the compilation runs in the default mode: 'auto'. That mode promotes an unknown-sign Sqrt, Ln, Log or Power through the complex kernels, but unlike mode: 'complex' it does not treat every wide numeric binding as complex, so the emitted code and the reported mode can differ from what complexPromotion: true gave. Pass mode: 'complex' to keep the old behavior, or mode: 'strict' to forbid promotion.

  • The warning for the removed realOnly compile option is gone. The option itself was removed earlier. A JavaScript caller that still passes the key no longer gets a console warning; the key is ignored.

  • Deprecated type aliases are removed. Use the name on the right:

    RemovedUse
    BoxedExpressionExpression
    SemiBoxedExpressionExpressionInput
    ExpressionComputeEngineComputeEngine

    The three were identical to their replacements, so a rename is the whole migration.

  • The fractionalDigits serialization option is removed from toMathJson(), toLatex() and the LaTeX number format (JsonSerializationOptions, NumberSerializationFormat and the options of LatexSyntax). Use digits instead: fractionalDigits: n becomes digits: { fractional: n }, and 'auto' and 'max' keep their names.

    With toMathJson(), the replacement gives the same output as before. With toLatex(), it does not:

    CallBefore (fractionalDigits)Now (digits)
    π, toMathJson, 6 digits3.1415933.141593
    π, toLatex, 3 digits3.141\ldots3.142
    2.0, toLatex, 2 digits22.00

    toLatex({ fractionalDigits: n }) cut the digits after the nth, wrote a \ldots marker when a digit was cut, and dropped trailing zeros. toLatex({ digits: { fractional: n } }) rounds to exactly n digits after the decimal point and writes no marker. The truncationMarker option is now read only by the parser.

    An engine-wide digits (ce.latexOptions = { …, digits: { fractional: 2 } }) now applies to .latex and toLatex(), as an engine-wide fractionalDigits did. Before, only fractionalDigits was read from the engine-wide settings.

    A standalone LatexSyntax serializer (one used without an engine) now accepts digits, in its constructor and per call: new LatexSyntax({ digits: { fractional: 3 } }) writes { num: "3.14159265" } as 3.142. It rounds a float and writes an exact number in full. With "auto" or "max" it writes every digit, since it has no engine precision to round to.

  • A bare number is no longer a digits value. toLatex({ digits: -4 }) and toMathJson({ digits: 6 }) were accepted at runtime, although the DisplayDigits type does not allow a number: n ≥ 0 meant { fractional: n } and n < 0 meant { significant: -n }. Such a call now throws an error that names the accepted values, in toMathJson(), toLatex() and a standalone LatexSyntax. An invalid digits set on ce.latexOptions throws when it is set. Write { fractional: n } or { significant: n }.

  • The canonical option of replace() is removed. Use form: canonical: true becomes form: 'canonical', canonical: false becomes form: 'raw', and a canonical form or a list of forms is passed to form unchanged.

  • The broadcast() batch body is built on request (ROADMAP CE-423, user decision 2026-10-25). Since 0.166.0, compile() of an expression that reads a shared list of numbers at a scalar index (F_{i+1} with F: list<number>) also compiled the batch body that makes broadcast() up to 15 times faster, a second compilation of the same size as the first (about 45 % of the compile time of such a kernel). It is now compiled only with compile(expr, { broadcast: true }). Without the option the result has no batch field, and broadcast(), broadcastAsync() and a kernel worker run the ordinary body for every cell, with the same values. A caller that broadcasts such a kernel over many cells passes the option; a caller that only calls run() saves the second compilation. A consumer that reads both run() and broadcast() from one result (Tycho's plot rows) passes the option at its compile site, or its implicit plots lose the 0.166.0 speed.

  • A product written by juxtaposition now types its symbols as numbers (ROADMAP CE-32). ce.parse('2t'), ce.parse('x y') and ce.box(['InvisibleOperator', 2, 't']) left a symbol with no value and no declaration unknown, where 2\cdot t types it number. They now type it number too. The product itself (its factors, their order and its LaTeX) is unchanged, and a function application such as f(x) keeps its reading. Types derived from such a product change: t ↦ (t, 2t) is (unknown) -> tuple<number, number> (it was (unknown) -> tuple<unknown, number>) and t ↦ [t, 2t] is (unknown) -> vector<2>. The inferred type stays for the lifetime of the engine, so in an engine used for several expressions, a symbol used as a factor is a number in later expressions: after \mathrm{B}x, \mathrm{B}(x+1) is a product, and after EG, x \in G \setminus H reports an error, as it already did after E\cdot G. Use a fresh engine, or declare the symbol, when a name has two roles.

  • Compiled Python reads a None input as NaN in arithmetic (ROADMAP CE-395 and CE-460, user decisions 2026-10-25). None is the Python spelling of Missing; p + 1, 1 / p, np.sin(p) and r^2 + 1 raised TypeError for it, and now give nan, as the interpreter and compiled JavaScript do. The conversion applies to an input whose type admits missing (number | missing, any), is unknown (a free symbol of a parsed expression, r in 2\pi r), or was inferred as number (r in \sin(r)). The generated code changes for those inputs: each read is (float('nan') if r is None else r), which costs about 6 to 15 % in a hot loop. An input declared number (ce.declare('r', 'number')) compiles to the same code as before and pays nothing; declare an input to keep the previous code. A comparison still reads the absent value (p ≠ 0 stays undecided). A vars source that is a number literal is not wrapped.

Resolved Issues​

  • A float plus a fraction was written as a mixed number (CE-471). ["Add", {"num": "2.0"}, ["Rational", 1, 3]] was written 2.0\frac{1}{3}, which reads back as a product (0.666…). It is now 2.0+\frac{1}{3}. Add(2, 1/3) is still 2\frac{1}{3}.

  • A point function with a second argument was typed any (CE-469). With f: (broadcastable<tuple<real, real>>, tuple<real, real>) -> real, f([[(1,2),(3,4)],[(5,6),(7,8)]], (1,1)) is typed matrix<real^(2x2)>, and f((1,2), (1,1)) is real.

  • A function literal with a rest parameter is accepted as a callback. Map((a, ...xs) => …, …) was refused with a callback-arity error that counted the rest parameter as required.

  • The interval target wrote a constant list out one term per element at any width, and Max, Min, Sum or Reduce over a list of 2,000 numbers overflowed the stack of the compiler. A list wider than 100 numbers is now one table read at run time: Sin(x + 4·L) over 2,000 numbers compiles to 135 characters, where it was 60,876.

  • Compiled Reduce and Scan used the library kernel for a combiner that only had the same name. With a user-declared Max(a, b) = 2a + b, Reduce([1, 2, 3], Max) compiled to Math.max and gave 3, where the interpreter gives 11; the same held for a parameter or a block-local function named Max, for a Max given in the functions option, and on the Python and interval targets. Such a fold now takes the route of a custom combiner or declines.

  • A function literal that passes a list to a function with an inferred point parameter was typed -> error. With d(a) := a[1] + a[2], x ↦ d([x, x]) is now typed (unknown) -> number; it evaluates. A signature that the engine inferred is not a contract, so the derived type of a call no longer checks the arguments against it. A call that passes a list of lists there is typed from the body: d([[1, 2], [3, 4]]) evaluates to [4, 6] and is now typed vector<integer^2>, not number. Compiled code accepts a number or a list of numbers at such a parameter (d(S(x), [1, 1]) on interval-js), and the message of a call that still fails says whether the point type was inferred or declared.

  • The type of a broadcast over a list whose elements have different ranks repeated an element arm. M + C with M: list<list<integer> | number> and C: broadcastable<number> was typed list<list<integer> | list<list<integer> | number> | number>; an arm that another arm holds is now dropped, and a union inside the element union is flattened: list<list<list<integer> | number> | number>.

  • Compiling nested Sum and Product was exponential in the nesting. The unrolled form of a clause is now limited to 10,000 nodes in total, and a larger clause is emitted as a loop: Σ_{a=1}^{60}Σ_{b=1}^{60}Σ_{c=1}^{60} abcx compiled in 12.7 s to 18 MB of code, and now in a few milliseconds. A body shared by a Sum and a Product at each of 10 levels compiles in 1.7 s instead of 55 s.

  • Compiled Python gave another absence value than the interpreter for an element read of an absent tuple. With b: list<tuple<number, string>> and b = [], At(At(b, 1), 1) was None (Missing); it is now nan, as in the interpreter. A read past the end of a tuple with an unknown slot picks the value from the elements, as the interpreter does.

  • Compiled Last declined an operand that First compiles, such as At(tup(x), 1) with x: any. It now compiles as At(…, -1).

  • The ASCII toString() of a Limit left out the variable: lim_(a) …. It now prints lim_(x -> a) ….

  • NLimit of a function that decays to 0 gave a tiny residue such as −2e-22 for 7t/(t² + 9) at infinity. A result whose magnitude is below its error estimate is now extrapolated again with a tighter tolerance and snapped to 0 when it stays below the estimate; a small true limit such as 1e-20·(1 + 2/t) is kept.

  • simplify() did not collect like terms of a numerator over a sum denominator: (t − t + 1)/(y + z) stayed as written; it is now 1/(y + z).

  • simplify() did not collect constant cofactors of like terms: π(√2 − 1) + π(√2 + 1) is now 2π√2, so the integral ∫ (x² − 1)/((x − 1)(x⁴ + 1)) dx over the real line gives π/√2.

  • Infinite sums and products: a two-term body whose second term is not a Negate now telescopes (Σ 1/(k+x) − 1/(k+x+1) from 1 to ∞ is 1/(x+1)); evaluate() and .N() now give the limit of a divergent or vanishing infinite product of a rational body (Π k and Π (k+1)/k are +∞, Π k/(k+1) is 0); the shifted-factorial rule no longer simplifies Π_{k=1}^n (k − 3) to 0.

  • An unevaluated Solve from a parse kept InvisibleOperator and Delimiter in its MathJSON. It now holds the canonical equation, so the parse route and the box route give the same result.

  • simplify() of −(2·4·…·2n) distributed the sign into the product and folded it to −16·…·n. An ellipsis product is no longer expanded, and −(1 + 2 + … + n) keeps its pattern order.

  • Map(x ↦ x + 1, [[(1, 2)], Missing]) gave [NaN, NaN]. The first cell is now a list holding the incompatible-type error, as without the Missing.

  • Mean, Median, Variance and StandardDeviation of an exact Range under .N() walked the float range and gave -8.50000000000000060577 for Mean(Range(-17, 0, 1/3)). They now read the held operand and use the closed form: -8.5.

  • The display preview of an unevaluated Dedup, Reverse or Filter of a string-typed symbol printed a list, while the value was a string. The preview now evaluates its source.

  • A base case assigned in a closure could record a pending sequence definition under a discarded scope, which stayed in the map and in every checkpoint. A discarded scope is now skipped.

  • A qualified call on an unknown protocol name (Comparable.compare("a", "b") with Comparable undeclared) gave an arity error. It now gives a protocol-unknown error that names the symbol.

  • IdentityMatrix(n), ZeroMatrix(2, n) and OnesMatrix(n) with a symbolic extent gave an expected-positive-integer error; Kernel(n) for a valueless n: integer gave []; Shape(m) for an unassigned m: matrix<integer> gave (-1, -1), and Shape(q)/Rank(q) of a free symbol gave ()/0. Each now stays unevaluated.

  • Sum(Rest(g(3))) and Reduce(Rest(g(3)), Add, 0) with g(t) = [t, 2t] stayed unevaluated. A lazy view over a held call is now resolved before the reduction; Most, Reverse, Filter and Join over g(3) too.

  • The entry type of a declared open record left out its undeclared fields, so FlatMap(ra, x ↦ [At(x, 2)]) with ra: record{a: integer} holding {a: 1, b: Missing} was an incompatible-type error. A declared record now reads tuple<string, any> entries and Values(ra) is list<any>; a dictionary literal keeps its field types.

  • Tabulate(w, 3) with w holding an error gave three copies of the error; it is now the error once, as Map(w, …) is.

  • Static types that did not hold the value of the expression. In each case the type is now a type of the value:

    • Length([1, Error("x")]), Count and IsEmpty over a list with a failed cell were typed error for the value 2; First, Last, At, IsError, Element, Contains and IndexOf over such a list are now unknown, and Sin([1, err, 3]) and [1, err, 3] + 1 are typed as lists over the valid cells.
    • PointY([(1, Missing), (2, 3)]) keeps its length: list<integer | missing^2>.
    • A type derived from operand descriptors now refuses what boxing refuses: derive('Add', [string, integer]) is error, so Map((a, b) ↦ a + b, ss, ys) over strings and integers is list<unknown>; Simplify(Sin(r)·P) with P: boolean is error; Sin over a list<string> is error.
    • Map(Abs, [1, Missing, 2]) and Map(Negate, …) are list<integer | nan^3>, as the lambda form is; Abs(w) with w: nan is nan.
    • An undeclared self-recursive numeric function (fact := n ↦ If(n ≤ 1, 1, n·fact(n − 1))) is typed (unknown) -> number, as when declared.
    • A conditional with a branch typed unknown from a parameter is typed unknown instead of by its other branch: f := (n, s) ↦ If(n = 0, s[1], [1, 2]) applied to (0, [7]) gives 7, which vector<integer^2> did not hold.
  • A component read of a tuple local did not type the parameter: g(s) = do { let q = (s, 0); take(q[1], 2) } applied to [1, 2, 3] gave element-wise errors. q[1] now aliases s, which is read as a list.

  • \keyword{if} x > 0 \keyword{then} x without else parsed to text next to the operands and reported no error. It now parses to the two-operand If, which is Missing when the condition is false, and it stops at an outer comma or semicolon.

  • \int dx and \int (dx) were an unexpected-command error. They now parse to Integrate(1, x).

  • LaTeX parser cases that were marked @fixme: a chained \over (1\over 2\over 3) now gives a non-associative-operator error, as TeX rejects it; \tan(90-0.000001)\degree applies the degree sign to the argument; a differential inside a denominator (\int\frac{3x}{5dx}, also inside a sum, a product or a function argument) gives an unexpected-differential error.

  • The compiled Map element function found the functions its callback calls by name in the drain scope. With an outer h(x) = sin(x) + 1.5, a callback f(x) = N(h(x)) and a block that declares h(x) = sin(x) + 2.5, the compiled sum used the inner h. The callees of a named or literal callback are now resolved through the callback's lexical binding, and the drain falls back to the interpreter when they differ.

  • A sum or product with one bound (\sum_{k=1} k) failed to compile with a message about Nothing; it now declines on every target with a message that says the form has no value.

  • Python target: Power with a float exponent was emitted as t ** 0.3 in auto mode while the analysis read it as promoted. It is now np.emath.power(t, 0.3), as the analysis says.

  • Shader targets: a trigonometric argument with a constant term that calls a library function (\sin(x + \Gamma(20))) was not reduced by whole periods, so its float32 value was far from the JavaScript value. A pure constant term is now folded for the reduction unless it reaches a function mapped through the functions option or a vars input.

  • Compiled JavaScript gave another absence value than the interpreter for an element read of an absent tuple. With tup := t ↦ (t, 2t), x: any and x = [], compiled At(At(tup(x), 1), 2) gave Missing; it now gives NaN, as the interpreter does. The compiled code now takes the value from the slot types of the tuple, so a slot typed unknown is no longer read as a number (Missing, not NaN), and a numeric slot of tuple<number, string>, or a position outside the tuple, gives NaN.

  • Compiled JavaScript over a symbol declared any that holds a list gave wrong values: with p = [1, 2] and q = [3, 4], p + q was the string "1,23,4", p·q was NaN, Max(p, q) was NaN, and p < q was one boolean. Such an operand is now tested at run time, and a list takes the list code ([4, 6], [True, True], 4). A value that is not a list runs the same code as before, and other types are not affected. Multiply and Power of such a list are entry-wise, as for a bare list.

  • Compiled PointX, PointY and PointZ of a value that is not a point (a number, a boolean, a string) gave NaN. They now throw a RangeError, as the interpreter gives an incompatible-type error. An absent value or NaN still gives NaN.

  • The type of a call that maps a function with a list result over an operand of unknown depth did not hold its value. With h := (t: number) ↦ [t, 2t] and x: any, h(x) was typed broadcastable<vector<2>>, which does not hold the list of matrices that x = [[1, 2], [3]] gives. It is now R | list<R | list<any>> over an operand of a top type and R | tensor<cells of R> over a tensor operand (R is the result for one element). A function with a boolean, string or tuple result over an operand typed any is typed the same way. A compiled Max or Min of h(x)[1] now declines, as for any operand that may hold Nothing.

  • interval-js: ≠ with an operand that has no value over the whole cell (\sqrt{x} \ne 0 over x = [-1, -1]) gave 'maybe'. It now gives 'true', as the interpreter and the other targets give True for NaN ≠ 0.

  • .N() did not check a value assigned to a callback parameter. The parameter of a Map callback is typed from the source elements, and evaluate() refuses an assignment of another type. Under .N(), evaluate({numericApproximation: true}) and N(…), the check was skipped: Map(q ↦ (q := q + 0.5; q + 1), 1..3).N() gave [2.5, 3.5, 4.5], and a list or a string was accepted too. It now gives an incompatible-type error for each element, as evaluate() does.

  • Static types that did not hold the value of the expression. In each case below the type of the expression was not a type of its value; it now is.

    • Map(g, t) with g := x ↦ x + 10 and t := ([1, 2], 3) is typed list<number | vector<2>> (it was list<number>, for the value [[11, 12], 13]) (CE-5). A function literal with an untyped parameter over a list<any> gets a list<any> arm in its result: (x ↦ (x, x))(A) is list<list<any> | tuple<…>> (CE-16).
    • (1, 2) + q and (1, 2)·q with q: unknown have number components (they kept integer, and the value for q = 0.5 is (1.5, 2.5)) (CE-373).
    • With tv: tuple<number, number> | vector<number^2>, Sin(tv), Sqrt(tv) and Power(tv, 2) are typed tuple<number, number> | vector<2> (they were number) (CE-7).
    • Sort(tu) with tu: tuple<unknown, missing> := (1, Missing) is typed list<missing | unknown> (it was list<missing>) (CE-3).
    • A tuple whose coordinate is a list with a failed cell has the same type inside a list as alone (it was error inside a list) (CE-367).
    • A Fold whose body assigns its accumulator is typed unknown, not from its seed alone (CE-11).
    • Any and All are typed boolean | missing when a cell can be absent: Any([1, Missing], x ↦ x > 2) evaluates to Missing (CE-8).
    • With the alias cyc = cyc | list<integer> and k: cyc := [1, 2], Map(x ↦ x + 10, k) is typed list<integer> and evaluates to [11, 12]. It was typed collection<integer> and evaluated to Set(11, 12) (CE-31).
    • If(c, 1, 0) with c of unknown type is typed integer | list<integer | list<any>>: c can hold a mask. A condition that can be undecided ([1, 2] = [1, Missing]) adds missing to the type of If and Which (CE-22).
    • MatrixMultiply of two vectors is typed as a scalar (integer for integer vectors). A lazy IdentityMatrix(101) is typed matrix<integer^(101x101)>, and Tabulate with literal extents has a shape. TrigExpand, TrigToExp and TrigReduce of Missing or of an untyped symbol keep the operand's type, not value (CE-39).
    • With f(n, s) = s if n == 0 else f(n - 1, [1, 2]), f(1, [0]) keeps the declared result; it was typed vector<integer^1> for the value [1, 2] (CE-34).
    • ce.box(expr, { form: 'structural' }) binds symbols to their definitions, as ce.function() does: with t: tuple<integer, integer>, Add(t, t) is typed tuple<integer, integer> (it was unknown) (CE-29).
  • More precise static types.

    • An operand that may be absent adds nan to a numeric result type, instead of widening the type to number: Length(At(xs, 1)) is integer | nan | signed_infinity, and Sin(m) with m: real | missing is nan | real (CE-41). [-2, NaN, Missing] is typed list<integer | missing | nan^3>.
    • v / g(1) with v: vector<integer^2> and an integer-valued g is typed vector<rational^2>, as the tuple route gives (CE-59).
    • A list literal of points of different tuple types keeps its length: [(1/2, 1), (~oo, ~oo)] is list<(tuple<…> | tuple<…>)^2> (CE-28).
    • First(Rest(xs)) and First(Take(xs, 2)) over a list literal have no absent member, and a numeric position proved not to exist (At([7, 8, 9], 99)) is typed nan (CE-42).
    • Map(Sqrt, x) over a union of vector types is typed arm by arm, and Map(Add, xs, ys) over two list<integer> is typed list<integer> (it was list<value>) (CE-31, CE-45).
    • An undeclared self-recursive function that builds a list is typed -> list<number>, and so is a declared mutually recursive pair (they were collection<number> or -> unknown) (CE-37, CE-38).
    • Simplify and the other lazy operators type the operands of a held application from their bindings (CE-40).
    • assume(t ∈ ExtendedRealNumbers) types t as real | signed_infinity (it left t typed unknown); the other extended sets work the same way (CE-50).
    • q: integer := uu, with uu an undeclared symbol, narrows uu to integer (CE-39).
  • Arguments and values that were admitted and now are refused when boxed.

    • g(S) with g: (integer) -> integer and S: list<string> (declared, no value) is an incompatible-type error, and so is f(S) with f: (matrix<2x2>) -> integer. h(S) with h: (list<integer>) -> integer stays valid, because the empty list is a value of both types (CE-24).
    • Map(h, s) with h: (T) -> T where T: number and s: list<string> is refused; it failed on each element (CE-30).
    • Q + M with Q a list of lists of points and M a list of lists of integers is one incompatible-type error, not a nested list of errors (CE-28).
    • After ce.declareType("Row", "vector<integer^3>", {alias: true}), ce.declare("R2", "list<Row^2>") is refused with the same message as list<vector<integer^3>^2> (CE-17).
  • A value-bounded type variable accepts its own literal (CE-52). With f: (x: T) -> T where T: 5, f(5) is typed 5; it was an incompatible-type error. In the same way where T: real<0..10> accepts g(3).

  • Setting .value follows the rules of ce.assign() (CE-49). n.value = 5 types n as integer (it stayed unknown), a value that does not fit a declared type is refused, and m.value = "hi" on an inferred integer symbol changes the type as ce.assign does (the type stayed integer). On a boxed symbol whose name a nested scope declares again, the setter writes the symbol's own binding, not the inner one.

  • A destructured local aliases the parameter it came from (CE-35). g(s) = do { let (a, b) = (s, 0); take(a, 2) } gave three incompatible-type errors for g([1, 2, 3]); it gives [1, 2].

  • A value-bound function with a declared effects contract refuses a rebuild that makes it effectful (CE-62). With g: (number) pure -> number bound to x ↦ 2h(x) and then h := x ↦ x + Random(), the body of g was silently rebuilt into a random call; the rebuild now throws EffectContractError and keeps the old body.

  • Epsil: an alias of a binding that holds a function literal is checked like the binding (CE-61). After let k = (n: integer) => n + 1 and let g = k, g(1.5) is reported by the static check, as k(1.5) is.

  • A float coefficient times a power of a reciprocal was written with a factor 1\times (CE-439). Multiply(0.5, y, Power(Divide(1, x), 2)) was written \frac{1\times0.5y}{x^2}, and its prettified MathJSON had the numerator ["Multiply", 1, 0.5, "y"]. The numerator of 1/x is 1, raised to the power 2, and the split of the product into a numerator and a denominator kept that 1^2 as a factor, which does not fold with a float coefficient. It is now \frac{0.5y}{x^2}. A long numerator over a single power of x is still written with a solidus, (0.5\vert x\vert x^{n})/x^2, as before.

  • The LaTeX of a function whose parameter is _1 showed an empty parameter list (CE-417). ["Function", ["Divide", "_1", "n"], "_1"] was written ()\mapsto\frac{\operatorname{\_1}}{n}, which looks like a function of no argument. A function literal whose parameters are the wildcards of its body now writes them: \operatorname{\_1}\mapsto\frac{\operatorname{\_1}}{n}, and (\operatorname{\_1}, \operatorname{\_2})\mapsto\operatorname{\_1}+\operatorname{\_2} for two. Both read back as the same function, as before. A body that uses a bare _ keeps ()\mapsto.

  • The type of a call whose argument may be absent did not hold the value of the call (CE-422). The type of a call of a user function is derived from its body, a second time with the parameter typed missing when the argument may be absent. The parameter is now typed as the call binds the absent value. With P: tuple<number, number> | missing:

    • At a parameter declared broadcastable<tuple<number, number>>, a string, a boolean or a list, an absent argument is an incompatible-type error, and it adds nothing to the type. With f := a ↦ (a.x + 1, a.y + 1), f(P) was typed tuple<missing, missing> | tuple<number, number> and is now tuple<number, number>, as at a parameter declared tuple<number, number>.
    • At a parameter declared real or integer, the absent value is read as NaN. With f := a ↦ (a, 1) and f: (real) -> unknown, the call was typed tuple<real, integer>, and its value (NaN, 1) did not fit. It is now typed tuple<nan, integer> | tuple<real, integer>, and f(Missing) is typed tuple<nan, integer> (it was tuple<missing, integer>).
  • Compiled Digamma, Trigamma and PolyGamma gave NaN at a pole. The interpreter answers ~oo at a non-positive integer, and compiled code spells that pole Infinity, as it does for Gamma. With a variable argument the compiled JavaScript gave NaN; it now gives Infinity. Compiled PolyGamma(-1, x) gave NaN; it is now LogGamma(x), as in the interpreter, for x > 0 and at a pole.

  • The GPU LerchPhi and PolyLog gave ±Infinity for some finite values at a large negative order, for example Φ(−0.5, −30, 10) = 8.0e28. They now give the value.

  • The colour reference showed wrong results. Color returns an Oklch expression, not a tuple, and ColorFromColorspace returns the colour expression of its space (Hsl(0, 1, 0.5)). doc/86-reference-colors.md now shows the real results.

  • GLSL and WGSL: a product with a derivative or with a one-operand function lost its grouping. 3·h'(x) with h(x) = x³ + sin x compiled to 9.0 * (x * x) + cos(x), not 9x² + 3cos x, and 3·Binomial(x + 1, 1), and ElementMax, ElementMin or Median with one operand, compiled to 3x + 1. The substituted body is now in parentheses, so the code of a derivative at the root is also in parentheses, for example (-sin(t)).

  • GLSL and WGSL: Oklch and Oklab with an infinite or NaN channel gave the raw channels. They now give the NaN colour, as compiled JavaScript does.

  • WGSL: ContrastingColor with a tuple background and colour candidates (ContrastingColor((u, 1, 1), Rgb(1, 0, 0), Rgb(0, 0, 1))) declined. It now compiles, as on GLSL.

  • GLSL: a loop-invariant declaration that no code reads is no longer emitted.

  • Compiled Python: And, Or and Not over a list of booleans read the list as one truth value (And(L, q) gave True for L = [True, False]). They are now computed element by element, as in the interpreter. Other list shapes are not compiled.

  • Compiled Python: \arg(x - iy) at y = 0 gave −π for a negative x. It now gives π, as the interpreter does.

  • Compiled Python: a string at a parameter of a list type was read as a list of code points, so Length counted "e\u0301x" as 3 where the interpreter counts 2 characters. The compiled function now refuses a string there with a TypeError, as compiled JavaScript does.

  • A one-operand Subtract, ElementMax or ElementMin from raw MathJSON compiled to a wrong value: 3·Subtract(x + 1) was 3 * (-x + 1), and 3·ElementMax(x + 1) was 3 * x + 1 on Python. A one-operand Divide or Power, and a Clamp with fewer than three operands, gave invalid code or NaN. They now compile to the operand, or are not compiled, as the interpreter gives an error for them.

  • Compiled Max and Min of a nested list of unknown type gave NaN (Max(g(x)[1], 1) with g := t ↦ [t, 2t] and a matrix element). They now flatten every level, as the interpreter does.

  • Compiled JavaScript added a number to a point: At(P, 1) + 1 with P: broadcastable<tuple<number, number>> gave [4, 5], where the interpreter gives an incompatible-type error. It is now not compiled.

  • A compiled power with a list exponent with a fractional cell gave NaN for a negative base (p^[2, 0.5] for (-4)^0.5). It now takes the complex lane, as a scalar exponent does, and gives 2i.

  • A compiled Root whose degree is known only at run time (Root(p, 3) over a list, or Root(x, n)) gave NaN for a negative radicand. It now gives the real root where the interpreter does (Root(-8, 3) is -2).

  • A compiled complex function of NaN (√ of a sum with an absent cell) gave {re: 0, im: NaN}. It now gives NaN.

  • A compiled user function read a free name of its body in the scope of the compilation. With a global a := 2, G := x ↦ x + a, and a assigned 1000 in a new scope, compiled G(1) gave 1001; the interpreter gives 3. Compiled code now declines a call whose body reads a name that the scope of the compilation binds to a different definition.

  • vars: { g: '…' } did not replace a user function g at its calls in compiled code. It now does, on JavaScript and interval-js. On GLSL and WGSL a vars entry names a uniform, which cannot be called, so a user function keeps its own definition there; the functions option replaces it.

  • A list of points at a parameter with no type annotation declined, and two forms gave the string "1,21": a list held by a let local at a point parameter, and the same call of a function assigned in the engine. They now compile and give the interpreter's value.

  • Compiled arithmetic on a function value (g + 1 with a block-local g) gave a string or null. It is now not compiled.

  • Compiled code did not check some parameter types. An argument at a list or tuple parameter is now tested for its shape, and the arguments of an annotated function literal applied directly are tested too; a failed test gives NaN, as the test of an integer parameter does. An assignment to a typed parameter of a value outside its type is now not compiled.

  • freeSymbols listed names that the program binds: a library function passed by name (reduce(1..20, lcm, 1)), the index of a comprehension read by its condition, and the names of a destructuring loop index. The compiled Epsil programs that read them now run.

  • Evaluating a dictionary that holds the same dictionary under several keys took exponential time (30 levels did not finish in 25 s). Each shared value is now evaluated once: 30 levels take about 1 ms.

  • A compiled power read a symbol in its exponent as unknown. With k = 1, y^{k/2-1} stayed on the real lane and gave NaN for a negative y, where y^{-1/2} gives the complex value. The value of an assigned symbol in the exponent is now used, so the two forms agree; the interpreted integral of such an integrand is complex too. A free symbol keeps the real lane.

  • A compiled ordering with a constant pole (x < 1/0, x < (-1)!) was true at x = 1, because 1/0 compiled to IEEE infinity. The interpreter gives False, and compiled code now does too, on every target.

  • When(v, C) with a mask of rank 2 or more now selects cell by cell at every level, as If and Which do, in the interpreter and in compiled code. Before, compiled JavaScript read each row as one condition and gave [NaN, NaN], and the interpreter kept the When unevaluated.

  • A compiled selection whose arm is a point or a list of points read one point as a list. One point is now lifted whole, as in the interpreter.

Improvements​

  • A comparison over a string or a tensor of strings (S: broadcastable<string>, matrix<string>) compiles again on JavaScript.

  • When(B, B < 0) with B: broadcastable<number> compiles.

  • mode: 'complex' compiles an operand that may be a number or a list (broadcastable<number>, number | list<number>). Before, it declined.

  • A function declared with a real result stays on the real lane in mode: 'complex' when its definition is emitted, and when it reads an assigned value that is complex only by promotion.

  • Compiled code calls a function returned by a call: let a = mk(2) then a(1), when mk returns a function literal on every path.

  • interval-js compiles an index into the tuple that a block-local function returns (At(f(x), 1) with f := (u) ↦ (u + 1, 2)).

  • Compiled Python: Equal and NotEqual with one list of numbers compile to a list of booleans, as the interpreter answers. Before, they were not compiled.

  • Compiled Python: a function defined in a block (const h = (k) => k + 1, function h(k)) can be called when its arguments are numbers, booleans or strings.

  • GLSL and WGSL: Mod(v, 1) and 2^v over a vector compile to fract(v) and exp2(v). Before, these forms applied to a scalar only.

  • GLSL and WGSL: GCD and LCM of any number of operands, a vector or a literal list included, compile as a fold over the components. Before, GCD took exactly two scalars and LCM did not compile.

  • GLSL and WGSL: a Match whose case body holds a loop-form Sum or Product compiles to an if … else chain. Before, it declined.

  • GLSL and WGSL compile Digamma, Trigamma and PolyGamma (order 0 to 24) of a real argument. Before, these heads fell back to the interpreter (#340). The relative error in float32 is about 1e-6 for Digamma and Trigamma and 1e-5 for PolyGamma.

  • The GPU Zeta(s) answers down to s = −63. It answered NaN below s = −24.5.

New Features​

  • epsil --workers <n> (also in the REPL and in epsil mcp) gives the engine a pool of n worker threads (at most 256) and runs each program asynchronously, so a findFit with a global search scans on the workers and gives the same values. Without the option, nothing changes. --workers auto picks the default pool size (the count of cores minus 1, from 1 to 8). With a pool, Ctrl-C in the REPL stops the entry that runs and gives the prompt back; the declarations of the session stay, and a second Ctrl-C ends the process if the entry does not stop.
  • IsPrime compiles to JavaScript.

Performance​

  • Derivatives of order 3 or more of nested expressions are faster and shorter: each intermediate derivative is simplified before it is differentiated again. The third derivative of √(x+√(x+√(x+√(x+x)))) takes about a third of the time and prints 37% shorter.

  • A coordinate of point arithmetic over a list of points held by a symbol (PointX(4·P + 0.3·(t, t)) with P: list<tuple<number, number>>) is compiled as 4·PointX(P) + 0.3·t on the JavaScript and interval targets, so the points are not built. Reduce(L, Add) (also Multiply, Max, Min) compiles on the interval target when L is not written out but its width is known from its structure.

  • A Map whose element function is a tree of integer operations is compiled with exact results when the bounds prove that no step leaves the safe integers: sum(map(x => x^2 + 1, 1..100000)) takes 0.12 s, it took about 10 s. map(f, xs) with a named user function f uses the same compiled element function as a lambda: exact results for an integer body, and machine floats for an N(…) body when the engine runs at machine precision.

  • A list or a tuple of string literals evaluates to itself, as a list of numbers already did. A recursion that passes a list of 3,000 strings at each call is about 30 times faster, and the cost of a call no longer grows with the length of the list.

  • A symbol that holds a tuple expression, such as a point (f(u), g(u)), evaluates its elements once and gives the remembered value at the next reads, until a name it reads changes.

  • A symbol assigned a seeded random draw is memoized again (WithRandomSeed(…), also inside other arithmetic). Each element read drew and computed the whole list again.

  • evaluateAsync() of a list or a tuple of numbers returns the same expression at once, as evaluate() does: about 2 ms in place of 180 to 350 ms for 20,000 points. The data preparation of FindFit.evaluateAsync() is 2 to 3 times faster, and the asynchronous evaluation of a large list gives the event loop a turn about every 8 ms: a timer can run, and an AbortSignal aborted during the evaluation rejects it with a CancellationError.

  • The compile-time constant fold no longer walks more than 10,000 elements or iterations, and CountIf, Any, Reduce and Fold are priced per element: sum(filter(1..99999, isPrime)) compiles in about 0.1 s (it took 6.9 s) and runs as compiled code.

  • Compiling in degree, grad or turn mode no longer resets the caches of the engine for each constant subexpression that is folded (about 30% faster for 60 angular constants).

  • Compiled JavaScript: Min/Max of a scalar and a list, or of two lists, no longer copies the lists (7 to 9 times faster at 10,000 elements); Re, Im, Abs and Arg of the square or the cube of x + iy read the parts directly, with no complex object; a block-local function that the block only calls has no broadcast wrapper; a loop that replaces elements of a local list (xs = ReplaceAt(xs, i, v)) copies the list once and then writes in place, O(n) in place of O(n²), with no change to the array of the caller (#386). Map(Sin, [a, b, c, d, e]) and other one-parameter library operators over a written-out list of five or more elements compile to separate calls on every target, glsl, wgsl and interval-js included.

  • .N() of an infinite sum whose convergence cannot be established declines in milliseconds, not in up to 30 seconds: the walk first evaluates the terms with the compiled body (#326). Values above machine precision are unchanged.

  • The Python target computes the index-independent collection work of a Sum or a Product once, not at each iteration (n = 10,000: 34 → 1.4 ms).

  • A misordered nested call of a callback operator (Map(Map(xs, f), g), Filter(p, …), Fold(xs, f, 0), TakeWhile, DropWhile, FlatMap, MaxBy, MinBy) takes a time linear in its depth (it was exponential: 5 s at depth 10). Canonicalizing deeply nested function literals is faster (a Map 80 levels deep: 434 → 156 ms), and arithmetic on symbols of type real | +oo | -oo no longer builds that type again for each result.

  • The test suite runs 6 to 9 times faster: a custom jest environment (config/jest-environment.cjs) makes the vm context of each test file with an ordinary global object, so a read of Math or Number is no longer a proxy trap.

Behavior Changes​

  • A Map of a named function whose body is in N(…) compiles at machine precision also when the body holds a loop or an inner collection, as the same body written as a lambda already did. map(g, 1..200) with g(x) = N(h(x) + 1) and h(x) = Sum(x/k, k = 1..10) is now compiled; its values can differ from the interpreted values in the last digits. A body whose nested loops do more than 100,000 turns in total stays interpreted.
  • Σ 1/(n (ln n)^p) with p ≤ 1 evaluates to +∞ under evaluate() and .N(), and so do other series that diverge as slowly as Σ 1/(n ln n) (a limit comparison with Σ 1/(n ln n)). They stayed symbolic.
  • Compiled JavaScript Arg of a square or a cube now gives a zero imaginary part as +0, so arg((0 − i)^2) is π, as in the interpreter (it was −π).
  • Setting .value on a symbol checks the value as ce.assign() does. n.value = "hi" for n declared integer now throws a TypeCompatibilityError; before, the value was written and the type stayed integer.
  • A numeric result whose operand may be absent keeps its handler's type and adds nan, instead of becoming number. Sin(m) with m: real | missing is typed nan | real; Length(At(xs, 1)) is integer | nan | signed_infinity. Code that compares a type string with number sees the narrower spelling.
  • Any and All can be typed boolean | missing, when a cell can be absent or the predicate can answer Missing.
  • The declared result of MatrixMultiply is matrix | vector | number. The product of two vectors is a number, which the old signature matrix | vector did not admit.
  • More calls are refused when they are boxed, where they failed on each element at evaluation: a declared function applied to a symbol whose type shares no value with the parameter (CE-24), and Map with a polymorphic callback whose type-variable bound refuses the element type (CE-30).
  • An inferred signature keeps its parameter names (CE-60). (x) => x + 1 has the type (x: unknown) -> number; it printed (unknown) -> number. A wildcard parameter (_, _1) and a destructuring pattern have no name. The names do not change how types compare. So a named call to an unannotated function literal held in a binding works: f := (a, b) => a - b; f(b: 2, a: 10) gives 8 (it was argument-name-unknown), and in Epsil ns.D(k: 1, x: 10) with const ns = {D -> ((x, k) => x - k)} no longer reports argument-names-unavailable. When a call names a parameter that the function does not have, the error lists the declared names.
  • A library operator given by name to Map is typed by its own type handler (CE-45.2), as x ↦ f(x) is. With xs: list<integer>, Map(Abs, xs) was list<number> and is list<integer<0..>>; Map(Sin, xs) was list<number> and is list<real>; Map(Conjugate, xs) was list<unknown> and is list<integer>. A function you declare keeps its declared result. A more precise type can change a value: with L: list<vector<number>>, Map(Reverse, L) · Map(Reverse, L) was an entry-by-entry product and is now the matrix product, as L · L and the lambda form Map(x ↦ Reverse(x), L) give.
  • A free symbol in a list literal is inferred as a number (CE-39.4), as in x + 1. With L := [u, v], the value of At(L, 1) was typed unknown and is number. A symbol used in a list literal and later used as a boolean condition gives an incompatible-type error. A parameter of a function literal is not affected (x ↦ [x] keeps the result list<unknown>). Arithmetic over free symbols in a list literal is read as a number too (CE-39.6): AdjugateMatrix([[x, y], [z, w]]) evaluated to a value typed list<list<unknown>> and is a matrix<2x2>.
  • A function with a point parameter maps over a matrix or tensor of points (CE-448). With g: (tuple<real, real>) -> real and P := [[(1,2),(3,4)],[(5,6),(7,8)]], g(P) was an incompatible-type error and is the 2×2 matrix of g at each point, as PointX and Abs give. For P: broadcastable<tuple<real, real>>, g(P) was typed list<real> | real and is broadcastable<real>; for T: tensor<tuple<real, real>>, g(T) was typed error and is tensor<real>. Compiled JavaScript maps the same calls; it threw "not a point" at rank 2.
  • A captured free symbol of a function literal is inferred on its outer binding, and a call checks the captured values (CE-473). In g := x ↦ [-x, y], y was left unknown outside the literal; it is now inferred number where it is declared. After y := "abc", a call of a named function whose captured value no longer fits the type its signature relied on (g(3) was [-3, "abc"], typed vector<2>) gives incompatible-type; Apply of the literal is typed from the current value; compiled JavaScript refuses such a call. A symbol read as the head of a provisional product ((g)(t) before g is a function) is not narrowed outside the literal.
  • A call never maps over the arguments of a rest parameter. h(...xs) = Length(xs) with h([1,2,3]) gave [1,1,1] and is 1; h([1,2],[3]) gave [2,2] and is 2. A fixed parameter before the rest parameter still maps a list. The same holds for Apply and Map.
  • A rest parameter is typed tuple in the body (CE-470), and Length and Count of a tuple are integer: h(a, ...rest) = Length(rest) was typed -> integer | signed_infinity and is -> integer.
  • A point-function call over a collection symbol with no value stays symbolic (CE-468). With g: (tuple<real, real>) -> real and a valueless L: list<tuple<real, real>>, g(L).evaluate() gave incompatible-type and is now g(L); after L has a value, it maps.

0.167.0 2026-10-24​

Behavior Changes​

  • FixedPoint(f, x) stays unevaluated when the default iteration limit is reached without converging (#474, contributed by enumeratio). FixedPoint(x ↦ 1 - x, 0.3) cycles between 0.3 and 0.7, and used to return whichever it held after ce.iterationLimit applications, a value that is not a fixed point. The call now stays unevaluated, as the divergence guards of FixedPoint and Any over an unbounded source already do. This also applies to an iteration that converges too slowly for the limit: at a precision of 200 digits, FixedPoint(Cos, 1.0) needs about 1170 steps, and it used to return a value correct to about 175 digits. A larger ce.iterationLimit lets such an iteration converge. With an explicit maximum, the last value is still returned, as in Wolfram's FixedPoint[f, x, n].

New Features​

  • A worker pool for a kernel loaded with the runtime entry (CE-436, Tycho row 445). The light entry @cortex-js/compute-engine/runtime now exports createKernelPool, and its runtime has loadBroadcastAsync(stored, { workers }): the broadcastAsync() of the compilation result, for code that a host stored and loads without an engine. It takes the same options (out, deadline, signal, chunk, inline, …) and gives the same result (done, route, reason); the function also has willUsePool(cells) and shippable. Only stored code that keeps the shippable: true field of its compilation result goes to a worker; other code runs on the calling thread (reason: 'not-shippable'). A worker that loads stored kernels can now run them on a pool of its own without importing the engine bundle. The runtime entry grows by about 15 KB minified (5 KB gzipped).

    import {
    createJavaScriptRuntime,
    createKernelPool,
    } from '@cortex-js/compute-engine/runtime';
    const workers = createKernelPool({
    create: () =>
    new Worker(
    new URL('@cortex-js/compute-engine/kernel-worker', import.meta.url),
    { type: 'module' }
    ),
    });
    const run = createJavaScriptRuntime().loadBroadcastAsync(stored, { workers });
    const { out, done, route } = await run({ x: xs }, { deadline: 100 });
  • Index axes in the grid layout of broadcast (ROADMAP CE-421, Tycho row 440). The indexes option names the inputs that are index axes: the input of each is the axis length n, and at each cell the variable is the cell's position along that axis, 0 to n − 1. f.broadcast({ m: 64, n: 64, F }, { layout: 'grid', indexes: ['m', 'n'] }) gives the values of the same grid with the columns 0 … 63, with no column to build, send to a worker or read. On a marching kernel that reads eight values of a shared list per cell, a 64 × 64 × 64 grid takes 3.5 ms instead of 4.05 ms.

  • executeEpsilAsync() (ROADMAP CE-314.1, Tycho row 441). Runs an Epsil program as executeEpsil() does and returns a promise of the same result. Each top-level statement is evaluated with evaluateAsync(), so a findFit whose search runs on the worker pool, or an operator with only an evaluateAsync handler, takes its asynchronous route. The signal option stops the program: the promise rejects with a CancellationError. A withTimeLimit() span around the call applies to the whole program.

Bug Fixes​

  • A chain of mixed comparisons serializes as a chain (CE-433). Since 0.166.0, 0 < M \le 5 parses to ["And", ["Less", 0, "M"], ["LessEqual", "M", 5]], and the serializer wrote that And as 0\lt M\land M\le5. It now writes 0\lt M\le5, which parses back to the same MathJSON. An And is written as a chain only when the chain spelling parses back to it: each link starts with the term the previous link ends with, and two adjacent links have different operators (a<b \land b<c keeps its \land, since a<b<c is one Less). A chain inside an \land needs no parentheses: 0\lt x\le1\land y\gt2.

  • broadcastAsync() with a deadline settles when the workers never start (CE-434). A batch that waited for a worker that never posts ready (for example a worker that fails only when it gets a message) never settled, even with a deadline: the pool started to watch the deadline only when it sent the task to a worker. A task that still waits at its deadline now settles with no cells done, and the batch resolves at about the deadline.

  • ReplaceAll in a function body reads a parameter as its argument (#476). ReplaceAll holds its target, so in x ↦ ReplaceAll(x, a → b) the rules were matched against the symbol x, and the call returned its argument unchanged: FixedPoint, Map or a call over a rewriting step did nothing. A parameter of a running call, in the target or in a rule, now means the value the call bound to it, as Function[x, x /. a -> b][a] does in Wolfram:

    const f = ['Function', ['ReplaceAll', 'x', ['List', ['Rule', 'a', 'b']]], 'x'];
    ce.box(['Apply', f, ['Add', 'a', 1]]).evaluate();
    // Before → a + 1 Now → b + 1
    ce.box(['FixedPoint', f, 'a']).evaluate();
    // Before → a Now → b

    Outside a function nothing changes: a held target keeps a symbol with an assigned value as the symbol (x := 5; ReplaceAll(x^2, x → 3) is 9), and so does a let local of a function body.

  • Assign and Declare evaluate their value on the asynchronous route. With evaluateAsync(), x = FindFit(…) and let x = FindFit(…) ran the synchronous FindFit, and an operator with only an evaluateAsync handler stayed unevaluated in the value. Both now await the asynchronous evaluation of the value; the results of the synchronous route do not change.

Performance​

  • expr.latex keeps its string, and prettified serialization is cheaper (ROADMAP CE-438, Tycho row 444). A second read of .latex on the same boxed expression returns the string of the first read while nothing that can change it has changed (a declaration, an assignment, an assumption, the precision or another engine setting, the LaTeX options or dictionary): about 0.3 µs instead of about 200 µs for a 20-node expression. The prettified MathJSON that .latex and toLatex() use no longer splits a product with no factor that can go to a denominator into a numerator and a denominator, which built and canonicalized new expressions: a first .latex of such an expression takes about 180 µs instead of 280 to 360 µs. The output does not change. toLatex({ prettify: false }) stays the fastest form for a string used as a key; expr.digest is the key to use for a cache.

  • The sum of a compiled Map is computed on doubles (ROADMAP CE-432). At machine precision, Sum(Map(f, xs)) whose element function the auto-compilation compiles now adds the doubles of the elements, with the rules of the element-by-element sum, instead of boxing each element: sum(map(x => N(sin(x) + x^2), 1..100000)) takes about 45 ms instead of about 190 ms, with the same value. The asynchronous route takes the same path in steps that give the event loop a turn. A Map over a set, an impure Map, and any element that the interpreter must compute keep the element-by-element sum.

  • FindFit.evaluateAsync() no longer blocks the calling thread for long (ROADMAP CE-429). One grid point of the separable scan and the setup (the data rows, the compilation of the model and of its derivatives) now give the event loop a turn, about every 8 ms, and an abort during the setup stops it. For an interpreted model over 200 rows, the longest block went from about 110 ms to 12 ms; the results do not change.

  • A product of decimal values no longer keeps every digit (ROADMAP CE-319.4 and CE-319.5). The product of two decimal values is rounded to twice the working precision plus 20 digits when it has more. It kept every digit of the exact product, so a value multiplied by itself at each step of a loop doubled its digits at each step: at precision 50, FixedPoint(x ↦ 2.8·x·(1 − x), 0.2) did not finish in 2 minutes, and now takes about 40 ms. .N() of a product of 64,000 factors went from 43 s to 0.9 s. The digits past the working precision are kept as guard digits, so the printed values do not change.

  • A product of many distinct factors takes a time proportional to its size (ROADMAP CE-319.3). A product looked for each new factor among all the factors it already held, and .N() also built a new product for each factor. A product of 2,000 distinct symbols took 18 ms on evaluate() and 8.6 s on .N(); it now takes 12 ms and 7 ms, and 64,000 symbols take about 250 ms on both routes. This also fixes the float coefficient of such a product under .N(): the coefficients were multiplied from the right, so 10⁻²⁰⁰·10⁻¹²³·10²⁰⁰·x went through a subnormal value and gave 9.88·10⁻¹²⁴·x, and some orders of large and small factors gave a coefficient of 0. It is now 10⁻¹²³·x.

  • Compiling an expression with shared subexpressions and constant folding on takes a time proportional to its distinct parts (ROADMAP CE-319.1). Before it folds a constant, the compiler checks the whole expression for a name bound by the compilation, a Sum or Product with a non-finite bound, and a free symbol reached through an assigned value. Each check visited a shared subexpression once for each place it appears, so e = e + e repeated 24 times over 2 + √3 took 3.9 s to compile. Each check now visits a subexpression once: the same expression compiles in about 110 ms, and at 60 levels in about 200 ms. The compiled code does not change.

  • A more deeply nested expression compiles (ROADMAP CE-320.2). The compiler is recursive, and each level of a nested expression uses stack. A chain of Sin and Cos compiled up to a depth of about 280, and a polynomial in Horner form, a₀ + x(a₁ + x(a₂ + …)), up to a degree of about 120; past that, compile() answered success: false and the call ran in the interpreter. Each level now uses less stack, and the two limits are about 420 and 205. The compiled code does not change.

0.166.0 2026-10-24​

Breaking Changes​

  • A parsed comparison chain is no longer a nested tree (ROADMAP CE-411, Tycho row 433). The LaTeX and Epsil parsers wrote a < b \le c as LessEqual(Less(a, b), c), and a = b = c as Equal(a, Equal(b, c)). A run of one operator is now one n-ary node, Equal(a, b, c), and a run of mixed operators is an And of links that share their boundary term: a < b \le c is And(Less(a, b), LessEqual(b, c)), in the raw form too. MathJSON input that nests a comparison inside another comparison, such as ["Less", "a", ["Less", "b", "c"]], now means a comparison of the inner truth value, not a chain. The serializers put parentheses around a comparison inside a comparison.

Behavior Changes​

  • A declared type of a batch input is trusted (ROADMAP CE-410). In the batch body, a read At(F, i) whose index type proves an integer of at least 1 (for example i + 1 with i declared integer<0..>) is a direct read with no run-time test; a read past the end still gives NaN. When an input breaks its declared type (a fractional i for integer, a string in a list declared list<number>), the result is not defined. A plain array that passed the check once is not checked again while its length is unchanged.

  • A list at an untyped parameter beside a point parameter maps (ROADMAP CE-414.2). A parameter with no type (unknown, any, no annotation) of a function that also has a point parameter now follows the rule of a one-parameter function: its type is refined from the body, and it maps a list unless the body uses it as a collection. With f := (P, t) ↦ (P.x + t, P.y) declared (tuple<number, number>, unknown) -> unknown, f((1, 2), [0, 1, 2]) is [(1, 2), (2, 2), (3, 2)], typed list<tuple<number, number>>; it was ([1, 2, 3], 2). With (P, L) ↦ Length(L), L is refined to collection and the list is still taken whole. Compiled JavaScript gives the same values.

  • An absent point at a broadcastable<tuple<…>> parameter gives Missing (ROADMAP CE-420), as at a tuple<…> parameter. With f := (a, b) ↦ (a.x + b.x, a.y + b.y), f([(1, 2), (3, 4)], [(10, 20), Missing]) is [(11, 22), Missing] for every mix of the two declarations; with broadcastable<…> parameters the second element was an incompatible-type error. A list of only absent points, [Missing, Missing], now gives [Missing, Missing] for every declaration; at a tuple<…> parameter the call was an incompatible-type error. A list of points that is absent as a whole (P: list<tuple<…>> | missing holding Missing) now gives Missing at a broadcastable<tuple<…>> parameter too, and the call's type has the missing option; it was an incompatible-type error. Compiled JavaScript gives undefined for an absent point, and for an absent list, at a broadcastable<tuple<…>> parameter; it declined.

  • A time limit applies to an asynchronous evaluation (ROADMAP CE-424, Tycho's CE-314 feedback). ce.withTimeLimit(20, () => { p = expr.evaluateAsync(); }) had no effect: the limit was removed when the callback returned, before the evaluation went on after its first await. A FindFit global search ran to its end (270 ms). The evaluation now keeps the limit (and a withStepBudget budget) that is in force when evaluateAsync() is called, until it ends. A timeout is reported as by evaluate(): the FindFit search answers its best point so far with timedOut: True after 21 ms, and the time left is given to the workers of a kernel pool.

  • broadcastAsync() evaluates no cell before it returns (ROADMAP CE-425.2, Tycho's CE-314 feedback). A batch that stays on the calling thread (no pool, a result that cannot run on a worker, or fewer cells than the inline threshold) ran its first slice, up to 16 384 cells, before the call returned. The batch now yields to the event loop (a macrotask turn) before its first slice, as it already did between two slices. A signal aborted right after the call now stops the batch before its first cell.

New Features​

  • The record of FindFit.evaluateAsync() says where the search ran (ROADMAP CE-428, Tycho's CE-314 feedback). The record of the asynchronous route of FindFit and FindRoot has a route entry: "pool" when the workers of the engine's kernel pool solved the grid points, or "calling-thread". With "calling-thread", a reason entry says why the pool did not take the search: "no-pool", "no-global-search", "not-separable", "not-compilable", "pool-declined" (the pool rejected every slice), "no-points-to-ship" or "setup-timeout". The record of evaluate() has neither entry.

  • broadcastAsync({ …, inline: false }) refuses the calling thread (ROADMAP CE-426). When the batch would not go to the worker pool, the call resolves at once with done: 0 and evaluates no cell; when a worker fails during the batch, the call keeps the cells that the workers did and does not complete the rest on the calling thread. result.willUsePool(cells) of a compiled result says before the call whether a batch of that many cells would go to the pool now.

  • result.shippable says whether a compiled result can run on a worker (ROADMAP CE-427). It is false when the compiled code draws random numbers from the engine, names a host global (a vars or functions entry given by name), or has a caller preamble; such a result always runs on the calling thread. It is present where broadcastAsync is.

  • The result of broadcastAsync() says where the batch ran (ROADMAP CE-428). route is 'pool' or 'calling-thread', and reason, present when the pool did not take the whole batch, is one of 'no-pool', 'pool-unusable', 'not-shippable', 'below-threshold' and 'pool-declined'. The types BroadcastAsyncRoute and BroadcastAsyncReason are exported. route is a required field of BroadcastAsyncResult: code that builds such an object itself (a mock, for example) must set it.

Bug Fixes​

  • FindFit.evaluateAsync() lets timers run during a search on the calling thread (ROADMAP CE-425.1, Tycho's CE-314 feedback). With no kernel pool, or when the pool does not take the search (a model that does not compile, a search that is not separable, no global option), the whole search ran in one piece: a 5-point fit of a cos(b x + c) + d LogGamma(x + c) blocked the thread for more than 4 s, so a page did not respond and an AbortSignal aborted by a timer was not seen until the end. The search now gives the event loop a turn about every 8 ms (between grid points, solver starts, solver iterations and groups of 8 data rows); the longest block of that fit is now about 25 ms. The results do not change; a short fit is up to about 5 % slower, near the noise of the measurement.

  • Equal and NotEqual of two relations compare their truth values (ROADMAP CE-411, Tycho row 433). ["Equal", ["LessEqual", "a", 0], ["LessEqual", "b", 0]] was boxed as the chain a ≤ 0 = b ≤ 0, and the LaTeX (a\le0)=(b\le0) evaluated as that chain after a substitution: both gave False for a = b = 1. Both now give True when the two conditions are both true or both false.

  • A negated relation in a comparison chain negates only its own link (ROADMAP CE-411). a < b \not\le c is And(Less(a, b), Not(LessEqual(b, c))); it was Not(And(Less(a, b), LessEqual(b, c))). The LaTeX commands \nleq, \nleqslant, \ngeq, \ngeqslant, \not\lt and \not\gt are now parsed; they gave an unexpected-command error.

  • A call of a function with a broadcastable<tuple<…>> parameter over a list of points compiles (ROADMAP CE-412, Tycho row 437). With f := a ↦ (a.x + 1, a.y + 1) and P a list of points, or a point or a list of points, compile(f(P)) declined. It now compiles to one call per point, as a bare tuple<…> parameter does, and gives the interpreter's values.

  • A bare tuple<…> parameter maps a list of points of unknown length (ROADMAP CE-413, Tycho row 438). With n unbound, a call over Map((u, v) ↦ (u, v), \frac{1}{n}[0...n], \frac{1}{n}[0...n]) was an incompatible-type error. It now maps one point per call, lazily, as for a list of known length.

  • A list in a scalar slot beside a point parameter maps (ROADMAP CE-414, Tycho row 439). With h := (P, t) ↦ P.x + t and t declared real, h((1, 2), [0, 1, 2]) was an incompatible-type error. It is now [1, 2, 3], typed as a list, in the interpreter and in compiled JavaScript. This applies when the slot's type cannot hold a list (real, number, boolean, string); for a slot typed unknown, see Behavior Changes.

  • A compiled call with an argument declared broadcastable<tuple<…>> that holds one point compiles (ROADMAP CE-415). It declined; it now gives the interpreter's value.

  • Printing a function whose parameter is _1 no longer defines _ (ROADMAP CE-416). toString() renamed the parameter with a substitution that created the symbol _ in the engine's scope, so a later lazy Map printed Error(incompatible-type …) in place of a call.

  • broadcastAsync reads an input that shares memory with out as it was given. When an input array was a view of the output buffer, each slice read it after earlier slices had written their results there: with Tuple(x + 1, 10x), x = [1, 2, 3, 4] in the first half of out and chunk: 1, the result was [2, 10, 11, 100, 12, 110, 101, 1000] instead of [2, 10, 3, 20, 4, 30, 5, 40]. broadcast was correct. Such inputs are now copied once before the first slice.

Performance​

  • A broadcast kernel runs up to 15 times faster (ROADMAP CE-410, Tycho row 432). For a kernel that reads two values of a shared list<number> and returns a tuple, 250 000 cells take about 0.55 ms instead of 8.3 ms when the index is declared integer<0..>, and about 0.95 ms when it is declared integer. A kernel that reads a shared list gets a second compiled body for batches: the list is checked once per batch (a Float64Array, or a plain array of numbers) and then read directly, and the inputs are passed as arguments. A shared Float64Array is read in place; it was copied into a plain array on every batch. The cell loop is generated for each compiled body, And/Or of plain comparisons compile to a conditional, and the per-cell call is direct.

0.165.0 2026-10-24​

Breaking Changes​

  • The IntervalValue type includes the answer strings of a comparison (ROADMAP CE-210, Tycho row 411). A compiled interval-js runner returns 'true', 'false' or 'maybe' for a boolean expression, but the public type IntervalValue was IntervalResult | Interval | IntervalValue[]. It is now IntervalResult | BoolInterval | Interval | IntervalValue[]. Code that narrows an IntervalValue with a check on kind must first test for a string.

Behavior Changes​

  • Compiled JavaScript keeps an absent value whose type is not known through a point accessor and arithmetic (ROADMAP CE-229). With W = (p, s) ↦ (s·PointX(p), s·PointY(p), s·PointZ(p)) and L a list of two points, the interpreter gives (Missing, Missing, Missing) for W(L[5], 2), and compiled JavaScript gave [NaN, NaN, NaN]. The interpreter answers the marker of the codomain for an absent operand: NaN when the operation is provably a number, Missing otherwise. Compiled code now does the same: a coordinate accessor whose type is any or unknown gives undefined (the compiled spelling of Missing) for an absent point, and an element-wise Add, Multiply (and the other arithmetic) whose type is not provably a number keeps an undefined operand of type any or unknown. W(L[5], 2) compiles to [undefined, undefined, undefined]. Beside an operand that is a list at run time the absence lands in each cell, as in the interpreter: [1, 2] + PointX(p) with p absent is [NaN, NaN]. A numeric operation, such as 2·x with x absent, is still NaN.

  • A parameter declared broadcastable<T> maps to the cells when T cannot hold a list (ROADMAP CE-405, Tycho row 434). With f: (broadcastable<number>) -> number and f := x ↦ x + 1, f([[1, 2], [3, 4]]) is [[2, 3], [4, 5]], typed matrix<2x2>. It was a list of two incompatible-type errors, one for each row, while the call was typed matrix<2x2>: the declared parameter mapped exactly one level down and checked each row against number. Since 0.162.0 broadcastable<T> is T | tensor<T>, and an undeclared function and the compiled targets already map to the cells. This applies when T is a number, a boolean, a string or a tuple; a tuple is not mapped into, so a list of points maps one point per call. A parameter declared broadcastable<value> keeps the one-level rule, and so does a call that maps such a parameter. An element that is neither a T nor a collection is an incompatible-type error that names broadcastable<T>. Such a function now compiles to JavaScript through the element-wise call, with each cell checked against T, where it declined. A call that maps a broadcastable<value> parameter is typed with one list level around the result (list<value>; it was typed from the whole shape of the argument).

  • Every result of the MCP server says which engine computed it (ROADMAP CE-406). A server loads the engine when it starts and keeps it, so after an upgrade a running server answered with the old engine, and nothing in its results showed it. A JSON result now has engineVersion, and an error message ends with a line Engine version: …. When the package installed on disk has another version, the result also has an engineWarning (or a line in an error message) that says to restart the server.

  • The interval-js fallback answers a comparison, and does not read a wide input as its midpoint (ROADMAP CE-210). When the target cannot compile an expression (Greater(Fibonacci(x), 0): Fibonacci has no interval lowering), the result runs the interpreter at the midpoint of each interval input. A boolean value was { kind: 'entire' }; it is now the answer string, 'true' or 'false', as compiled code gives it. When an input is wider than a point, the value at its midpoint does not bound the values over it, and was returned as if it did: Fibonacci(x) over [1, 10] was { kind: 'empty' } (no value at 5.5), although it takes the values 1 to 55 there. Over such an input a boolean expression is now 'maybe' and a number is { kind: 'entire' }. At a point the answers are exact, as before.

  • A declared collection that shares only the empty collection with a collection parameter is refused when the expression is boxed (ROADMAP CE-402). With R: list<list<number>> or B: list<boolean>, Mean(R) and Mean(B) were valid and were refused only when evaluated, while Histogram(R, 2) was refused when boxed: the empty list is both a list<list<number>> and a collection<number>. A collection argument is now admitted at a collection parameter only when their element types can match, so Mean(R), Mean(B) and Mean([Missing, "a"]) are refused when they are boxed. A collection whose elements are not known yet (list, list<any>, set<unknown>, tensor) is still admitted.

Resolved Issues​

  • A compiled call with an argument that is one point or a list of points gave a wrong value (ROADMAP CE-407, Tycho row 435). With f: (tuple<number, number>) -> tuple<number, number>, f := a ↦ (a.x + 1, a.y + 1) and L: tuple<number, number> | list<tuple<number, number>>, the interpreter maps f(L) over the points when L holds a list, and gives one point when L holds a point. The call was typed tuple<number, number>, and compiled JavaScript called f once on the whole list: for [(1, 2), (3, 4)] it gave ["1,21", "3,41"] and reported success. The call is now typed list<tuple<number, number>> | tuple<number, number>, and compiled JavaScript tests the value at run time: it maps over a list of points and gives a point whole. The same applies to an argument declared broadcastable<tuple<number, number>>, which was typed and compiled as a list of points only, so compiled code read one point as a list; a matrix of points there throws at run time, as the interpreter gives an incompatible-type error. An absent argument of such a type (… | missing) gives Missing, as an absent list of points does; it was an incompatible-type error. A union that also admits another collection (… | list<list<tuple<…>>>, … | list), and a parameter with a coordinate that is not a number, a boolean or a string (tuple<list<number>, number>), are now declined by the compiler: a point and a list of points cannot be told apart at run time there.

  • A generic parameter declared broadcastable<T> with a number bound refused a matrix (ROADMAP CE-409). With w: (x: broadcastable<T>) -> list<T> where T: number and w := x ↦ [x], w([[1, 2], [3, 4]]) was the error incompatible-type when boxed, while the same declaration without a type variable, broadcastable<number>, maps to the cells (ROADMAP CE-405). The type solver read the matrix one level down and bound T to the rows. It now binds T to the cells when the bound cannot hold a list, also for a list with rows of different lengths: w([[1, 2], [3, 4]]) is [[[1], [2]], [[3], [4]]], typed list<list<list<integer>>>, and compiled code gives the same value. A type variable with no bound (where T) now maps each row whole, as where T: value does; each row was an incompatible-type error naming T.

  • A parameter named Return can be called in the body of Sum, Integrate and the other operators that take an expression (ROADMAP CE-401). Since 0.164.0 a Return statement in such a body is the error return-in-expression-body. The check also refused a call of a parameter of the enclosing function literal named Return: ["Function", ["Sum", ["Return"], ["Limits", "k", 1, 3]], ["Typed", "Return", "() -> real"]] was invalid. The call is now allowed, with or without a type on the parameter, and the function applied to () ↦ 2 gives 6. A Return that calls a bare parameter, of the function or of an enclosing function, also no longer adds nothing to the result type of the function: Return ↦ 1 + Return() is typed (unknown) -> broadcastable<number>, not (unknown) -> broadcastable<number> | nothing.

  • The result of a function whose parameter is declared broadcastable<T> was typed for a whole collection (ROADMAP CE-408, Tycho row 436). With l: (broadcastable<tuple<number, number>>) -> unknown and l := A ↦ √(A.x² + A.y²), the signature was refined to -> broadcastable<number>, so l(P) over a list of points was typed list<broadcastable<number>> and P / l(P) had a tensor arm. Each call of the body receives one T, so the result is now typed with the parameter read as T: -> number, and l(P) is list<number> | number.

  • The LaTeX serialization of Reduce and Fold keeps the initial value (ROADMAP CE-292). Reduce([1, 2], Add, 10) serialized as \sum of the list without the seed, so the LaTeX parsed back as an expression whose value is 3, not 13. A seeded reduction now writes its third operand, and the \sum/\prod shorthand is used only when there is no seed.

  • A symbol declared with the type error parses (ROADMAP CE-294). With w: error, w, w+1 and \int_0^1 w\,dw gave an unexpected-symbol error. They now parse as terms. A symbol of type never also parses now.

  • In lenient mode, ab√x..5 reports ambiguous-radical (ROADMAP CE-298), as ab√x and 5..ab√x already did. The line-level ambiguity checks now read the tree after the range-precedence repair.

  • Declare with a type string that does not parse returns an error (ROADMAP CE-101). ["Declare", "r", {str: "integer<<"}] threw when it was evaluated. It now gives Error("invalid-type-declaration", …) with the message of the type parser.

  • Hold around a Function with no body does not throw (ROADMAP CE-106). Reading the type of Hold(Function()), or evaluating it, threw from the signature of the function literal. The bodyless function literal is now typed error.

  • A ce.declare() of a value that is refused leaves no placeholder (ROADMAP CE-116, issue #394). When the value does not fit its type, a new name stays undeclared and a redeclaration keeps its previous binding, as the operator route already did.

  • More boolean identities fold beside an unknown (ROADMAP CE-119). Nand(True, A), Nor(False, A), Nand(A, A) and Nor(A, A) evaluate to ¬A, Implies(True, A) to A, Implies(A, False) to ¬A, and Implies(A, A) and Equivalent(A, A) to True. An absent operand gives the same results as before.

  • isEqual() of a symbol reads the bounds that pin it to a value (ROADMAP CE-104). With b ≥ 0 and b ≤ 0 assumed, b.isEqual(0) is true; it was undefined, and ce.number(0).isEqual(b) agrees. The Equal operator does not read bounds.

  • Limit of a function whose body is Return(v) (ROADMAP CE-400) gives the limit of v. It gave a Return expression as its value. A Return inside a statement block or a branch leaves the limit unevaluated.

  • A divergent improper integral with a symbolic exponent stays unevaluated (ROADMAP CE-383). ∫₀^∞ xⁿ dx gave a value under the condition 0 < n ∧ n < 0, which no n satisfies.

  • The limit at infinity of a ratio of logarithms (ROADMAP CE-147). ln(3x)/ln(x) and ln(3x)/ln(x^π) give 1 and 1/π; they stayed unevaluated. A ratio that is not an indeterminate form, such as (2 + sin x)/(3 + sin x), still has no limit.

  • Determinant and CharacteristicPolynomial of a matrix with float entries fold their constants (ROADMAP CE-145). Determinant([[x + 1.5, 1], [1, 2]]) is 2x + 2; it was 2x - 1 + 3.

  • A telescoping sum with an infinite bound and a parameter (ROADMAP CE-142). \sum_{k=1}^\infty ((x+k+1)^2-(x+k)^2) is +oo; it was (x + 1 + oo)^2 - (x + 1)^2. The product \prod_{k=1}^\infty (x+k+1)/(x+k), whose sign depends on x, stays unevaluated. A sum whose term at the finite bound is a pole stays unevaluated.

  • Reducers over a Range held by a symbol use the closed form (ROADMAP CE-80). With r := Range(1, 10^20), Sum(r), Mean(r), Median(r), Variance(r), Max(r) and Min(r) return at once; they walked every element.

  • Dot of two empty lists (ROADMAP CE-220) is 0 for two numeric vectors and [] for two lists of points, as the compiled code gives. It stayed unevaluated.

  • Compiled Max, Min and the ordering comparisons decline an operand of type color, regexp or object (ROADMAP CE-393). The compiled code read such a value as a number, while the interpreter leaves the call unevaluated. With a fallback, the interpreter now runs. A custom compile handler of the operator still takes precedence.

  • The WGSL Mod keeps the sign of the divisor when the quotient underflows (ROADMAP CE-246). floor(r / b) gave -0 for a tiny remainder of the opposite sign, so the divisor was not added.

  • NLimit of a function that rises before it decays (ROADMAP CE-184). NLimit(t ↦ 7t/(t² + 9), ∞) is about 0 (−2e-22); it was NaN. The numeric limit retries from a start closer to the target when the first extrapolation is rejected.

  • narrow() of two collection types of different kinds that share values (ROADMAP CE-12). matrix<number> and collection<vector<integer>> narrow to matrix<integer>; the result was never. A pair that cannot be reduced is kept as an intersection.

  • The empty tuple type prints as tuple<> (ROADMAP CE-23), which parses back to the empty tuple. It printed as the bare tuple, which means a tuple of any length.

  • Abs of a list or a matrix of numbers is typed from its cells (ROADMAP CE-18). Abs([1, 2]) is typed list<integer<0..>^2>; the type had a signed_infinity arm that the value cannot take.

  • Epsil: the hint of a type error in an annotation is shown (ROADMAP CE-307). let v: vector<integer^(2x3)> now says to write matrix<…> for two dimensions.

  • Epsil: an error in the body of a match case is underlined on that case (ROADMAP CE-308), not on the whole statement. The error frame names the case (in Match argument 3).

  • Epsil: a loop over a range whose bound is an unassigned local reports the error (ROADMAP CE-309). In a function, for k in 1..t { t = 2 } gives read-before-declaration, as compiled code does; it ran zero times. A lambda in a pipe stage that assigns a new name, (1..3) |> map((k) => do { t = k * 2; t }), no longer needs the scope effect.

  • Faster type checks and set operations (ROADMAP CE-315, CE-316). isSubtype on a union whose arms are the same type node took exponential time in the nesting depth. Duplicate removal in a set comprehension, in Intersection and in SymmetricDifference compares an element only with the elements of the same hash: 3,604 comparisons in place of 812,704 for a comprehension over Range(1, 900).

  • The structural hash of a deeply nested expression does not repeat (ROADMAP CE-318). The hash of a function expression, and of a dictionary, repeated every 32 or 64 levels of nesting, so different deep expressions fell into one hash bucket. Hash values of all function expressions change.

  • Compiled code: seven more corrections (ROADMAP CE-224, CE-226, CE-230, CE-243, CE-251, CE-262, CE-282).

    • GLSL and WGSL decline a point component typed indexed_collection or collection, as they decline a list; such a value was put in a vecN.
    • A comprehension whose term assigns its own index declines to compile, and the interpreter runs it. The compiled code threw "Assignment to constant variable".
    • A branch of If or Which behind a condition that is constant by its type no longer moves the compiled result to the complex lane: x + Which(1 > 0, y, True, √(y − 1)) compiles to real code.
    • The GLSL and WGSL integer-power helpers of a vector give +∞, not NaN, for a zero component under a negative odd exponent, as the scalar helper does.
    • A one-operand Add, Multiply, And or Or, and Power(x, 1), from non-canonical input compile with correct grouping on JavaScript and the GPU targets. 3·(x+1)^1 compiled as 3·x + 1.
    • Compiled JavaScript \arg(x − iy) is π at y = 0 and a negative x, as the interpreter gives; it was −π.
    • compileShader on GLSL and WGSL converts the arguments and results of trigonometric functions in degree, grad and turn modes, as compile does.
  • Evaluation: six corrections (ROADMAP CE-99, CE-105, CE-108, CE-115, CE-121, CE-129).

    • A dictionary that holds an Error value reports isValid === false, as a list and a tuple do, and has() of an object reads its field values.
    • A symbol that holds an error gives that error when it is called: w(1), Apply(w, 1) and Pipe(3, w) stayed unevaluated, while Map(w, …) gave the error.
    • An error passed to a function declared with a typed signature carries the frame of that call.
    • Sum and Product with several Limits clauses, where a bound depends on another index, evaluate in either clause order: Sum(k, Limits(k, 1, n), Limits(n, 1, 10)) is 220.
    • The .mul() method no longer stores the current value of a variable in a point or a list: with w := 4, 2·(w, 2) keeps (2w, 4) and follows a later assignment of w.
    • A callback written InverseFunction(f) or Derivative(f, n) is accepted by the lazy collection operators: Map(InverseFunction(Sin), xs).
  • Numeric evaluation: more values and better accuracy (ROADMAP CE-180, CE-185, CE-189, CE-192, CE-197).

    • .N() of a divergent integral next to a singular bound, ∫₀¹ t⁻³⁰⁰ dt, is +oo; it was a Measurement of +oo with a NaN error.
    • A Measurement whose error is below 1e-100 prints in exponential notation; it printed the text of a RangeError.
    • Gamma and Factorial at an argument whose value is past the exponent range stay unevaluated, and no longer give 0 to internal callers.
    • Arcoth, Arcosh and the other inverse functions with a branch point at ±1 read an exact sum next to ±1 (1 + 10⁻¹⁰) exactly at machine precision: Arcoth(1 + 10⁻¹⁰) is 11.8594990552752; it was 11.8594990139, the value at the double.
    • The complex Gamma is about 100 times more accurate for an argument with Re z above 2 (relative error 1.6e-15 on the real axis, it was 2e-13), so Hypergeometric2F1 with a complex z is more accurate too.
    • PolyGamma(m, z) of an order from 100 to 400 close to a half-integer on the real axis has a value: PolyGamma(120, -1/2 + 10⁻⁵i).
    • GammaRegularized(n, z) for an integer n up to 200 and a negative z has a value under .N().
  • More precise static types (ROADMAP CE-17, CE-44).

    • The meet of a fixed-length list with a plain list of lists is never (the empty list for an open length). It was a type such as list<list^3>, which the type parser refuses.
    • An element-wise comparison over a list that can be absent as a whole, L < 3 with L: list<integer> | missing, is typed list<boolean> | missing; it was list<boolean | missing>.
  • Symbolic computation: nine corrections and capabilities (ROADMAP CE-137, CE-138, CE-141, CE-152, CE-154, CE-155, CE-156, CE-157, CE-158).

    • Solve finds the roots of equations with sines and cosines of multiples of the unknown: sin x + sin 2x = 0, cos 2x + cos x = 0, sin 2x = cos x.
    • D and Integrate over a parameter of a user function use the argument of the call: with g(f) := \frac{d}{dx} f, g(x^2) is 2x; it was 0. This also holds when a global symbol has the name of the parameter, and for an argument that holds it: with g(f) := \int f\,dx, g(f + x) is f·x + x²/2.
    • The definite integral of sin(x − Round(x))/(x − Round(x)) is no longer evaluated through an antiderivative that ignores the jumps of Round; it gave 0. .N() gives 63.1177… on [0, 64].
    • A definite integral of |u| or sgn(u) with a quadratic u is split at the real roots of u: \int_{-1}^{1} |x^2 - 1/4|\,dx is 1/2.
    • TrigReduce reduces each element of a list and each side of an equation (it rewrote them in exponential form), and TrigReduce(Missing) stays Missing.
    • Sum and Product over Element(j, Range(1, k)) read the value of k, as Limits does.
    • simplify() rationalizes a quotient by a constant inside a sum or a product: 1/(2−√2) − 1/(2+√2) is √2.
    • simplify() reads a negative imaginary literal as a negation, so sin(−i) + sin(i) is 0, and simplifies the numerator of a quotient as a term of a sum: (Si(1) − Si(−1))/y is 2Si(1)/y.

0.164.0 2026-10-23​

Behavior Changes​

  • The structural form of ce.parse() leaves out a spacing command between the operands of a juxtaposition (ROADMAP CE-403, Tycho row 431). ce.parse("\\,d=2", { form: "structural" }) was ["Equal", ["InvisibleOperator", ["HorizontalSpacing", 3], "d"], 2], a product of a space and d on the left side, while d=\,2 was ["Equal", "d", 2]: the shape depended on where the space was. It is now ["Equal", "d", 2], as d\,=2, \quad d=2 and x\,+\,y (["Add", "x", "y"]) are. The canonical form already left the spacing out. The raw form keeps it, and so does the structural property of a raw expression. A space still separates two tokens: x\,.75e2 is x times 75.

  • Mean and the other statistics refuse a matrix when the expression is boxed (ROADMAP CE-13). Mean([[1, 2], [3, 4]]) was a valid expression that failed only when it was evaluated. The data parameter of Mean, Median, Mode, Variance, PopulationVariance, StandardDeviation, PopulationStandardDeviation, Kurtosis, Skewness, Quartiles, InterquartileRange, Histogram, BinCounts, Covariance, PopulationCovariance and Correlation is now a collection of numbers, collection<nothing | number> (collection<nothing | number | tuple> for the first operand of the bivariate statistics, which also take a collection of (x, y) pairs), in place of collection<any>. A matrix, a symbol declared matrix, a ragged list, a collection of booleans, strings or residue classes, and a dictionary are now invalid when they are boxed, with an incompatible-type error that names the data parameter. Vectors, sets, ranges, tuples, data with Missing or Nothing cells, measurements and symbolic data are accepted as before. The validation now reads the rank of a collection<T> parameter whose elements cannot be lists as 1, so any operator with such a parameter refuses an argument of rank 2 or more. Related changes:

    • A symbol with no value used as statistics data (Mean(q)) is inferred collection<nothing | number> | distribution | number.
    • In Histogram(P + 0.5, 1/100) with P of unknown type, P is inferred list<number> (it was matrix).
    • The inferred parameter of a user function xs ↦ Mean(xs) is collection<nothing | number> | value.
  • A Return in an operand of Sum, Product, Integrate, D, ND, NIntegrate, Limit or NLimit is an error (ROADMAP CE-96). These operands (the body, a bound, a variable) are expressions, computed once per index value or integration point, not statement blocks of the enclosing function, so a Return there has no function to end. It stayed in the value: Sum(1 + Return("q"), (k, 1, 2)) was 2 + 2Return("q"), and D(Return(x^2), x) was 2x·Apply(Derivative("Return", 1), x^2). The expression is now invalid, with the error return-in-expression-body naming the operator. A Return inside a function literal in the body ends that function, as before, and a Return inside Hold is data.

  • x/0 waits for the value of x (ROADMAP CE-95, Tycho row 413). With x: real and no value, x/0 was canonicalized to ComplexInfinity, so \{x/0 > 0: 1, 0\} compiled to (0 < Infinity) ? 1 : 0 and gave 1 at every x, including x = 0, where the value is 0/0. A quotient by the number 0 now folds to ComplexInfinity only when the numerator is provably not zero: a number other than 0 (5/0, π/0, i/0), or an expression whose sign is known from its type and the assumptions ((x^2+1)/0, e^x/0). Otherwise the quotient stays x/0 through evaluate(), .N() and simplify(), and the value decides: Indeterminate at x = 0, ComplexInfinity elsewhere. A numerator that reads a variable with an assigned value is not folded when it is boxed, so the answer follows a later assignment. The compiled branch above now gives 0 at x = 0. A limit does not take such a quotient as its value: lim_{x→a⁺} x/(x−a) with no assumption on a stays unevaluated.

  • A vector plus a matrix adds the vector to the rows (ROADMAP CE-399). A sum of tensors whose shapes nest (the shape of each one is the start of the shape of the deepest) adds element i of the vector to row i of the matrix: [1, 2] + [[1, 2, 3], [4, 5, 6]] is [[2, 3, 4], [6, 7, 8]], typed matrix<integer^(2x3)>. It was Error("incompatible-dimensions", "2 vs 2x3") for two list literals, while the same sum of symbols that held these values gave the matrix, as compiled JavaScript and Less did. The value is now the same on every route; a compiled Sum over a list that holds a matrix and a row gives it too, where it threw. Shapes that do not nest ([1, 2, 3] + [[1, 2], [3, 4]], a 2 by 2 plus a 2 by 3 matrix) are still an incompatible-dimensions error. A product is not changed: a vector times a matrix is the matrix product.

New Features​

  • couldMatch(other, { nonEmpty: true }) (ROADMAP CE-397, Tycho row 429). couldMatch() answers true for two list types with no dimensions whatever their elements, because the empty list is a value of both. With the option, it answers whether a value that is not an empty collection has both types, so it tells a list of numbers from a list of points: ce.type('list<number>').couldMatch('list<tuple<number, number>>', { nonEmpty: true }) is false, and couldMatch('list<integer>', { nonEmpty: true }) is true. Only the outer collection must be non-empty: [[]] is a value of both list<list<number>> and list<list<string>>. The couldMatch documentation no longer says that list<integer> could not be a list<string>.

Resolved Issues​

  • FindFit with global: true searches an even function of a periodic term on a fine enough grid (ROADMAP CE-176, Tycho row 407). The grid spaces a searched frequency by the period of the expression around the periodic function. An even outer function (Cos, Sec, Cosh, Sech) of a term that changes sign under a half-period shift has half the period of the term: cos(cos θ) and sec(sin θ) have the period π, cos(sin θ cos θ) has π/2. The grid recorded 2π (or π), so it was twice as coarse as the basins of the residual. cos(sin θ + 1), cos(sin² θ) and sin(cos θ) keep their period.

  • The type of an element-wise operation over a list of tensors or a broadcastable operand (ROADMAP CE-398, Tycho row 430). Three kinds of static type did not hold the value, or said less than they could:

    • A list whose elements are each a number or a tensor (list<broadcastable<number>>, list<tensor<number>>) is not one tensor, because two elements can have different ranks ([1, [2, 3], [[4]]]). An element-wise operation over it is now typed list<broadcastable<…>>: with U: vector<real^3> and M: list<broadcastable<number>>, U + M is list<broadcastable<number>>. It was list<broadcastable<number> | list<any>>, an arm with no layout on the compiled routes. Sin(T) with T: list<tensor<number>> was typed tensor, which [[sin 1, sin 2], [[sin 3]]] is not.
    • An operand declared broadcastable<number> may hold a matrix. Beside a vector, the result was typed as if the operand was a number: with B: broadcastable<number> holding a 3 by 2 matrix, U + B, U^B and U < B evaluate to 3 by 2 matrices, but were typed vector<3> and list<boolean^3>. They are now typed tensor and tensor<boolean>. A union with a broadcastable arm (broadcastable<real> | integer) is read the same way.
    • U·B was typed broadcastable<real>, the cells of U: with B = i it is [i, 2i, 3i]. A product with a broadcastable<E> factor beside a collection now takes the tier of E (tensor here). A product of a list literal and such a factor ([1, 2]·b) is broadcastable<number>, not vector<2>: when b holds a matrix the product is a matrix product ([1, 2]·[[1, 2, 3], [4, 5, 6]] is [9, 12, 15]).

    A comparison of an element-wise application over an abstract collection with a list (Sin(P) < [1, 2] with P: collection<number>) is now typed tensor<boolean>, not list<boolean^2>: Sin(P) is typed broadcastable<number>, which admits a matrix.

0.163.0 2026-10-23​

Breaking Changes​

  • Residue classes have a type of their own, residue (ROADMAP CE-136, #471). A residue class, an element of ℤ/nℤ, had the type value, and the engine kept the number rules (c/c is 1, c - c is 0) away from it by looking for an operator named ResidueClass. A head with another name got no protection: with MyMod declared (any, any) -> value, MyMod(2, 4)/MyMod(2, 4) simplified to 1, although 2 has no inverse modulo 4. The type now does what the name did:

    • ResidueClass(k, n) is typed residue, arithmetic over classes too (c · [1, 2] is a list<residue^2>, NaN + c is nan | residue), and ℤ/nℤ is a set<residue>. residue is a value, not a number: it is disjoint from every numeric type, residue | integer is a union, and a symbol declared residue refuses an integer (ce.assign('r', 2) throws; an integer is a representative of a class, not the class).
    • The arithmetic operators that have residue arithmetic (Add, Subtract, Multiply, Divide, Negate, Power, Square, Sqrt, Root) name residue in their parameter types. Every other operator refuses a class at boxing with an incompatible-type error: Sin(c), Floor(c), Mod(c, 5) were admitted and stayed unevaluated. The incompatible-type error of these nine operators names the new parameter type: Sqrt("banana") reports "expected complex | infinity | residue", where it reported complex | infinity. Max(c, x) and Min(c, x) refuse a class too, as an operand, an element of a list or an element of ℤ/nℤ: classes have no order. The call is typed error when it is boxed and evaluates to an incompatible-type error (Max(c, x) was typed number and stayed unevaluated).
    • The protection reads the type: an operator declared to return residue (ce.declare('MyMod', { signature: '(any, any) -> residue' })), a function whose inferred signature returns it (g := k ↦ ResidueClass(k, 4) is (unknown) -> residue), and a symbol declared residue, which is protected before it has a value (r/r stays r/r). A head with another name gets the admission and the protection, not the arithmetic of ResidueClass: a sum or a product of its values stays unevaluated (a hook for the head to supply that arithmetic is planned, ROADMAP CE-507). A symbol typed value or left undeclared is still read as a possible number: w/w is 1 for it.
    • A juxtaposed class is a product: \overline{3}_{7}x parses to Multiply, not to a tuple. compile() declines an expression typed residue or one that holds a class; an expression that only may hold one (a parameter typed number | residue) compiles. The documentation lists the type in doc/08-guide-types.md, the rules in docs/TYPE-SYSTEM.md, and how an operator declares it in doc/06-guide-augmenting.md.
  • A comparison with an absent number is absent (ROADMAP CE-93, Tycho row 411). With w: number | missing holding Missing, w > 0 was False: a numeric slot read its absent value as NaN. But If(w > 0, 1, 0) had no value (the absent-condition error), so the two forms disagreed. A comparison (<, ≤, >, ≥, =, ≠, chained or not) with an absent operand is now Missing, in a numeric slot too, and it is typed boolean | missing. The broadcast form agrees: [1, 2] < w is [Missing, Missing]. A NaN is a value, and a comparison with it keeps the IEEE answer; arithmetic still reads an absent number as NaN (w + 1 < 2 is False). Compiled JavaScript and Python give the absent value (undefined, None) when such an operand is passed absent, where JavaScript gave false and Python raised a TypeError; a chain is read link by link, so 5 < 0 < w is false. On GLSL and WGSL an absent number can only be NaN, and these comparisons compile as before, with the IEEE answer. A chained comparison with an operand that has an effect (0 < w < Random()) and an operand that may be absent no longer compiles; with fallback (the default) compile() runs the interpreter. One case compiled numeric code cannot tell apart: a local variable that holds the NaN of an If with no else branch is read as a value. Rule: docs/ERROR-MODEL.md.

New Features​

  • Factoring over a number field (contributed by KingArth0r, #469). Factor(polynomial, variable, generator) factors over the algebraic extension of the rationals that generator selects. The generator can be an exact radical (√2, ∛2), ImaginaryUnit, or the new AlgebraicGenerator(List(c₀, c₁, …)): a root of the irreducible polynomial with these rational coefficients in ascending order, with no numerical embedding chosen. Factor(x^2 - 2, x, √2) is (x - √2)(x + √2); over the rationals, x^2 - 2 stays irreducible. The result is exact, also under .N(). A request that the engine cannot answer within its limits (a coefficient it cannot read, an unsupported generator, the operation budget) stays unevaluated; it is never factored over the rationals instead, and an unevaluated result is not a proof of irreducibility. A power of a generator at or above the degree of its polynomial is reduced, as (√2)^2 is 2: for α a root of t² - 2, α^2 is 2 and Expand((x - α)(x + α)) is x^2 - 2. The one- and two-argument forms of Factor are unchanged. Examples and limits: number-field factoring.

Performance​

  • Solve of a polynomial whose roots have closed forms is about 4 times faster (ROADMAP CE-385). x^4 + x^2 - 1 = 0 takes 1.0 ms, where it took 4.1 ms in 0.162.0 and 1.9 ms in 0.133.0. The roots that the closed-form formulas give for the equation itself (a quadratic, a pure power a·xⁿ + b, or a polynomial in xᵍ that reduces to one of these) are exact by construction, so they are no longer substituted back into the equation, and repeated roots among them are found by comparing the expressions. Before, each root was substituted and the residual, an exact constant such as (-1/2 + √5/2)^2 + (-1/2 + √5/2) - 1, was proved zero by refining it at increasing precision. The roots of every other route (squaring, a change of variable, the rational-root search, the numeric root finder) are still checked.
  • Symbolic operations are 10% to 30% faster (ROADMAP CE-385). Compared with 0.162.0: D(x^3, x) and Expand((x + 1)^3) take about 0.7 of the time, evaluate() and simplify() of small expressions about 0.8, and antiderivatives and definite integrals 0.7 to 0.8. The type of a new expression is derived with less work when its operands are numbers: the check for an element-wise application over a list is skipped, and fewer objects are created for each operand. A product with no sum, no Negate, no Divide and no matrix among its factors is built once instead of twice. An indefinite integral no longer prepares the residue method, which applies only to some definite integrals, and the check of a function type is done once per type. The results are the same.

Behavior Changes​

  • A branch on NaN ≠ x takes its first arm (ROADMAP CE-125). NaN ≠ 2 is True, but If(NaN ≠ 2, 1, 0) and, with b holding NaN, Which(b ≠ 2, 1, True, 0) took no arm: Missing in the interpreter, NaN in compiled JavaScript and Python. A statement-form If ran neither branch. The element-wise form gave the IEEE answer, so Which([1, b, 3] ≠ 2, 1, True, 0) was [1, 1, 1] and its second element did not match the scalar form. A branch now uses the value True of a ≠ with a NaN operand, as it already used the value False of <, ≤, >, ≥ and =: the examples give 1 on the interpreter, JavaScript and Python. An absent operand (Missing, an input the caller leaves out) still leaves the branch without an arm. Rule: docs/ERROR-MODEL.md.

  • A chained comparison with a NaN pair is False (ROADMAP CE-94, Tycho row 413). 0 < NaN < Missing was Missing, while 0 < i < Missing was False: a pair with no order stopped the chain, but a NaN pair did not, and the absent operand won. A pair with a NaN operand now makes the chain False, wherever the absent operand sits (Missing < 0 < NaN is False too), on the interpreter and in compiled JavaScript and Python, which gave undefined/None. A chain whose every pair has the absent operand stays absent: NaN < Missing < 0 is Missing. Undefined is read as absent in a chain as Missing is: NaN < Undefined < 0 and i < Undefined < 0 are Missing, where they were False. Also fixed: compiled code decides a branch over a chain link by link, as the interpreter does, so with x = 1 and w (number | missing) absent, If(x < 0 < w, 1, 2) gives 2, where it gave NaN. Rule: docs/ERROR-MODEL.md.

  • .N() of an integral whose integrand has no value is NaN (ROADMAP CE-177, Tycho row 411). Integrate(Which(t > 5, 1), Limits(t, 0, 1)).N() stayed unevaluated: no sample of the integrand was a number, and the integral was kept as if the integrand held an unknown symbol. NIntegrate and the compiled routes gave NaN. When every sample is the undefined value (NaN, a selection with no selected arm, arithmetic with Missing), the integral is now NaN under .N(), also for a double integral; ∫₀¹ t·Missing dt is NaN too. An integrand that still holds a free symbol (∫₀¹ t·D dt, an undeclared g(t)) stays symbolic.

Resolved Issues​

  • A product over a list of elements of unknown type, or over rows, was typed unknown (ROADMAP CE-374). Product(L) of an operand that is not a list literal becomes Reduce(L, Multiply, 1) at canonicalization, and the type of a Reduce is the reducer's own result type, which is unknown for Multiply over an element of unknown type: with L: list<unknown>, Sum(Rest(L)) was a broadcastable<number> and Product(Rest(L)) was unknown; with V: list<vector<2>>, Product(Rest(V)) was unknown too. A seeded Add or Multiply fold with the identity as its seed is now typed as Sum or Product type the collection when the elements are of unknown type or are numbers or rows: Product(Rest(L)) is a broadcastable<number> and Product(Rest(V)) is integer | vector<2> (the seed for an empty list, or a row). A tuple arm keeps the reducer's typing, and Scan keeps its running values.

  • Compiled Equal and NotEqual read a NaN operand as absent and answered Missing where the interpreter answers False or True (ROADMAP CE-386). With g := t ↦ [t, 2t] and x: any, the element g(x)[5] at x = 3 is NaN (an index past the end of a list of numbers), and the interpreter answers False for g(x)[5] = 1 and True for g(x)[5] ≠ 1. The compiled equality over an operand that may hold an object-domain absence (here typed missing | number | vector<2>) carried a guard that read NaN as absent, and answered Missing. The guard now tests the object absence only (undefined): a NaN operand compares as IEEE says on both routes, and g(x)[5] = 1 at x = "a", where the element is the object absence, still answers Missing on both. IsMissing and Coalesce keep reading NaN as absent. The guard is also emitted for an element read or a call typed any or unknown, which admits the absent value without a missing arm (a declared symbol of such a type stays the scalar the host passes): with g: (unknown) -> list, the element g(x)[1] is typed any, and g(x)[1] = 1 at x = [] answered NaN where the interpreter answers Missing. Each operand of the guarded comparison that is a call or an element read, or that is impure, is now evaluated once: Equal(g(x)[1], Random()) drew twice, in the absence test and in the comparison.

  • .N() of an integral that diverges at an infinite bound gave a random value or NaN (ROADMAP CE-391). evaluate() gives +∞ for ∫₀^∞ eˣ dx, but .N() gave a Monte-Carlo estimate that changed at each run (5007912 ± 2900000), and NaN for ∫₀^∞ x dx. When the integrand has one sign on the interval and does not decay faster than 1/x (or overflows) on the infinite tail, .N(), NIntegrate and a compiled integral now give +∞ or −∞. When the sign changes, .N() and NIntegrate take the answer of the exact route, as compiled code already did with the closed form: ∫₀^∞ (x − 1) dx is +∞ and ∫₀^∞ (eˣ − 5) dx is +∞ (it was a Monte-Carlo value). An integral with no value (∫₀^∞ sin x dx, ∫_{−∞}^∞ x dx) still gives NaN.

  • .N() of ∫₀^∞ x¹⁰⁰·e⁻ˣ dx was wrong by 45 orders of magnitude (ROADMAP CE-394). It gave 1.9·10¹¹² ± 1.8·10¹¹²; the value is 100! ≈ 9.3·10¹⁵⁷. In double precision x¹⁰⁰ is ∞ and e⁻ˣ is 0 past x ≈ 1200, so the integrand was NaN on the tail, the quadrature did not converge, and Monte Carlo missed the peak. Where a product of powers and exponentials is not finite, it is now computed again as exp(100·ln x − x), on .N(), NIntegrate and compiled code: the result is 100! to 10 digits. An integral too large for the change of variable of the quadrature (170!) is NaN, not a random value.

  • A definite integral over a half-infinite interval with a pole at its finite bound was ComplexInfinity (ROADMAP CE-391). ∫₀^∞ (x − 1)/x³ dx and ∫₀^∞ −1/x³ dx gave ComplexInfinity, which is not the value of a real integral. They are now −∞: the sign of the integrand next to the pole decides, and the antiderivative at the infinite bound tells whether the tail adds a divergence of its own. When it adds one of the other sign (∫₀^∞ (x − 1)/x² dx), the integral has no value and is Indeterminate.

  • A function that receives itself as an argument and recurses through a lazy Map stayed unevaluated (a regression in 0.160.0, #470, ROADMAP CE-387). With G = (self, t) ↦ If(Length(t) = 0, 0, Max(Map(c ↦ self(self, c), t)) + 1), the height of a tree given as nested lists, Apply(G, G, [[[], []]]) gave the Apply(…) back, where 0.159.0 gave 2; Reduce and Sum in place of Max failed the same way. The body scope of a function literal is shared by its applications, and an application hides its parameters there, so that the body reads the values of its call frame. A nested application of the same literal put the parameters back when it ended, and a canonicalization of the literal during an application declared them again, while an enclosing application still read its parameters through that scope: the next element of the lazy Map then read self with no value. The parameters now come back when the last application ends, and a canonicalization during an application uses the hidden bindings.

  • The signature of a function literal read a captured parameter as a number cell in a nested literal (ROADMAP CE-379). With g := x ↦ (() ↦ [x]), the signature was (unknown) -> () -> vector<1>: the nested literal has a type of its own, and its own typing read the captured x as a number cell, while x ↦ [x] is (unknown) -> list<unknown> since 0.162.0. The value ["a"] of g("a")() does not fit a vector<1>. The derivation of a signature now types a nested literal again with the parameters of the enclosing literals added to its own: g is (unknown) -> () -> list<unknown>, x ↦ (() ↦ [[x]]) is (unknown) -> () -> list<list<unknown>>, and a parameter of the nested literal that shadows the captured name is read as its own (x ↦ ((x: real) ↦ [x]) keeps (real) -> vector<real^1>). A use that narrows the parameter keeps the number cell (x ↦ (() ↦ [x + 1]) is (unknown) -> () -> vector<1>). An ascribed result of the nested literal is kept, and so are the arms of an early Return of its body; a call of the nested literal inside the body is typed from the derived result.

  • Solve of a polynomial with large terms gave a root that is not a root, or lost a root. The rational-root search tested each candidate by the value of the polynomial at 21 digits, and a candidate was taken for a root when the terms were large: x³ − 10¹²x² + 10¹² = 0 gave the root 10¹², whose value is 10¹², and the true root next to it was then removed as its duplicate, so the list held 2 of the 3 roots; x⁹ − 1000x⁸ + 1000 = 0 held 8 of its 9 roots. A candidate is now an exact rational, tested with exact arithmetic. A root found by the numeric root finder whose value is an integer is now a float (1000000000000.0), not an exact integer, so the exact check no longer rejects the root that it approximates. A rational root of a polynomial of degree 3 or more is now exact: 2x³ − 3x² − 3x + 2 = 0 gives −1, 1/2 and 2 (it gave 0.5).

  • Compiled code declined a call of a user function whose argument is an untyped free input (ROADMAP CE-377). With g := t ↦ [t, 2] and x free, the signature of g is (unknown) -> list<unknown>, so the engine types g(x)[1] as unknown, and the complex lane of the JavaScript target declined g(x)[1] + g(x)[2] ("scalar arithmetic over a list-valued operand"); Trace(m(x)) + 1 with m := t ↦ [[t, 1], [2, 3]] declined the same way, and Sum(1 + v(x)[2], k from 1 to 3) with v := s ↦ [s, k] and k = i declined in every mode. The emitted code reads an untyped free input as one scalar (x + 1 is _.x + 1), so the compiler now types an operand with each such input read as a number, with the engine's own derivation: g(x) is a vector<2>, g(x)[1] is a number, and the operand is a scalar. The three expressions compile in every mode, with the interpreter's values; a call keeps its run-time broadcast, so an input that holds a list at run time still maps the call over it. The shader targets read the static length of such a call's result the same way: v(x)[2] + 1 with v := s ↦ [s, 2s] compiles to (_fn_v(x)).y + 1.0 on GLSL and WGSL, where it declined ("the base (type list<unknown>) has no statically known length"); a list with a complex element still has no shader value, and the decline now names that element. The decline of an operand that only may be a list at run time, h(x) + 1 with h: (unknown) -> unknown and no body, names the operand and its type ("a possibly list-valued operand (h(x), of type unknown)"), where it said "a list-valued operand".

  • Compiled JavaScript gave NaN or false for an absent element where the interpreter gives Missing (ROADMAP CE-368). With g := t ↦ [t, 2t] and x: any, the element g(x)[5] is absent at x = [1, 2, 3] (the index is past the end) and at x = [], and the interpreter answers Missing for g(x)[5] + 1, Sin(g(x)[5]), -g(x)[5], g(x)[5]·g(x)[5] and g(x)[5] < 1. The compiled code spelled the absent element undefined and applied the scalar function to it: NaN, or false for the comparison. The run-time broadcast (_SYS.bcast) and the checked product (_SYS.mulChecked) now answer undefined when an operand is absent beside scalars only, and a compiled comparison answers undefined for an absent cell. Beside a present list the absent operand is still NaN in each cell (g(x)[5] + [1, 2] is [NaN, NaN] on both routes, and so is g(x)[5] + g(x)[1] at x = [1, 2]), and a comparison with a list answers an absent cell for each (g(x)[5] < [1, 2] is [Missing, Missing]). The compiled Sum of a list with an absent element is unchanged (NaN, a numeric slot, as in the interpreter). The ten tests of tycho-row-425-index-of-broadcastable-call.test.ts that recorded the divergence are agreement checks again.

  • A surplus tuple operand of Numerator, Denominator or NumeratorDenominator gave a tuple of errors (#467, contributed by enumeratio). A call with an operand past the declared arity is one unexpected-argument error when the surplus operand is a tuple, as it is for a list: Numerator(3/2, (1, 2)) was (Error, Error), a tuple of two errors, and is now the one error. The type of the call is no longer lifted over a surplus list or tuple (it was list<…> or tuple<…>): it keeps the declared result type of the operator, nothing | number for Numerator and Denominator and nothing | tuple<number, number> for NumeratorDenominator.

  • simplify() gave False for a relation whose two sides are the same function call. f(a) = f(a), sin(a) = sin(a) and g(x) + 1 = g(x) + 1 simplified to False, and f(a) ≠ f(a) to True; evaluate() gave the correct values. The simplifier rewrote the relation as f(a) - f(a) = 0, that is 0 = 0, and the factoring step then divided both sides by their common coefficient, 0, which gave NaN = NaN. Such a relation now stays as it is, as x = x does, and Factor(0 = 0) is 0 = 0 (it was NaN = NaN). The same error made Equal(c, c) False for a value c of an extension head (#471).

  • Compiled folds over elements of unknown type, absent elements, and booleans (ROADMAP CE-380, CE-381, CE-382). Three kinds of compiled JavaScript folds (Sum, Product, Reduce, Scan, and Max, Mean and the other statistics) disagreed with the interpreter:

    • With L: list<unknown>, Sum(L) + 1 and Product(L) · 2 did not compile, although L usually holds real numbers. They now compile, and a complex entry of the fold is added as complex: over [[1, 2], [i, 3]], Sum(L) + 1 is [2 + i, 6] and Reduce(L, Add, 0) + 1 is [2 + i, 6]; the second gave ["[object Object]1", 6]. In the complex mode, the same parents over list<list<number>> compile too, and a bare list is read as a list whose cells may be complex, as list<unknown> is: L + 1 over [i, 2] gave ["[object Object]1", 3]. In the default mode, a complex entry in a list input that the code reads as real is still the host's responsibility.
    • JavaScript reads null as 0 in +, * and Math.min. With L: list<number | missing> and [2, null, 3], Scan(L, Multiply, 1) gave [2, 0, 0], Reduce(L, Add, 0) gave 5 and Reduce(L, Min) gave 0; Reduce(L, Multiply, ImaginaryUnit) threw. Each is now NaN from the absent element on, as in the interpreter ([2, NaN, NaN], NaN). The Product and Sum of rows with an absent entry ([[2, null], [3, 4]]) are [6, NaN] and [5, NaN] (they were [6, 0] and [5, 4]), and a matrix product reads an absent cell as NaN.
    • The Sum and Product of a list of booleans or strings (list<boolean>, list<list<string>>), the Reduce and Scan of a list of booleans with Add, Multiply, Min or Max, and the Max, Min and Mean of a list of booleans no longer compile; the interpreter gives an incompatible-type error or leaves Max(True, False) unevaluated. The compiled Sum of [[true], [false]] was [1]. With fallback (the default), compile() runs the interpreter for these.
  • Compiled Multiply of a fold and a matrix did not compile (ROADMAP CE-388). With M: matrix<number> and L: list<matrix<number>> (or list<tensor<number>>, or list<unknown>), Sum(L)·M, M·Reduce(L, Add, 0) and 2·Sum(L)·M were refused at compile time, so compile() ran the interpreter. They now compile to the matrix product, decided on the shapes of the values as in the interpreter: Sum(L)·M is [[2, 2], [5, 3]] for L = [[[1, 2], [3, 4]], [[1, 0], [0, 1]]] and M = [[0, 1], [1, 0]]. With complex entries (L: list<matrix<complex>>, or an At(P, 1) of complex matrices) the product is computed as complex; such products were refused too. A factor that may hold points (Sum(L) over a list of lists of points, or over list<list<unknown>>) beside a matrix is still refused: compiled code holds a point as an array, and the interpreter scales the matrix by the point.

  • Multiply of a lazy collection and a matrix paired their elements (ROADMAP CE-390). The value of Scan, Reverse or Map is a lazy collection, and Multiply read it as a plain collection: beside a matrix, it paired each element with a row of the matrix. With L holding the vectors [1, 2] and [3, 4] and M = [[0, 1], [1, 0]], Reverse(L)·M was [[0, 4], [1, 0]]; it is now the matrix product [[4, 3], [2, 1]], as for the list literal [[3, 4], [1, 2]]. A lazy collection of vectors or matrices is read as the tensor of its elements: Scan(L, Add, 0)·M over a list of matrices now stays unevaluated (a tensor of rank 3 beside a matrix), as the same list literal does, where it gave a value. A lazy collection whose elements have no known type (Map(Reverse, L)) is read from its values when a matrix is beside it; two such collections keep the product of each entry, as compiled code computes it. A point in such a collection stays a point.

  • A list literal with an absent cell had no dimensions (ROADMAP CE-389). [[2, Missing], [3, 4]] was typed list<list<integer | missing>>, while [[2, 3], [3, 4]] is a matrix<integer^(2x2)>. So it could not be assigned to a symbol declared matrix<(number | missing)^(2x2)>, Shape and Rank of it were () and 0, Multiply of two such matrices paired their rows, and Determinant and Inverse were an incompatible-type error. A literal whose cells are numbers that may be absent now has the dimensions of its shape (matrix<(integer | missing)^(2x2)>, Shape (2, 2)), and an absent cell is read as NaN where a number is needed, as for an absent operand: [[2, Missing], [3, 4]]·[[1, 0], [0, 1]] is [[NaN, NaN], [3, 4]] (the value compiled code gives), its Determinant is NaN, and its Trace, which reads the diagonal only, is 6. Transpose, Flatten and the other operators that move cells keep the absent cell. The type of such a literal, and of a broadcast over it, now carries its length: [1, Missing] is typed list<(integer | missing)^2>, and Sin([Missing, 1]) vector<2>.

0.162.0 2026-10-23​

Breaking Changes​

  • Multiply of two matrices is the matrix product, however the factors are written (ROADMAP CE-98). A product of two matrix values is now the matrix product, as MatrixMultiply, also when the factors are symbols typed tensor<number>, list<list<number>>, list, any, broadcastable<number> or number | matrix, and under .N(). Before, a product of two such symbols multiplied each entry (and .N() did so even for matrix<number> symbols), while a literal or computed factor gave the matrix product; so p·p now equals p^2. Migration: use HadamardProduct(p, q) for the product of each entry. Unchanged: a vector or a plain list times a vector or a plain list multiplies each entry, and a number times a matrix scales each entry. Related changes:

    • The canonical form keeps the written order of factors that may be matrices (q·p stays q·p; p·q − q·p is no longer 0), and isSame() and the hash tell them apart. simplify(), Expand, Factor and D keep that order: (A·B)^2 is no longer A^2·B^2, the product rule of D keeps each factor in place, and D(f^n) for a matrix-valued f uses the matrix rules (D(f^-1) = -f^-1·f'·f^-1). A sum with a term that may be a number beside a term that may be a matrix is not expanded, because a number added to a matrix is added to each entry.
    • Matrices whose dimensions do not agree give Error("incompatible-dimensions", …) (the product stayed unevaluated), and the result type of a product of matrices has the dimensions of the matrix product (matrix<2x3>·matrix<3x4> is matrix<2x4>).
    • Two tensors of rank 3 of the same length multiply each pair of matrices, as Power does; a tensor of rank 3 beside a matrix or a vector stays unevaluated; a number times a tensor of rank 3 scales every cell; a Range beside a matrix is read as a vector.
    • Compiled JavaScript: matrix<…> operands call the matrix product; tensor<number>, list<list<number>> and broadcastable<number> choose by the shape of the values when the code runs. Python uses np.matmul for matrix types. interval-js throws (its result is then entire) for a list of lists times another list. Types that may hold points (bare list, list<unknown>, any, list<tuple<…>>) keep the product of each entry, because compiled code stores a list of points and a matrix as the same array (ROADMAP CE-216.1); their compiled operand order now follows the written order. A list whose rows have different lengths, or that mixes numbers and lists ([[1], [2, 3]]), is not a matrix: its elements are paired with the elements of the other factors, as in the interpreter ([[1], [2, 3]]·[[4], [5, 6]] is [[4], [10, 18]]; it was null or wrong values). Two tensors of rank 3 multiply each pair of matrices, as in the interpreter (it threw, and a compiled Product gave NaN entries); a tensor of rank 3 beside a matrix or a vector throws when the code runs. Where the interpreter gives an incompatible-dimensions error, compiled code gives NaN. A compiled Multiply fold (Product, Reduce(L, Multiply, 1), Scan) over elements of several shapes (numbers beside lists, a matrix beside a row, rows of different lengths) compiles and multiplies each step as the interpreter does; it was refused, or threw "the elements have incompatible dimensions" for a matrix beside a row. A compiled Sum still checks that its elements have one shape.
    • A product boxed while its symbols are unknown is still sorted: declare a matrix-capable type for the written order to be kept. Divide(p, q) of two matrices still divides each entry (p·q^-1 is the matrix product).
    • The Derivative of a function declared with codomain any keeps that signature (it was typed (any*) -> number), so expressions built on it are typed any too.
  • has() on a dictionary is the symbol test; the key test is hasKey(). has(v) asks whether the symbol (or function operator) v occurs in an expression, and a dictionary now answers that over the values of its entries, as every other expression does. Before, a dictionary replaced it with a key test: ce.box({ dict: { a: { sym: 'u' } } }).has('u') was false and .has('a') was true; now .has('u') is true, .has('a') is false (a key is a string, not a symbol, unless a also occurs in a value), and .has('Dictionary') is true. The key test moves to the new method hasKey(key) of DictionaryInterface (records included). Migration: replace dict.has(key) with dict.hasKey(key) wherever you test whether a dictionary or a record has a key. A call dict.has(key) still compiles, because has() exists on every expression, and now returns a different answer. The other symbol queries read dictionary values too: .symbols, .unknowns, .freeVariables, .subexpressions, .errors and the references of a dictionary, or of an expression that holds one, now include what its values hold (.errors of {"a" -> Error("oops")} lists the error, though the dictionary stays valid). has(), .subexpressions and these queries visit a value shared by several entries once.

  • A declared type is a contract on every value the symbol takes. s: string now refuses Missing and Undefined as it refuses 5, on the engine's assignment route and in Epsil (static check and run time alike); a declared element contract is checked too, so xs: list<string> refuses ["a", Missing]. An author who wants absence writes it: string | missing, list<string | missing>, or a refining declaration (list, list<unknown>). A number declaration still admits NaN, the numeric absence marker, so a numeric restriction such as 3 {a > 0} (typed integer | nan) binds to n: number as before; a restricted non-numeric value such as (1, 2) {a > 0} (typed missing | tuple<integer, integer>) needs tuple<number, number> | missing. This reverses the rule of 2026-09-09 under which a declared type described the successes only and a symbol declared string could report string while holding Missing. Decision P1 and P1b of docs/plans/2026-10-21-presence-contracts-and-assertion.md.

  • One default for an absent argument at a library parameter that excludes absence: it is refused. An operator with no absence behavior of its own now answers an incompatible-type error naming the parameter when an absent value (Missing, Undefined) reaches a parameter typed as data that excludes absence (string, boolean, value, a color, …), as a user function does for the same argument. Before, the handler received the marker and answered as it happened to: Parse(Missing) and IsSquareMatrix(Missing) computed a value from it, ToUpperCase(Missing) was already an error. Unchanged: numeric and collection operators answer the marker of their result (Sin(Missing) is NaN, Reverse(Missing) is Missing), operators that own their absence semantics (Length, Equal, the reducers, Coalesce, IsMissing), operators that hold their operands (Expand, Integrate, ForAll), and a parameter typed expression, symbol, any or unknown. Decision P2.

  • A list type with no dimensions is read by its elements. A vector, a matrix and a tensor are read by their cells. list<T> and set<T> now read T as the type of the elements, the values that indexing returns. A matrix of integers is a list<vector<integer>> and a list<list<integer>>. It is no longer a list<integer>. Before, the subtype relation also read T as the type of the cells (a list type with no dimensions "admitted any shape"), while indexing, the library and the compiler read T as the element type. The two readings together let a wrong value through: with L: list<number>, the assignment L := [[1, 2], [3, 4]] was accepted, L[1] + 1 was typed number and gave [2, 3], Mean(L) was accepted and failed at run time, and a compiled Sum(L) gave the string "01,23,4". Now the assignment is refused: a value of type matrix<integer^(2x2)> is not compatible with list<number>. list<integer> and list<list<integer>> are not related. Their meet is list<never>, the type of the empty list [], which is a value of both. For the same reason couldMatch() and typesOverlap() answer true for two lists with no dimensions, whatever their elements.

    vector<U>, matrix<U>, tensor<U> and list<U^dims> read U as the type of the cells, the values at the depth of the rank. The elements of a matrix are its rows: matrix<integer> is a list<vector<integer>>. A list<vector<integer>> is not a matrix, because its rows can have different lengths. A list has elements and may have a length (list<T^n>); vector, matrix and tensor have cells and may have a shape, written in one place: two rows of three integers are matrix<integer^(2x3)>, and a 2×3×4 array is tensor<integer^(2x3x4)>. Two spellings are now parse errors:

    • A list with two or more axes: list<integer^(2x3)>, list<integer^2x3>, list<boolean^(2x?)>, list<T^(MxN)>, list<2x3>. The message names the spelling to use: list<boolean^(2x2)> gives "A list type has one axis at most, its length (list<T^n>), but this type has 2 axes. Write matrix<boolean^(2x2)> instead". list<U^n> with one axis is valid.
    • A list with a length, a vector, a matrix or a tensor whose cells are lists: list<list<integer>^2>, list<vector<integer^3>^2>, list<list^2>, matrix<list<integer>>, tensor<list<integer>>. The message names the spelling with one shape (list<list<integer^3>^2> gives "Write matrix<integer^(2x3)>, with the shape in one place"); write list<list<integer>> for rows of any lengths.

    vector, matrix and tensor accept every cell type that is not a list: matrix<boolean>, matrix<string^(2x2)>, vector<string^2> (printed list<string^2>), tensor<boolean> and tensor<tuple<number, number>> are valid. Before, a vector or a matrix cell had to be able to hold a number, so matrix<boolean> was a parse error while matrix<any> was not. The bare names vector, matrix and tensor still mean number cells, so the linear-algebra operators, whose signatures use the bare names, still refuse a matrix of booleans. A printed type takes its head from its rank: rank 1 is vector<…> when the cells can hold a number and list<E^n> otherwise, rank 2 is matrix<…>, rank 3 or more is tensor<U^(…)>, and an unknown rank is tensor<U>. The types themselves do not change, only their spelling:

    ValueBeforeNow
    [True, False]list<boolean^2>list<boolean^2> (same)
    [[1, 2], [3, 4]]matrix<integer^(2x2)>matrix<integer^(2x2)> (same)
    M < 3, with M := [[1, 2], [3, 4]]list<boolean^(2x2)>matrix<boolean^(2x2)>
    (1, 2) * Mlist<tuple<integer, integer>^(2x2)>matrix<tuple<integer, integer>^(2x2)>
    [["a", "b"], ["c", "d"]]list<string^(2x2)>matrix<string^(2x2)>
    a 2×3×4 array of integerslist<integer^(2x3x4)>tensor<integer^(2x3x4)>
    Determinant of a matrix of booleansrefusedrefused (same)

    [(1, 2), (3, 4)] is still list<tuple<integer, integer>^2>, and Sin(M) is matrix<2x2>.

    tensor<U> is an array of any rank (1 or more) with cells of type U, and bare tensor is a numeric array of any rank. Before, tensor<U> meant list<U> (tensor<boolean> was list<boolean>, which does not hold a matrix of booleans), and bare tensor meant list<number>. The element type of a tensor<U> is U | tensor<U>: a cell, or a tensor of a lower rank. A matrix of integers matches tensor<integer>, but not list<integer>. A list<U> with a number type U and a vector<U> denote the same values: list<integer> matches vector<integer>, and vector<integer> matches list<integer>.

    broadcastable<T> is T | tensor<T> for a T that cannot hold a list (a number type, boolean, string, a tuple, integer | missing). A matrix of numbers matches broadcastable<number>, a matrix of integers matches broadcastable<integer | missing>, and a list<list<number>> does not match broadcastable<number> (its rows can have different lengths). broadcastable<boolean> is boolean | tensor<boolean>, so the matrix of booleans that a comparison over a matrix gives is one of its values, and broadcastable<tuple<number, number>> is a point or an array of points of any rank. broadcastable<T> no longer matches T | list<T>: to accept a number or a numeric array of any rank, write number | tensor, and for booleans boolean | tensor<boolean>.

    The relation is covariant again: vector<value> matches vector<any>, and list<expression^3> matches list<any^3>. They did not match before. The meet of two lists with no dimensions is the list of the meet of their elements: list<integer> & list<string> is list<never>. A meet of two list types is never only when their shapes cannot be the same: vector<integer^2> & vector<integer^3> is never.

    Some result types change:

    • A broadcast over operands of different ranks, over an operand whose type is a union of ranks, or over a tensor of unknown rank, is typed tensor<R>, for a result R that cannot hold a list. With U: list<integer> | matrix<integer>, U + 1 is tensor<integer> (it was list<integer | vector<integer>>). With T: tensor<integer>, T < 3 is tensor<boolean>. With an operator declared broadcastable: true and the signature (number, number) -> number, the call with [[1, 2], [3, 4]] and [10, 20] is tensor (it was list<number>).
    • Map over a matrix whose function keeps the length of the rows keeps the shape of the matrix: with h: (real) -> real, map(x ↦ h(x), [[1,2],[3,4]]) is matrix<real^(2x2)> (it was list<vector<real^2>^2>, a spelling that is now a parse error).
    • A generic function literal over a matrix keeps the shape of the operand: with g the literal (x: T) -> tuple<T, T> where T with the body (x, x), g([[1, 2], [3, 4]]) is matrix<tuple<integer, integer>^(2x2)> (it was list<tuple<integer, integer>>). Over a ragged list, see Resolved Issues.
    • A conditional protocol conformance type list<T> is P where … binds T to the elements of the receiver, as the generic solver does. For a matrix, the elements are the rows. With type list<T> is Named where T: number, name([1, 2]) uses the conformance as before, but name([[1, 2], [3, 4]]) now gives a protocol-implementation-missing error: T is vector<integer^2>, which is not a number (before, T was the cell type integer).
    • A cell pattern vector<T> or list<T^N> in a generic signature binds T to the cells of a tensor of rank 1, and a matrix no longer matches it: first: (x: list<T^N>) -> T where T, N applied to [[1, 2], [3, 4]] is an incompatible-type error (it was typed vector<integer^2>). A plain pattern list<T> binds T to the rows of the matrix.
    • Norm and Trace spell their tensor parameter tensor and accept a matrix as before. The result of Integrate spells its array arm tensor. Mean and the other statistics functions still accept a matrix when the expression is boxed, and refuse it when it is evaluated.
    • A broadcast over an operand that may hold a list of any depth is typed so. With b: broadcastable<number> holding a list, -b is tensor (it was list<number>, which does not hold a matrix value of b); with no value, it is broadcastable<number> as before. With C: indexed_collection, C < 2 is list<boolean | list<any>> (it was list<boolean>): an element of C may be a list, which the comparison maps at its leaves.
    • A generic function applied to a union whose options the broadcast reads differently is typed one option at a time. With f: (x: T) -> tuple<T, T> where T and u: list<integer> | set<integer>, f(u) is list<tuple<integer, integer>> | tuple<set<integer>, set<integer>> (it was list<tuple<list<integer> | set<integer>, …>>, which holds neither value). An operand of type collection<integer>, which may be mapped or passed whole, gets both answers.
    • PointX, PointY and PointZ of a list of points have the type of the coordinate: PointX of a list<tuple<string, number>> is list<string> (it was list<number>), and of a list<tuple<integer, integer>> it is list<integer>. Over rows of points whose first row is empty, PointX([[], [(1, 2), (3, 4)]]) is [[], [1, 3]] (it was [Missing, (1, 2)]). The compiled JavaScript refuses a point accessor over points whose coordinates may not be numbers, and throws a RangeError at run time on a value whose shape its type does not decide (numbers of rank 3 or more beside points).

    This entry replaces these entries of 0.160.0, where they describe the reading of a list type:

    • "The type broadcastable<T> is now T | list<T>": for a T that cannot hold a list, the collection part is now tensor<T>. Every subtype of list<T> is still a broadcastable<T>, and a tuple is still not a broadcastable<number>.
    • "The meet (intersection) of two collection types of different kinds is the narrower kind with the meet of the elements": list<number> and collection<list<integer>> no longer share a matrix. The type reducer now gives list<never> for their meet.
    • "The overlap test of types (typesOverlap())": two lists with no dimensions now always overlap, because the empty list is a value of both. list<list<list<real>>> and list<number> still overlap, but only through the empty list: they no longer share a tensor of rank 3.
    • "A list with dimensions whose element type is a top type (vector<unknown>, …) holds scalars, not lists": list<list^2> and list<list<integer>^2> are now parse errors. A matrix is still not a vector<unknown>, because the ranks are different.
    • "The meet of two collection types of the same kind no longer drops values that are in both": list<number> & list<list<integer>> is list<never> again, since no matrix is a list<number>, and the example matrix<number^(2x2)> & list<list<integer>^2> is now a parse error.
    • "The subtype relation of lists now reads a value in every way it can be read": the nested spelling read as its flat shape, the removal of outer axes by collection<E>, and list<vector<integer^3>> as a list<integer> are removed. matrix<string> and vector<collection> are now parse errors, list<vector<integer^1>^2> is a parse error, and list<vector<integer^3>> is not a list<integer>.
    • In Resolved Issues, "Subtype relation: matrix<T> is a subtype of broadcastable<T>": still true. couldMatch() still answers true for list<integer> and list<list<integer>>, but the reason is now the empty list: a matrix is not a value of list<integer>.

    The rules are in docs/TYPE-SYSTEM.md, section "Lists, tensors and broadcastable".

New Features​

  • FixedPoint(f, x) applies f from x until two successive values are the same. It was declared but did nothing: a call stayed unevaluated. FixedPoint(x => (x + 2/x)/2, 1.0) is 1.414213562373095…, and FixedPoint(Cos, 1.0) is 0.739085133…, correct to the working precision. Two values are the same when they are identical, when they are numbers, one of them a float, that agree to the working precision, or when they are lists or tuples whose elements are the same. An optional third argument is the maximum number of applications (the engine iteration limit by default); when it is reached, the last value is returned. Without a maximum, a call that cannot converge stays unevaluated: FixedPoint(Cos, 1) (Cos(1) is exact, and each step nests it) and an exact iteration whose terms gain digits; N gives their numeric fixed point. The result type is the result type of f.

  • The presence assertion x?! (AssertPresent(x) in MathJSON, ⁈ is an alias): the value of x, or an expected-present error naming the operand when x is absent (Missing, Undefined, NaN, Indeterminate, the set isMissing recognizes). Nothing passes through, an error operand propagates. Its type is the present part of the operand's type: xs[i]?! with xs: list<string> is a string, so f(xs[i]?!) is accepted at a parameter that excludes absence, and let y: string = xs[i]?! too. It is a postfix operator at the factorial's precedence and must abut its operand; as for 3! + 1, write x?! + 1, not x?!+1. Compiled: JavaScript, the interval JavaScript target and Python raise with the message expected-present: <operand> is absent; the check is omitted when the operand's type already excludes absence (a declared length and a literal index, a type without nan); a shader target compiles the identity and does not read the assertion as a proof, so a later absence test on the value keeps its own rule (decision P5). Decision P4.

  • The Epsil static check narrows a symbol an isMissing guard proves present. if isMissing(v) { "none" } else { f(v) }, if !isMissing(v) && v != "b" { f(v) } and isMissing(v) || f(v) == "a" are no longer reported at a parameter that excludes absence. A reassignment inside the region ends it; a function literal in the region is not entered.

  • The Epsil static check reports a possibly absent value wherever the run time refuses it: at a library parameter that excludes absence (trim(xs[i]), as it already did at a function's annotated parameter) and as the initializer of a declaration that excludes absence (let y: string = xs[i]). The discharges are the same everywhere: xs[i]?!, xs[i] ?? fallback, a guard, or a type that admits absence. Decision P3. A numeric read at a real, integer or complex declaration is reported too, since these types exclude NaN: let r: real = zs[i] for zs: list<real> (typed nan | real) was refused only when the program ran; write zs[i]?!, zs[i] ?? 0, or declare real | nan. A value with another type outside the declaration, such as a / b (infinity | nan | real), is not reported.

Improvements​

  • A Gaussian with a symbolic coefficient integrates to an error function (Tycho row 428). ∫ exp(−z²/(2s²)) dz is now √(π/2)·√(s²)·Erf(z/(√2·√(s²))), and ∫ exp(z²/(2s²)) dz the same form with Erfi; before, only a numeric coefficient (∫ e^{−3z²} dz) had a closed form. The sign of the coefficient is read as written when what remains after the sign is positive wherever it is defined, for every real value of the symbols (1/(2s²), e^a): ∫ e^{−a z²} dz with a bare a stays unevaluated, because at a < 0 the Erf form needs an imaginary argument, which the compile targets do not have, and so do ∫ e^{−s² z²} dz and ∫ e^{−|a| z²} dz, whose closed form is 0/0 at s = 0 or a = 0 while the integrand is 1. A symbol declared complex disqualifies the reading. A definite integral with an infinite bound carries the condition the way ∫₀^∞ e^{−cx} dx is 1/c {0 < c}: ∫_{−∞}^{∞} exp(−z²/(2s²)) dz is √(2π)·√(s²) {0 < √(s⁻²)}, and the normal distribution function 1/(√(2π)s) ∫_{−∞}^{x} exp(−½ z²/s²) dz, with or without a mean, is a guarded closed form in Erf. Its compiled JavaScript calls erf instead of running a numerical quadrature at every call: 2 469 calls take 2 ms instead of 3.2 s, with the same values to 2e-15.

  • More operators have a precise static result type. A type handler now derives the result from the operand types, where the declared result was a bare dictionary, tuple, list, matrix, value or unknown. Each type contains every value the operator can return for operands of those types; when the operand types do not decide it, the declared result stays.

    • FindRoot and FindFit are typed as their result record: FindRoot(x^2 - 2, [(x, 1)]) is record{parameters: record{x: real}, converged: boolean, residualNorm: nan | real | signed_infinity, iterations: integer, stationary: boolean} (FindFit has no stationary). So the Epsil static check reports let c: string = r.converged.
    • The linear-algebra operators Shape, PseudoInverse, LinearSolve, AdjugateMatrix, Kernel, MatrixMultiply, HadamardProduct, RowReduce, Diagonal, IdentityMatrix, ZeroMatrix, OnesMatrix, Eigenvalues, Eigenvectors, Eigen, LUDecomposition, QRDecomposition, CholeskyDecomposition, SingularValues and CharacteristicPolynomial carry the dimensions and the cell type of the result: PseudoInverse of a 3×2 matrix is a 2×3 matrix, and IdentityMatrix(3) is matrix<integer^(3x3)>.
    • TrigExpand, TrigToExp and TrigReduce of a numeric operand are number; TupleFrom of a list of known length is a tuple of that many components (TupleFrom([1, 2, 3]) is tuple<integer, integer, integer>).
    • A pipe whose stage is the name of a library operator is typed as the call: 16 |> Sqrt is real (it was unknown), and [3, 1, 2] |> Sort is list<integer>. So the static check reports let s: string = 16 |> sqrt. A stage that names a user function is not typed: a function parameter with the same name may be the function that is called.

Behavior Changes​

  • The signature of a function literal says only what its body proves about a parameter of unknown type. g := x ↦ [x] had the signature (unknown) -> vector<1>, which says that the element is a number, but g(Set(1, 2)) is [Set(1, 2)] and g("a") is ["a"]. The signature is now (unknown) -> list<unknown>: x ↦ [x, 1] and (x, y) ↦ [x, y] give list<unknown>, and x ↦ [[x]] gives list<list<unknown>>. A parameter that a use types as a number (x ↦ [x + 1]) and an annotated parameter ((x: real) ↦ [x]) keep the shape of the list. A call with a typed argument is typed from the body: with m := t ↦ [[t, 1], [2, 3]], m(2) is a matrix<integer^(2x2)> and m(r) with r: real a matrix<real^(2x2)>; a call with a free symbol is typed by the signature, list<list<unknown>>. The reading of an operand typed unknown as a number stays where an operation demands a number: x ↦ x + 1 is still (unknown) -> number. Consequences for a function with such a signature called with a free or an any-typed argument: an index into the call is typed missing | unknown (it was missing | number | vector<2>), a sum of its elements broadcastable<number>, and Dot of such an index the wide missing | value; the values are unchanged, and each type holds its value. With f(x) := (-x, x), h(a) := f([a, a]) is typed (unknown) -> list<tuple<number, number>> as before (the call maps f over the list), and an integral over a list integrand keeps its shape (∫₀¹ [x, 2x] dx is a vector<2>: the integration variable is a real). Compiled code that cannot tell whether an element of unknown type is a number declines in eight cases that compiled before: Trace(g(x)) + 1 with g := t ↦ [[t, 1], [2, 3]] and a free x in the complex lane, Sum(1 + v(x)[2], …) with v := s ↦ [s, k] on the JavaScript and GLSL targets, Product(Rest(g(x))) and Max(At(m(x), 1)). The workaround is a declaration, x: number or g: (number) -> matrix; ROADMAP CE-377 records the compiler change that removes the declines. ROADMAP CE-25; the rationale is in docs/TYPE-SYSTEM.md, "The inferred signature of a function literal".

  • The type of a list or set literal that holds an error value describes its other elements. A type describes the successful values, so an element whose type is error (an Error expression, or a failed application such as 1/"a") is left out of the element type when another element of the same level succeeded. [1, 1/"a", 3] is now typed vector<integer^3>, as [1, 2, 3] is; it was list<unknown>. [[1, 1/"a"], [3, 4]] is matrix<integer^(2x2)> (it was list<list<unknown>>), and Set(1, 1/"a") is set<integer> (it was set<unknown>). A level whose elements all failed keeps its type: [1/"a", 1/"a"] is still list<unknown>, and a set whose elements all failed is still set<unknown>. The error is still in the expression: isValid is false and errors lists it. As a result, the value of an element-wise If or Which with at least one successful cell matches the static type of the expression: If([True, Missing, False], 1, 0) is typed list<integer> and evaluates to [1, Error(…), 0], typed vector<integer^3>; that value was typed list<unknown>. A tuple is unchanged: (1, 1/"a") is still typed error.

    A variable with a declared type still refuses a collection that holds an error element, at any depth (also inside a tuple slot or a dictionary value, and through a symbol that holds the collection), unless the declared element type admits error (any, unknown, or an explicit error arm). With s: list<number>, s := [1, Error("oops")] throws, and the Assign and Declare operators give an incompatible-type error. The message now names the failed element ("The value holds the failed element Error("oops")…"), and it no longer states the type of the value when that type matches the declared type. A declared union is read arm by arm: list<number> | set<any> refuses [1, Error("oops")]. list<number | error> now admits that value; it was refused, because the value was typed list<unknown>. A parameter binding does not refuse it: a function whose parameter is declared list<number> runs when called with [1, Error("oops")], as before. A symbol without a declared type takes the value, as before.

  • An applied function literal is typed by its body (Tycho row 426). Apply(literal, args), and xs |> literal when the literal takes its argument whole, are now typed as the body with each parameter set to the argument's type, as a call of a named function already was: with M: list<real> and p(n) = s·n, Apply(a ↦ Map(p, a), M) is list<number> (it was collection<unknown>), and 'abc' |> (s ↦ s) is string. The derivation stops (and keeps the literal's own result type) for a body with an early Return, a loop, an assignment that is not at the top of the body, or a nested function that reads an outer parameter. A call of a named function whose body passes an inline callback (Map(u ↦ 2u, a)) is also refined. Other type changes in the same area:

    • A function literal's result type includes its early Return values, and Nothing when it can return nothing (Return(), or a final empty Block): x ↦ Block(If(x > 1, Return('big')), 2x) is -> number | string.
    • A Sum or Product index with the same name as a parameter no longer sets that parameter's type.
    • Map with a callback whose result type is unknown has elements of type unknown: Map(u ↦ Q, M) is list<unknown> (it was list<real>).
  • Inside a function or a Block, a stored value takes the current values of the variables it names; a closure keeps the variable. When Assign, Declare with a value, or a destructuring stores a value that names a parameter or a block local, those names are replaced by their values at that moment: with f := x ↦ Block(Declare(r), Assign(r, If(x > y, x, 0)), Assign(x, 100), r), f(3) is If(y < 3, 3, 0), and a block local no longer escapes its block unbound (it gave If(y < 3, b, 0)). A destructuring reads every variable as it was before the statement. A function literal in the value is not changed: it reads the variable when it is called, so a closure returned after a parameter is reassigned sees the last value (u ↦ u + 10, it was u ↦ u + 3), as closures over globals and block locals already did. When a function reassigns a parameter, a result that still names it uses its last value: a ↦ Block(Assign(a, [7, 8]), Map(u ↦ 2u, a)) gives [14, 16] (it mapped the original argument), and Filter likewise.

  • Compiled Power of a value whose depth is not known follows the interpreter's matrix power. For a base typed tensor<number>, tensor, list<list<number>> or broadcastable<number>, the compiled JavaScript checks the value's shape when it runs: a square matrix with an exact integer exponent gives the matrix power ([[1, 2], [3, -4]]^2 is [[7, -6], [-9, 22]]; it was [[1, 4], [9, 16]]), also for each matrix of a tensor of rank 3 or more, and a vector or a float exponent keeps the power of each entry. A non-square matrix to an integer power other than 1 throws expected-square-matrix, as the interpreter answers; this now also holds for MatrixPower, Inverse and Power over matrix<…>, which returned NaN. When compiled code cannot tell whether the exponent is exact (for example p^(2x) with x: real, or a float within 1e-12 of an integer, such as √n·√n), it throws instead of guessing. A base that may hold points (bare list, list<unknown>, any, list<tuple<…>>) keeps the power of each entry, unchanged, because compiled code stores a list of points and a matrix as the same array. A function the caller maps with functions takes precedence, as before.

  • An index into a broadcastable<T> whose T is a tuple, a string, a record or a dictionary is typed by what it can return. With p: broadcastable<tuple<number, number>> holding (1, 2), At(p, 1) is 1, but it was typed missing | tensor<tuple<number, number>> | tuple<number, number>, and Take(p, 1) is [1], but it was typed list<tuple<number, number>>. With p: broadcastable<string> holding "abc", At(p, 1) is the character "a", but it was typed missing | string | tensor<string>. The element readers (At, First, Second, Third, Last) and the selectors (Take, Drop, Rest, Most, Slice) now also include what an index into one value of T returns: the slots of a tuple (a nothing slot included), character for a string, the field types of a record (an unknown field included) and the value type of a dictionary, also for such an arm of a union T. At(p, 1) is now typed missing | number | tensor<tuple<number, number>> | tuple<number, number>, and a selector over broadcastable<string> is typed list<string | tensor<string>> | string, which holds the rows of a 2-D array of strings. A tuple and a string are still one cell for broadcasting, and the aggregates (Sum, Max, Reduce) read the elements as before.

    Compiled code: the JavaScript At(p, 3) of the one point (1, 2) gave Missing; it now gives NaN, as the interpreter does. The JavaScript and Python element readers and selectors of an operand that may be a string without proof that it is a list (broadcastable<string>, a broadcastable of one, an alias of one, string | list<string> on Python) now decline, so the fallback option decides: they read a string by UTF-16 code unit or by code point, so At(p, 1) of "abc" gave undefined (JavaScript) or NaN (Python), and First(p) of "😀b" half of a surrogate pair. Such an operand that holds a list of strings, which compiled correctly, now declines too. JavaScript First, Second and Third of an operand typed any, value or broadcastable<any> decline, as At and Last already did.

  • An index into a broadcastable<T> value is typed by what it can return (Tycho row 425). With g(t) = [t, 2t] and x: any, g(x) is typed broadcastable<vector<2>>: a number x gives a vector, a list x a list of vectors. g(x)[1] is now typed missing | number | vector<2> (it was missing | vector<2>, which does not hold the number that g(3)[1] gives), so Repeat(0, g(x)[1]) is valid. The same reading applies to First, Second, Third, Last and At with a list of indices, and to the elements of Take, Drop, Rest, Most and Slice of such a value. Other changes that follow:

    • Sum, Product, and Reduce with Add and 0 or Multiply and 1, over elements that may be numbers or rows are typed as a number or a row (Sum(Rest(m(x))) with m(t) = [[t, 2t], [3t, 4t]] is integer | matrix<2x2> | vector<2>; it was number), with nan when the operand may be absent.
    • Arithmetic on a union of lists with dimensions maps over the list: 2·vv for vv: vector<2> | vector<3> is list<number> (it was number).
    • Product, Reduce and Fold with Multiply over elements that are matrices or vectors are accepted (they were incompatible-type).
    • PointX/PointY and Dot of a union of numbers and vectors are typed from the vector arms; PointX of string | list<string> reads the character or element at the position.
    • Compiled JavaScript At, First, Second, Third and Last over elements that may be numbers or lists choose the out-of-range value from the value read, as the interpreter does: NaN for a list of numbers, Missing otherwise. Last([]) of a list that is not of numbers (a matrix included) is now Missing (it was NaN).
    • Compiled Sum and Reduce with Add over elements that may differ in shape throw when two elements have different shapes, as the interpreter answers incompatible-dimensions; compiled Product over such elements declines.
    • Compiled Repeat throws when its count is not an integer when the code runs (it rounded the count, or returned []).
    • Compiled JavaScript Max, Min, Mean, Median and Variance over elements that may be numbers or lists, or over an operand that may be absent and holds lists, decline (they gave NaN). Python Mean, Median and Variance of a tensor or broadcastable operand check the rank when the code runs and raise for a matrix; Python folds over a tensor<number> decline.
  • Element-wise If and Which over a mask of any rank. A condition that is a nested list of True/False/Missing now selects at every level. It stayed unevaluated before. With M := [[1, 2], [3, 4]]:

    • If(M < 3, M, 0) is [[1, 2], [0, 0]], typed matrix<integer^(2x2)>.
    • Which(M < 2, 10, M < 4, M, True, 0) is [[10, 2], [3, 0]].
    • The mask may be uneven: If([[True], [False, True]], 7, 0) is [[7], [0, 7]].

    At each level, a list condition and a list arm must have the same length, and a scalar is used at every position. A position decided by a scalar condition takes the arm's value there whole. Results for a mask of rank 1 do not change.

    An arm of lower rank than the mask is refused, as Add refuses it: If(M < 3, [10, 20], 0) is Error("incompatible-dimensions", "2x2 vs 2").

    The compiled JavaScript gives the same values. Before, it compiled, and then threw Condition must evaluate to "True" or "False" at run time. Under a mask of rank 2 or more, compiled code cannot tell a point from a row of numbers, so an arm whose type does not say where its points are (list, list<any>, tensor<tuple<…>>) throws a RangeError at run time when a nested mask meets an array element. Declare such an arm matrix or list<tuple<…>> to avoid it. A mask that is flat at run time is not affected.

  • The types of If and Which hold every value they can give.

    • A condition of unknown rank types the result list<C | list<any>>, where C joins the arms: Which(C < 2, 1, True, 0) with C: indexed_collection is list<integer | list<any>> (it was list<integer>).
    • A broadcastable<T> arm contributes T: with B: broadcastable<number>, If(B < 0, -B, B) is broadcastable<number> | list<list<any> | number>. It was broadcastable<broadcastable<number>>, a type with no meaning. The list option holds the uneven value of a mask of lower rank than an arm ([[1, 2], 0]).
    • A condition typed boolean | list<boolean> types the result like a condition that may be a mask: it typed as a scalar before.
  • The Python and interval-JavaScript targets refuse a condition that may be a list in If, Which and When, so the fallback option decides. With B: broadcastable<number>, Python emitted 0 if np.less(B, 0) else B, which raises for an array of two or more elements, and the interval target gave empty for If(B < 0, 0, B). The compile of B < 0 on its own on Python no longer overflows the stack. GLSL and WGSL do not change. On JavaScript, When(v, B < 0) with B: broadcastable<number> now restricts element by element when B holds a list: it compiled as one scalar test and gave v for B = [-1, 2], where the interpreter gives [v, NaN].

  • The derivative of an element selector, and of a list with a constant point (follow-up of Tycho row 424). First, Second, Third, Last, Rest, Most, Reverse, Take, Drop, DeleteAt, RotateLeft, RotateRight, Slice and At (also with several indices) are linear in their container for a fixed selection, as PointX/PointY/PointZ already were: the derivative is the selector applied to the derivative of the container. This holds when the selection does not depend on the variable or is constant between its jumps (\lceil u\rceil); a selection that varies with the variable declines. D(Last(h(u)), u) over a path of 29 pieces is a closed form in about 0.1 s (it declined after about 1.1 s). A selector that reads by value (Sort, Filter, Unique, Max, …) is not linear and keeps the generic rule. In the derivative of a list, a tuple or a piecewise branch, a constant element that is a point now gets the zero point of its arity, not the scalar 0: with Q := (1, 2), D([(t, t^2), Q], t) is [(1, 2t), (0, 0)] (it was [(1, 2t), 0]). A selection of a constant container gives the zero of the selection's shape (D(Take([1,2,3], 2), u) is [0, 0]), an absent element stays absent (Missing), and an index past the end gives the same value as the original selection.

  • A call of a user function that shadows a library operator compiles (Tycho row 423; the fix is under Resolved Issues). On the javascript, interval-js, GLSL and WGSL targets, a call bound to a user definition with a library name (Gamma := x ↦ (x-1)!, Sin, Sqrt, Square) compiles as a call of that user function, where compile() declined and the interpreter ran it. Before the target compiles, such a call is renamed to a private name declared in a temporary scope, so no part of the compiler reads it as the library operator (the angular-unit rewrite, sign rules, scalar facts, derivative recurrences, lazy streams). Decline messages still name the user's function. These keep the decline and run interpreted: a user definition of an operator the language builds by name (Add, Less, And, If, Sum, …), a multi-clause function, a definition with its own compile handler, stated derivative, lazy, broadcastable or declared effects, a node bound to the library operator, and every user function on the Python target. A declaration in a scratch scope of a name that no binding outside the scratch scopes holds now advances no state version, so such a compile leaves the engine's type, sign and memo caches as they were.

  • A numerical integral answers NaN at once when its integrand has no real value on part of the range (Tycho row 422; the fix is under Resolved Issues). The adaptive quadrature, on both the interpreted and the compiled route, stops with NaN when a panel and both of its halves have a majority of NaN samples, and the integrand next to the NaN set is not of rounding size, so the integrand has no real value there: the Monte-Carlo fallback is not run. \int_{-1}^{1}\sqrt{x}\,dx on the compiled real route was 0.6667 (the part below 0 was left out) and is now NaN, as the interpreter's real route is. Removable points no longer stop the quadrature: \int_0^{64}\frac{u}{u}dx with u = x - \lfloor x\rfloor gives 64, where the quadrature gave NaN and only the Monte-Carlo fallback gave an approximate value. An integrand that overflows to NaN in a tail it has decayed in (the logistic density on [0, ∞)) keeps its earlier result.

  • A D that has no closed form is not worked out again (Tycho row 424; the fix is under Resolved Issues). When the derivative of a D node gives no closed form, the node is returned as it is, and the attempt is recorded: evaluating the same node again, or reading a parameter that holds it, does not differentiate again. A write to a symbol the derivative reads (a value, a type, a definition of a function it calls), a change of the type of the variable, an assumption, or a change of the configuration makes the record invalid. A decline made inside another differentiation, under a temporary precision or angular unit, or for a node that is not pure, is not recorded.

  • A Sum or Product bound may be a type that could be a number (Tycho row 421; the fix is under Resolved Issues). A bound whose type is a union with a number arm (number | string, broadcastable<number | tuple>) or a top type (value, expression) is now accepted, where it was an incompatible-type error; string, boolean, a list or a tuple type, and a literal string, NaN or Missing, are still refused. Such a bound gets no closed form and no fold of two equal bounds, so the operator evaluates only when its value is a number: with y: number | tuple holding (3, 4), \prod_{n=1}^{y} n stays unevaluated, where a closed form would have given Factorial((3, 4)), a tuple. Its javascript compile answers NaN for a value that is not a number, where Math.floor read the string "3" as 3 and true as 1; a bound typed as a number, unknown or any compiles as before.

  • A numerical derivative narrows its window until the value settles (Tycho row 420; the fix is under Resolved Issues). The numerical derivative of an expression that stays symbolic (its javascript compile, ND, and a derivative of a function with no closed form under N()) is an 8th-order centered stencil whose samples reach 0.4 on each side of the point. It now halves its step until the values of successive steps agree (to 10⁻⁶ at order 1, a looser part at higher orders) or converge at the rate of the stencil, and is NaN when they never do, at a jump of the function. A smooth function keeps its value bit for bit, and the compiled and interpreted values stay identical. Other values change: ND of \cos(20x) at 0.05 is −16.829419, the derivative, where it was −14.55; ND of \sqrt{x} at 0.05 is 2.236068 where a sample below 0 made it NaN; ND of x\lfloor x\rfloor at 2, a jump, stays unevaluated where it was 14.19. A smooth function costs 12 calls of the function at order 1 instead of 8, fewer than before at orders 2 to 4 (44, 67 and 154 instead of 64, 512 and 4096), and a point at a jump up to 72 at order 1.

  • FindRoot polishes a steep equation to its root (Tycho row 419; the fix is under Resolved Issues). A root solve whose accepted step was below the relative step tolerance stopped there, even when the step had removed most of the residual: on a steep equation the step to the root is that small relative to the parameter. It now goes on while its steps more than halve the sum of squared residuals. a^2 = 10^{12} from a = 1 ends at a = 10^6 exactly (it stopped at a residual of 0.14), and \sin(10^8 a) = 0.5 at a residual of 6e-9 instead of 3e-5. FindFit is unchanged.

  • FindFit and FindRoot stop polishing at the tolerance's precision (Tycho row 418; the fix is under Resolved Issues). A solve within its tolerance ends once an accepted step moves every parameter by less than tolerance · max(1, |θ_j|) while the cost is at its rounding floor, where it went on to the gradient test or to the iteration cap: a badly scaled exact fit takes 5 iterations instead of 200, and its stationary key can be true without the gradient test. The converged key of a FindFit record now reads the engine's tolerance (ce.tolerance, never below 64 machine epsilons), which it ignored before: a fit whose every residual is within the tolerance of the terms its row computes is converged, stationary or not. Setting a looser engine tolerance makes more fits exact.

Resolved Issues​

  • Collection fixes listed for 0.160.0 were not in the 0.160.0 and 0.161.0 packages. A later change removed them before 0.160.0 was published, and they are back in this release:

    • Dedup, Filter and TakeWhile of a string held by a symbol of a wider type give a string: with c: list<integer> | string holding "aab", Dedup(c) is "ab", not ["a", "b"], and so is Dedup(Filter(c, p)).
    • Filter and Dedup over a dictionary give a Dictionary, not a Set of entry tuples, also when the dictionary is an arm of a union type and through nested Filter and Dedup views. A view over a dictionary of more than five entries prints as written (Dedup(d)).
    • The preview of Map(x ↦ x + 10, c), with c declared missing | list<integer>, is the list [11, 12], not Set(11, 12).
    • Map of a function literal whose parameter is annotated as a scalar ((x: integer) ↦ x + 1) over a list of lists is refused when it is boxed; it was typed list<integer> and gave an error for each row.
    • Map(Sqrt, [[1, 4], [9]]) is typed list<vector<1> | vector<2>>; it was list<list<number>>.
    • The types of FlatMap over a source with absent cells, of Map of an operator whose declared result is any (First, PointX), of Map over an alias of a tuple (list<number>, was collection<number>), and of Map over a dictionary arm of a union hold their values again.
  • The type of a Map did not hold its value in two cases. Map(x ↦ If(x = 1, "x", x), [1, 2]) was typed list<integer | string^2>, and its value ["x", 2] is a list<integer | string>: a list whose elements are a union that is not a number type now states no length, as a list literal does. Map(First, [[], [3]]) was typed list<integer | nan> for the value ["Missing", 3]: First([]) is Missing, since the element [] states no element type, and the type is now list<integer | missing | nan>.

  • A sum with an infinite term of unknown sign folded to that infinity. ∞·c + 1 evaluated to +∞ for a c with no known sign: the sum read the coefficient ∞ of the term and ignored c, although the sum is −∞ at c < 0 and 0·∞ at c = 0. Such a term now stays in the sum, also beside a pure infinity (∞·c + ∞ is ∞ − ∞ at c < 0), ∞·c − ∞·c is Indeterminate, and a term whose cofactor has a known sign still folds (−∞·3 + 1 is −∞). Found while fixing Tycho row 428: the value of ∫_{−∞}^{x} exp(−(z−m)²/(2s²)) dz at its lower bound, Erf(−∞·√(s⁻²) − m·√(s⁻²)), folded to −1 and lost the condition √(s⁻²) > 0 that the same integral carries at m = 0.

  • A use of a call at a typed parameter rewrote the signature of the called function. With g := t ↦ [[t, 1], [2, 3]], whose signature is (unknown) -> list<list<unknown>>, boxing Trace(g(2)) (the parameter of Trace is number | tensor) widened the result of g to list<list<unknown>> | number | tensor, and Determinant(g(x)) + 1 then declined to compile ("operand is not an indexed collection"). A use now refines only a result that the body leaves unknown (k := x ↦ x used in k(1) + 2 gets the result real, as before); a result that the body states, and that may fit the parameter, is kept.

  • D of the inverse of a matrix function stayed unevaluated. With f: (real) -> tensor<number> and f := x ↦ [[1, x], [x, 2]], the call f(x) is now typed matrix<real^(2x2)> from the body, so f(x)^{-1} canonicalizes to Inverse(f(x)), and D(f(x)^{-1}, x) was an unevaluated derivative of Inverse (it was differentiated as a negative matrix power while the call was typed tensor). D(Inverse(f), x) is now -Inverse(f) · f' · Inverse(f), and agrees with a finite difference.

  • At and First refused an operand that may be absent or of unknown type, and PointX of such an operand was typed missing. With g: (unknown) -> list<unknown> and x: any, g(x)[1][2] was an incompatible-type error (at x = [1, 2] its value is 2), and PointX(g(x)[1]) was typed missing, as if always absent; it is missing | unknown.

  • Compiled Sum, Product and Reduce over a list of elements of unknown type gave null or a string when the elements are lists. With L: list<unknown> and L = [[0, 1], [2, 3], [4]], the compiled Sum(L) was the string "01,23,4" (the interpreter gives an incompatible-dimensions error: the rows have different lengths), and with L = [[1, 2], [3, 4]] the compiled Product(L) was null (the interpreter gives [3, 8]). The fold now dispatches on each element: Sum(L) is [6, 9] for [[0, 1], [2, 3], [4, 5]] and throws for rows of different shapes, and Product(L) is the product of the elements, [3, 8], [17, 39] for a matrix beside a row (the matrix product), and [[4], [10, 18]] for ragged rows. A Multiply fold over elements typed as lists of cells of unknown type (list<unknown> | unknown, the elements of Rest(g(x))) still declines (ROADMAP CE-98.3).

  • The compiled statistics over a list<unknown> read each row as a number. With L: list<unknown> and L = [[1, 2], [3, 4]], the compiled Max(L) was NaN (the interpreter gives 4). Max, Min, Mean, Median and the other statistics now decline such an operand, as they decline a bare list and a list<any>, and the interpreter evaluates it.

  • Product, Reduce and Map over a list of ragged lists that hold matrices were an incompatible-type error. With L: list<unknown> and L = [[M, [5]], [N, [2]]] (M, N 2×2 matrices), the elements of L are typed list<matrix<integer^(2x2)> | vector<integer^1>>, and the check of the callback refused Multiply and Negate for them, so Product(L) was Reduce(L, Error(incompatible-type), 1), while the product of the two elements written as p·q had a value. A union of tensors and numbers below one or more list levels of an element is now read arm by arm, as a union at the top of the element already was: Product(L) is [M·N, [10]].

  • An element of a list<unknown> result was read as a number. With g: (unknown) -> list<unknown> and x: any, the element g(x)[1] is typed missing | unknown, and it is a list when x is a list. Compiled g(x)[1] + 1 gave the string "1,21" at x = [1, 2], a product gave NaN, g(x)[1] < 1 gave one false, and Sin(g(x)[1]) was typed number, so Sin(g(x)[1]) + 1 also joined strings. A value typed by unknown, any or value beside missing or nothing only is now read as that type, both when typing and when compiling: Sin(g(x)[1]) is typed broadcastable<number> | missing, and compiled arithmetic, the cell functions (Sin, Sqrt, Abs, Floor, …), the comparisons, an If that selects the element, and a Block local that holds it broadcast at run time, as they already did for a g declared (unknown) -> list. They give the interpreter's values: [2, 3] and [false, false]. A Block local that holds the element is read the same way, also inside a cell function: Block(Assign(y, g(x)[1]), Sin(y) + 1) gave a string, and Sin(y) was typed number. A symbol the user declared any, a global with an assigned value and a free variable stay scalar code, as before. A number x gives the same values as before. The cost: an index into a list whose elements are typed unknown | missing (L[i] + 1 with L: list<unknown | missing>) now compiles to the run-time broadcast _SYS.bcast, as an index into a list<unknown> or a list<any> already did. An index into a list of numbers stays scalar code.

  • A call of a function literal over a list had a different type inside the body of another function. With f := x ↦ (-x, x), the call f(L) for a list L evaluates to a list of pairs and is typed list<tuple<number, number>>. The same call typed from the types of its arguments, which is how the engine types a call in the body of another function and how a custom type handler derives a call (context.derive), was typed tuple<number, number>, the type of one element. Both now give the same type, for a list, a matrix (each cell is mapped), a broadcastable<number> argument and a function with a declared signature such as (real) -> real; a scalar argument and a parameter typed as a collection are not mapped, as before. A call with fewer arguments than parameters is now typed as the function of the remaining parameters in both cases. Some inferred types become narrower: with h := a ↦ f([a, a]), h(1) is typed list<tuple<integer, integer>> (it was list<tuple<number, number>>), and with g: (T) -> T where T (or where T: number), Map(g, rows) over a list<list<integer>> is typed list<list<integer>>. A generic function whose type variable has a bound no longer takes its result from a list element that breaks the bound: with g: (T) -> T where T: number and a list of strings L, g(L) was typed list<string>; each element of its value is an incompatible-type error, and the call is now typed list<number>, and so is the list option of an argument typed number | list<string> (the call is typed list<number> | number).

  • Sum of elements that may be lists was typed number. With g(t) = [t, 2t] declared (unknown) -> list<unknown> and x declared any, Sum(Rest(g(x))) was typed number, but at x = [1, 2] it is [2, 4]. Sum and Product now give a type that holds a list when their terms may be lists: broadcastable<number> for terms typed unknown (the type Add gives for such operands), as in Sum(L) for L: list<unknown> or L: list; any for terms that have an any option, as with g declared (unknown) -> list; list<any> | number for rows whose cells are not typed, as in Sum(M) for M: list<list<unknown>>; and number | vector<2> for Sum(g(x)) when g(x) is typed broadcastable<vector<2>>. This applies to the Sum(body, limits) form too: Sum(L[k], k, 1, 2). Terms typed as numbers, points or rows of numbers keep their type. A compiled Sum(g(x)) or Product(g(x)) over such a g now compiles to the interpreter's value (it declined before), and a compiled Sum(L) + 1 for L: list<unknown> now declines (it gave null for a list of rows).

  • Arithmetic over a cell of unknown type took the tier of the other operand. With u: broadcastable<unknown>, 2 · u and 2 + u were typed broadcastable<integer> and 0.9 · u broadcastable<real>, although a cell of u may be any number. They are now typed broadcastable<number>, and so are [1, 2] · u, [1, 2] + u and [1, 2] - u (they were vector<integer^2>, but u may hold a matrix). A free symbol in a list literal is now narrowed to number, as the same symbol in a scalar product is: 2 · [y] is typed vector<1> and y is inferred number (they were vector<real^1> and real). With w := x ↦ 2 · [x], w(i) was typed vector<real^1> with the value [2i].

  • The complex compile mode did not reach the cells of a list input. With mode: 'complex', a scalar input declared number used complex arithmetic, but a list<number>, tensor<number>, matrix<number> or list<unknown> input did not: Add(p, 1) with a cell 1 + i gave the string "[object Object]1". Element-wise arithmetic, cell functions and the orderings over such inputs now use complex arithmetic in that mode (an ordering of a non-real cell is false). The default and strict modes emit the same code as before.

  • Compiled statistics of complex data gave NaN. Compiled Mean, Variance, StandardDeviation, PopulationVariance and PopulationStandardDeviation over complex data now give the interpreter's values in the default and complex modes: Mean([3, 4i]) is 1.5 + 2i, and the variance family is real. The strict mode still declines. Real data gives the same bits as before in every mode.

  • The variance of large values of opposite signs overflowed. In compiled code and in the interpreter at machine precision, Variance([1e308, -1e308]) was -Infinity; it is now +Infinity, and StandardDeviation([1e200, -1e200]) is 1.414e200 (it was +Infinity). Ordinary data gives the same bits as before.

  • Compiled folds and reads over elements that mix numbers and lists gave wrong values. For a list whose elements may be numbers or vectors (list<number | vector<2>>, a tensor, or the selectors of a broadcastable<vector<2>>), compiled Sum, and Reduce with Add or Multiply, joined the rows as strings ("01,22,43,6") or gave NaN; they now combine element by element, as the interpreter does. Compiled Max, Mean and Reduce(…, Max) over such elements gave NaN or compared rows as strings; they now decline. Python Max/Min of an operand that may be absent or a list returned the operand itself; they now reduce it, and give NaN for an absent or empty operand.

  • HadamardProduct, and the element-wise sum, difference, quotient and power of two tensors, gave undefined cells when the two tensors had different data types. HadamardProduct([[0.5, 1], [1, 3]], [[1, 2], [3, 4]]) threw a TypeError when the type of the result was read. The operation was taken from the first tensor's arithmetic before the two tensors were converted to a common data type; it is now taken after. The product is [[0.5, 2], [3, 12]].

  • TrigExpand, TrigToExp and TrigReduce bind their operand when they are canonicalized, as the other operators do. The operand is still not evaluated before the rewrite. So Nothing is removed from the argument list: TrigExpand(Nothing) is the error of a missing argument, as Sin(Nothing) is (it was Nothing, and TrigReduce(Nothing) was 0).

  • A FindFit evaluated with evaluateAsync and cut short by the deadline while its data was evaluated reported no parameters (parameters: {}). It reports the parameters at their starts, as the synchronous route does. On that route a malformed parameter spec is now an error before the data is evaluated, and a start such as Random() is evaluated once, as on the synchronous route (it was evaluated twice).

  • An index that occurred only in a dictionary value was left in a Sum or Product. Every check of the form "does this expression mention k?" asked a dictionary for a key named k (see the has()/hasKey() entry under Breaking Changes), so \sum_{k=1}^{2} \mathrm{If}(x<1, \{"v"\to k\}, 0) evaluated to 2 \cdot \mathrm{If}(x<1, \{"v"\to k\}, 0) with the bound k left in, and simplify() gave 3\,\mathrm{If}(\dots k \dots) for the sum to 3 and \mathrm{If}(\dots k \dots)^3 for the product. The terms are now evaluated one by one, and simplify() keeps the sum. The compiler checks that look for a function literal or a Return now see one in a dictionary value.

  • The compiler declined a call of a user function that shadows a library operator (Tycho row 423). A Desmos document defines its own Gamma, \Gamma(x)=(x-1)!; with Gamma := x ↦ x + 100, the interpreter gave \Gamma(0.5) = 100.5, and the javascript and interval-js compiles of any expression that called it failed ("a user definition shadows the library operator Gamma"), while the same function named G compiled. Both declare(name, "function") and declare(name, { signature }) followed by assign now compile \Gamma(\frac{1}{2}) + x to 101.5 at x = 1.

  • A numerical integral took 0.5 to 2 s to answer NaN (Tycho row 422). The chi-square tail \int_{x}^{\infty}\frac{y^{\frac{k}{2}-1}e^{-\frac{y}{2}}}{2^{\frac{k}{2}}\Gamma(\frac{k}{2})}dy with k = 1 has no real value at x < 0, where the integrand is not real on [x, 0). The adaptive quadrature bisected the NaN panels until its budget of 1500 panels was spent, and then each route drew 10⁷ Monte-Carlo samples, whose mean was NaN again. Now x = −0.2 and x = −1 answer NaN in under 15 ms on both routes, and x = 1 still gives 0.3173105. Written with the number -\frac{1}{2} as the exponent, the interpreted value at x = −0.2 is the complex 1.0000 − 0.3691i (the principal value, which the interpreter integrates in two parts) and the compiled real value is NaN; that is not changed.

  • A compiled numerical integral of a divergent integrand took 200 to 400 ms and answered an arbitrary finite number (Tycho row 427). The absolute moment of the normal distribution \int_{-50}^{50}|t|^{x}\frac{1}{\sqrt{2\pi}}e^{-\frac{1}{2}t^2}dt diverges at t = 0 for x ≤ −1. The interpreter answered +∞, because it finds the pole from the expression with the value of x, but the compiled function gave a different number at each call (647, 708, 7249 at x = −1.5), and \int_{-1}^{2}\frac{1}{(t-x)^2}dt at x = 0.3 gave 8.9e16. The adaptive quadrature looked for a divergence only at the two bounds. When its bisection goes deep at a point inside the interval, it now finds the singular point and splits the interval there, so that each half has the singularity at a bound. The compiled function now answers NaN in under 5 ms for x = −1, −1.5 and −2.5, and the interpreter still answers +∞. A convergent moment is also more accurate: at x = −0.9 the error was 5.7e-10, and it is now 2.6e-12. A smooth integrand, a kink, a jump and a narrow peak are not split.

  • D(PointY(h(u)), u) took seconds, and was worked out again at each evaluation (Tycho row 424). With h a path joined from 29 linear pieces, the derivative went through the generic chain rule, which reads PointY as an unknown scalar function and multiplied by the derivative of the path, a list of 29 points, until a size limit declined: 0.6 to 3.6 s, paid again at each evaluation and at each read of a parameter holding the value (9 reads: 30 s). PointX, PointY, PointZ and At (with an index that does not depend on the variable, or that is constant between its jumps, such as \lceil u\rceil) are now differentiated as linear accessors: the accessor of the derivative of the container, with a constant point element of a list given the zero point. The derivative of the row is a closed form in 60 ms, and 9 reads cost what 1 does. The generic rule gave wrong values too: with g := t ↦ (t, t^2), D(PointY(g(u)), u) was a tuple, and is now 2u. PointZ of a list of 2-D points whose elements may be absent reports the dimension error, as for present points.

  • Two typing defects of 0.161.0, fixed by the change to the reading of list types (see Breaking Changes). A symbol declared (broadcastable<unknown>) -> unknown refused the function literal (x) => (x, x): ce.assign threw a type error, because provablyAdmits('nothing', 'broadcastable<unknown>') answered true, as if the type stated that a Nothing value is possible. A broadcastable<T> is now read as the union of its two parts, so broadcastable<unknown> states nothing about Nothing, as unknown does not, and the assignment is accepted. A generic function literal over a ragged list gave a type that does not hold the value: with g the literal (x: T) -> tuple<T, T> where T with the body (x, x), g([[1], [2, 3]]) was typed list<tuple<vector<integer^1> | vector<integer^2>, vector<integer^1> | vector<integer^2>>>, and its value is [[(1, 1)], [(2, 2), (3, 3)]]. It is now typed list<list<tuple<integer, integer>>>, which holds the value.

  • A Sum or Product bound refused a union type with a number arm (Tycho row 421). With x declared broadcastable<number | tuple>, \sum_{n=1}^{x} n^2, \sum_{n=x}^{5} n and \prod_{n=1}^{x} n were invalid, so a function such as s(a) = \sum_{k=1}^{a} k over that declaration never registered, while the bound of an integral and the operands of Mod and Max accepted the same declaration. The check of a bound said it refuses only a bound that cannot be a number, but it accepted only a type below number. Now s(3) is 6 and s([1, 2, 3]) is [1, 3, 6], as over a parameter declared any.

  • The derivative of a sum whose bound is ⌊x⌋ (Tycho row 420). D(Sum(Cos(i x), Limits(i, 1, Floor(x))), x) stayed unevaluated, because a bound that depends on the variable declined the rule for a sum, and the numerical derivative of its compile read across the jumps of the sum at the integers: −5.308 at x = 1.9, where the derivative is −0.946. A bound of a Sum whose derivative is 0, a step of the variable (Floor, Ceil, Round, Truncate or Sign of it, also through a call of a function), now keeps the rule: between two jumps the set of terms is fixed, and the derivative is Σ_{i=1}^{⌊x⌋} −i sin(i x), which compiles to javascript and interval-js; the derivative of order 2 closes too. At a jump the result is the value of that sum, as D(⌊x⌋) is 0 at an integer. A bound that is not a step (x) or not pure keeps the derivative unevaluated. A Product with such a bound still stays unevaluated, and its numerical derivative is now right between the jumps and NaN at them (the change under Behavior Changes): D(Product(x + i, Limits(i, 1, Floor(x))), x) at 1.9 is 1, where it was 57.3. D(Truncate(x), x) is 0, as for Floor; it stayed unevaluated.

  • FindRoot reports converged: false far from a root of a steep equation (Tycho row 419). The root test of 0.161.0 allows each residual tolerance · (1 + Σ_j |J_ij| · max(1, |θ_j|)). Its Jacobian part is a first-order claim, that a zero lies within a relative change of tolerance of the parameters, and it had no check: 2 - \sin(10^{18} a) = 0 has no real root, and its Jacobian, up to 10^18, let a residual of 1 to 3 pass at every point (converged: true from a = 1, from a = 0 and under the bound a \le 0). An equation whose residual is above tolerance is now a root only when the residual confirms the claim. Each parameter is moved by ±2 · tolerance · max(1, |θ_j|) toward and away from the zero. The central difference of the residual must be the change the Jacobian predicts to within half, and the parabola through the three values must reach tolerance, change sign, or touch 0 to within the rounding of the parameters (a root of even multiplicity, such as 10^{24}(a^2-2)^2 = 0). At a bound, the side inside the bounds is checked and a zero just past the bound is accepted. Over 100 starts, 2 - \sin(10^{18} a) = 0, \sin(10^{12} a) = 0.5 and \sin(10^{10} a) = 0.5, which oscillate faster than the tolerance can resolve, go from 100, 100 and 93 starts reported converged to none; \sin(10^8 a) = 0.5, a^3 - 2a = 10^9, 10^{12} a = 1 and e^a = 10^{30} stay converged. The check costs at most two residual evaluations per equation, at the points where the solve would stop.

  • FindFit reports converged: true at an exact fit (Tycho row 418). A degree-10 polynomial through 11 points is an exact interpolation whose constant term is 0. The fit reached the rounding floor (a residual norm of 3e-10) and reported converged: false after 200 iterations, since the record of a fit read converged from the solver's stationarity tests alone, and neither can pass on a problem scaled that badly: the Jacobian columns reach 10^10, so a residual at the rounding floor still has a gradient far above the absolute gradient tolerance, and the constant term, whose true value is 0, never takes a step that is small relative to itself (the per-component stall test of 0.161.0; the whole-vector stall test it replaced reported this fit converged after one iteration). A fit whose every residual is within the engine's tolerance of the terms its row computes, |r_i| ≤ tolerance · (1 + Σ_j |J_ij · θ_j|), is now converged, whatever the gradient and the parameters' magnitudes; a fit with a misfit is converged at a stationary point as before. The scale is the model's terms, not the max(1, |θ_j|) of the root test of FindRoot, under which a degree-10 polynomial over x in [10, 20] would tolerate a residual of 1 at data of magnitude 1. A root solve or a fit within its tolerance stops polishing once an accepted step moves every parameter by less than the tolerance of its own scale (tolerance · max(1, |θ_j|), the precision to which the tolerance test says the parameters are determined) while the cost is at its rounding floor, so the same fit now takes 5 iterations from the exact coefficients and 44 from a start of 1, and the stacked FindRoot form 4 and 39 instead of 20 and 92; a point whose residual is still dropping (1 + (10^{12} a)^2 = 0 from a = 10^{-11}, within the root tolerance of its steep equation at a residual of 101) goes on to its minimum as before. A solve cut short by a deadline reports converged: false beside timedOut: true, as the record's contract says; a root solve that timed out while polishing a root reported both true. The FindFit record still has no stationary key.

0.161.0 2026-10-21​

New Features​

  • FindRoot takes the options dictionary of FindFit (Tycho row 408). A third argument {global: {b: [lo, hi]}} searches the named parameters (at most two) over the range for the lowest sum of squared residuals of the whole system, equations included, as FindFit does for a model over data: the separable scan when the system is linear in the other parameters, a multi-start otherwise, with [lo, hi, step] for a caller-given grid step. A stacked system of fit rows next to a plain equation (a cos(b x_i + c) + d x_i + e = y_i for each data point, and d = 0) is searched for its best frequency; the constant phase coefficients of the equations (3.22 in cos(3.22 b + c)) space the grid as the span of a variable does for a fit, so a point falls in every basin of the residual. An equation built from a row at x = 0 reads a cos(0 · b + c) + 0 · d, which canonicalization keeps; such a parameter is no longer read as a step function of the system, which turned the scan into the coarse multi-start. The record is the usual one (see the stationary entry below). With a kernel pool on the engine (ce.workers), evaluateAsync() scans the grid on the workers, as FindFit does.

  • Parser.binderSymbol() and Parser.rebindSubscripts() are public, for a custom LaTeX dictionary entry that binds a name (a with clause, a loop form). Since 0.160.0 a subscripted name in a binder position is a new name also when its base is declared as a list (n_{0} with n: list<number> is At(n, 0) elsewhere); the two parser methods that the built-in binders use for that were internal, so a custom clause could not: with n a list, n_{0} + 1 \operatorname{with} n_{0} = 2 through a custom infix entry that read the binder with parseSymbol() was a parse error. binderSymbol(expr) gives the name that an expression declares in a binder position (n_0 for the At(n, 0) that the symbol parser made from n_{0}; a symbol is its own name; null otherwise), and rebindSubscripts(expr, names) rewrites those element accesses in a body, including a body parsed before the clause. See "Binding a Name in a Custom Entry" in the LaTeX syntax guide.

Improvements​

  • Factor uses certified univariate factorization over the rationals, including nonlinear factors such as Factor(x^4+4) = (x^2-2x+2)(x^2+2x+2). The numerics entry point exposes factorRationalPolynomial and its types for structured exact factors, multiplicities and explicit incomplete results when resource limits or certificate checks prevent completion.

  • .N() of Power, Exp, Ln and the other numeric heads of a number operand is about 1.6x faster at ce.precision = 30: the check for a collection operand no longer runs a type match on a number literal (#465, contributed by enumeratio).

  • GammaLn of a complex argument is the principal logarithm of Γ(z) in every region, as documented, and LogGamma stays the continuous branch (#463, contributed by enumeratio). GammaLn(0.5 - 11i) was -16.3598 - 15.3806i, the continuous value (the principal log is -16.3598 - 2.8143i), while GammaLn(-2.5 + 0.1i) was already principal, -0.1031 - 3.0313i. The imaginary part is now reduced to (-π, π], so GammaLn(-7.3 + 2i) is -13.3277 - 1.5238i (it was -13.3277 - 20.3734i), and GammaLn(25.5 - 40i) is 31.7745 - 1.2258i (it was 31.7745 - 139.4559i). Real arguments are unchanged: GammaLn(-2.5) is still ln|Γ(-2.5)|.

  • Compiled GammaLn of a negative real is ln|Γ(x)| on JavaScript and the shader targets, as the interpreter gives (#463, contributed by enumeratio). GammaLn(-2.5) compiled to JavaScript gave NaN; it is now -0.0562, and GammaLn(-7.3) is -7.7791. A pole (0, -1, -4) is +Infinity. The GLSL and WGSL _gpu_gammaln used the Stirling series, which is meaningless for x < 0; it now reflects (ln π - ln|sin πx| - ln Γ(1 - x)) and answers +Infinity at a pole, as _gpu_gamma does.

Behavior Changes​

  • unknown is an inference marker, not a content type. Every type is now a subtype of unknown, the absence markers, error and any included: ce.type("missing").matches("unknown") and ce.type("any").matches("unknown") are true, and list<integer | missing> matches list<unknown>. unknown still proves no contract: it matches only any and itself. Bare list, list<unknown> and list<any> denote one set of values and differ as declarations: the bare name and the explicit <unknown> ask inference to refine the element type from each assignment (xs: list<unknown> := [1, Missing] reports list<integer | missing>, where it stayed list<unknown>), list<any> is a contract that never moves. Bare tuple and record refine their slots and fields the same way (t: tuple := (1, Missing) reports tuple<integer, missing>), and a transparent alias of a bare name (type L = list) carries the instruction. A type derived from a value keeps an unresolved cell: ListJoin(fs, [2]) with fs: list<unknown> is list<unknown> (it was list<integer> for a value that could hold an error), and Filter(tu, p) over tu: tuple<unknown, missing> is list<missing | unknown> (it was list<missing> for the value [1]). A union keeps an absence arm beside unknown (missing | unknown) and absorbs a value arm (integer | unknown is unknown); a union with an error arm reduces to error, as before. An incompatible-type diagnostic shows a generic parameter whose variable got no bound by the variable's name, indexed_collection<T>, instead of indexed_collection<unknown>. A lambda whose parameters are not inferred yet, (unknown) -> number, still does not satisfy (any) -> number. expression and value are the contracts that exclude the immediate markers. Design and decisions: docs/plans/2026-10-21-unknown-inference-marker.md.

  • FindRoot reports converged only at a root (Tycho row 414). FindRoot(a^2 + 1 = 0, a) gave converged: True with residualNorm: 1: the solver's stop test is a stationary point of the sum of squared residuals, which is a minimum of the misfit for a fit but a root only when the residual is zero. converged is now true only when every residual is within the engine's tolerance scaled by its own equation, |r_i| ≤ tolerance · (1 + Σ_j |J_ij| · max(1, |θ_j|)) with J the Jacobian and θ the parameters: an absolute part for a residual that is zero to within the tolerance, a relative part that says the parameters the equation reads are determined to that relative precision (a^2 = 10^{12} from a = 1 is a root; a^2 + 10^{-7} = 0 is not; a steep equation or a large parameter does not hide another equation's residual). The tolerance is never below 64 machine epsilons, and a non-finite residual or Jacobian is never a root. The solver's stall test is now per component: the system a^2 = 4, b = 10^{11} stalled after one step at a = 2.5, which was reported as converged, because a step of 1 in a was negligible next to the norm of the whole parameter vector. The record gains stationary, the solver's own stop test, which is true at such a minimum too: a stacked system of data rows next to an equation, whose misfit is not zero, reads it where it read converged. The gradient test alone no longer stops a root solve: FindRoot(10^{-6} (a^2 - 4) = 0, a) from a = 1 stopped at its start point as converged, since the absolute gradient tolerance passes everywhere on a residual scaled small, and now reaches a = 2. The FindFit record and its convergence test are unchanged.

  • A call between a typed declaration with no value and the definition of the function now resolves. With let g: (integer) -> integer, then let a = String(g(2)), then function g(k) { 2k }, a was "g(2)" on the Epsil runner and on the whole-block route; it is now "4", and the call is checked against the declared signature (String(g(1.5)) gives the incompatible-type error). This also holds on each iteration of a loop body, when a block is evaluated again (with evaluate() or evaluateAsync()), and when a program runs again on the same engine. A program that stopped (time or step budget) before its definition ran no longer leaves that definition to a later program that declares the same name. Inside a function body the declared signature is still not checked (recorded in ROADMAP.md).

  • An evaluateAsync() call started by the host, or by a handler that passes no options, now runs on its own copy of the engine's evaluation-context stack, not on the engine's array. A scope that the host pushes or pops while the call is pending no longer reaches it. Example: start Block(Tick(), w + 1).evaluateAsync() with Tick() an operator that only has an evaluateAsync handler, then run ce.pushScope(); ce.declare('w', 'integer'); ce.assign('w', 5) before awaiting it. The result was 6; it is now w + 1. Pending asynchronous evaluations no longer see each other's scopes or the host's later scopes, as was already the case for scopes that an evaluation pushes itself.

Resolved Issues​

  • On the interval-js target, 0^x over an exponent interval has the value 0 (Tycho row 417). compile(ce.parse('0^{x}'), {to: 'interval-js'}) answered empty for every exponent interval, as did a declared base holding 0, so a plot that reads a row through the interval leg drew nothing for a slider at 0. The interval-exponent power read a base whose upper end is exactly 0 as a negative base. A base whose only point in the domain is 0 now answers [0, 0] for a positive exponent interval, [0, 1] for one that reaches 0 (x^0 is 1 in the interval library), a clipped enclosure for one that reaches below 0, and empty for a wholly negative one, where no point of the box is defined; the scalar-exponent power answered a clipped enclosure [+∞, +∞] there and now also answers empty. The literal bounds 0 and 1 are certified exact, so the export does not move them one ulp outward.

  • A subscripted binder is a new name in a cases block and in a set-builder whose variable appears only in the condition (the two residues of Tycho row 410). With n declared a list, the first row of \begin{cases} n_{0}\coloneq 3\\ n_{0}+1\end{cases} was read before the parser knew the environment is a block, so the target was the element access At(n, 0) and the block declared a function At; such a row of a cases, dcases or rcases environment now gets a second reading, the declaration of the local n_0, which the block swaps in for its first, middle and last rows alike (a single row, a row with a condition and a matrix keep the top-level reading). \{2n_{0} \mid n_{0}\in\Z\} read its variable as At(n, 0) because the body names no variable; a membership of the condition (v \in S, alone or in a conjunction) whose variable the body reads now names it, while a side condition on another subscript keeps its index. Both parse as with n undeclared. The parser's environmentName() gives a dictionary entry the innermost environment being read, and blockRowAlternative() the second reading of a row.

  • Solve no longer lets a called user function's global take the place of the unknown. With h := a ↦ a − k, Solve(h(k) = 1, k) gives [k + 1], and [3] with k := 2; it stayed unevaluated, and before that it gave []. The unknown is renamed in the equation and in the specs, as Integrate and Limit already rename their variable. This covers domain specs, a bundled {h(k) = 1, k ∈ 1..3}, systems and NSolve. An assumption about the unknown (assume(k ≠ −1)) and a declared or use-inferred type still restrict the solutions.

  • Solve(t·x = 1, t) gives [1/x] when t is declared real; it stayed unevaluated. The root 1/x is the pole ~oo at x = 0, but each of its finite values is real.

  • An unknown that has an assigned value is no longer restricted by the type inferred from that value. With x := 5, Solve(2x = 1, x) gives [1/2] and Solve(x^2 = 2, x) gives [√2, −√2]; both gave []. With x := 3, y := 4, Solve(2x + 4y = 1, [x, y]) is no longer sent to the integer solver, which gave []. A declared type still restricts the roots.

  • A Solve over a finite domain whose equation or predicate has a free parameter stays unevaluated: Solve(x − a = 0, x ∈ 1..5) and Solve(x > a, x ∈ 1..5) were [].

  • The bounds of a Limits clause and the collection of an Element clause of Sum, Product, Integrate and NDSolveFunction read the enclosing symbol, not the clause's own index. With k := 2, ∫₀ᵏ k dk gives 2 (was k²/2), ∏_{k=1}^{k} k gives 2 (was k!), Σ_{k=1}^{k} k gives 3 (stayed unevaluated) and Σ_{k ∈ [1, k]} k gives 3. Also Σ_{k=1}^{3} Σ_{k=1}^{k} k gives 10 on every route (was 3·Σ_{k=1}^{k} k), and ∫₀ᵏ k dk with a free k reports k as an unknown.

  • FindFit with the global option now spaces the grid of a frequency by the period of the expression its periodic function is part of, not by the period of the function alone. A square, an absolute value or a product of periodic functions of the same argument can have a shorter period: a cos(b x + c)², a |cos(b x)|, a sin(b x) cos(b x) and sin(b x) / cos(b x) have the period π, and (sin(b x) cos(b x))² has π/2. Their basins in b are half or a quarter as wide as those of a cos(b x). The grid was spaced for the period 2π, so the search could return a wrong frequency with a large residual: for example b over [0.3, 300] on 200 points over [0, 10], with the true b between two grid points. tan(b x)² keeps the period π of tan, because tan(θ + π/2) = −cot θ.

  • FindRoot did not move a parameter read through PointX of a point (Tycho row 415). On PointX(PointList(2a, 2)) = 4 the solver stopped after one iteration at the start value: the symbolic derivative of the residual was the generic chain rule's PointList(2, 0) · Apply(Derivative("PointX", 1), …), a product of a point and a number that is a type error, which compiled to a Jacobian of NaN. D now differentiates an accessor of a literal container through the operand it selects: PointX, PointY and PointZ of a Tuple or a PointList, and At of a Tuple, a PointList or a List with a literal index (D(PointY((2a, a^2)), a) is 2a, D(At([2a, a^2], -1), a) is 2a); over a PointList that zips (a component is a collection) the accessor reads the differentiated PointList, so D(At(PointList([a, 2a], 5), 1), a) is the point (1, 0); and FindFit/FindRoot take a finite difference for a derivative that is not valid.

  • Boxing nested folds whose accumulator is read by index no longer takes about three times longer for every level of nesting (#462). Since 0.158.0 such an accumulator is typed to the fixpoint of its seed and its body's result, which canonicalizes the combiner once per fixpoint step, and a fold nested in the body was refined from scratch inside each of those canonicalizations. Now a nested fold is not refined while the enclosing fold only admits its callback, and the fixpoint found for it in one step of the enclosing fold is the starting point of the next step, where one canonicalization settles it. Six nested folds of the shape reported box in 66 ms instead of 1.4 s (0.156.0: 47 ms); eight in 121 ms instead of 13 s. Types and values are unchanged, and a chain of numeric folds costs what it did.

  • A comparison with a long exact sum whose coefficients have many distinct denominators no longer throws. 0 < \sum_{k=1}^{300} \ln(-k)/k^2 evaluated by extracting the content of the sum, whose denominator (the least common multiple of k² over the terms) is past the double range; lcm gave Infinity and the next call threw RangeError: The number Infinity cannot be converted to a BigInt. rationalGcd now uses the bigint form past the safe integer range (with a non-negative denominator), and the public lcm of the numerics entry point answers NaN for a non-integer or non-finite operand, as gcd does, instead of throwing.

  • The presence test of a compiled branch condition no longer follows a global's value through a bound name of the same name. With k := 2m in the engine, \{\sum_{k=1}^{3} k > 0: 1, 0\} compiled to a test of m (_.m !== undefined), so an unsupplied m made the branch take no arm; the Sum index k is not a read of the global, and the compiled code is now (0 < 6) ? 1 : 0. The same held for a parameter of a called function literal named like a valued global. A global that the condition does read (directly, or through the body of a called function) is still tested.

  • A chain of \text{for all} or \text{there exists} clauses that have no condition, such as x_0 \text{ for all } y_0 \; x_1 \text{ for all } y_1 \; …, parsed in exponential time: 12 clauses took 10 to 30 seconds, and a ( after the chain (… + (z > 0)) made it slow too. A quantifier parsed the rest of the input up to three times, to find a ( that starts its body and then for its condition, and each parse reached the next quantifier. Now a quantifier parses its condition again only when its own ( test stopped the parse, so 12 clauses take less than 30 ms, or about 100 ms with a ( after the chain. The parse results are unchanged.

  • An aborted broadcastAsync() or FindFit.evaluateAsync() no longer keeps a worker of the kernel pool busy. Before, a slice that a worker was running when the signal aborted ran to its end, and its result was dropped. Now the pool tells the worker to stop through a cancel flag in shared memory; the worker reads it every 64 cells, or at each grid point of a FindFit scan, and is free again at once. A slice also stops within 64 cells of its deadline, so a heavy kernel keeps the cells it computed, and a slice whose deadline has passed before its turn is not sent to a worker. A worker that has not answered within the new cancelGrace option of createKernelPool() (default 250 ms, at most 2147483647; Infinity turns it off) after an abort, or after the deadline of its task, is terminated and replaced by a new one from create; the tasks that waited for it run on the new worker. Without SharedArrayBuffer (a browser page that is not cross-origin isolated), or on a host that cannot send the flag, only the termination applies.

  • Each asynchronous evaluation now has its own top-level evaluation number, carried by its evaluation-context stack. Before, all pending asynchronous evaluations of an engine shared one number. So with B(h) = Block(r := h(k), h := z ↦ 2z, r), Promise.all([B(h).evaluateAsync(), B(q).evaluateAsync()]) gave h(k) and q(k), where the same two evaluations awaited one after the other gave 2k and 2k. A synchronous evaluation of such a block while an asynchronous one was pending also gave q(k). Both now give 2k. Related fixes: when a symbol holds such a block (p), ce.symbol('p').evaluateAsync() now gives 2k, as evaluate() does; a block in an operand of a function application stays a nested block on the asynchronous route, as on the synchronous route (with f(a, b) = (a, b), f(Tick(), B(h)) gave (0, 2k) and now gives (0, h(k))); a sticky unresolved call read again after an await in one asynchronous evaluation stays unresolved (it could resolve too early); and the locals of a block are kept separately for each evaluation, so a synchronous evaluation of a block between two iterations of a pending asynchronous loop over the same block no longer removes the loop's locals (t + 2 instead of t + 3), and two evaluations whose runs of one block alternate no longer lose each other's locals (t + 1 instead of t + 3).

0.160.0 2026-10-21​

Behavior Changes​

  • A call evaluated while its function had no definition stays symbolic for the rest of the top-level evaluation that produced it, even when the name is assigned later in that evaluation. Sum(Block(If(k = 2, h := z ↦ 2z), h(k)), k, 1, 3) is 10 + h(1): the h(1) computed at k = 1 is not resolved by the assignment at k = 2 (the unreleased callable-binding change had made it 12). A later evaluation resolves the stored call as before: r = ce.parse('g(3)').evaluate(), then ce.assign('g', …), then r.evaluate() is 9. A Block evaluated directly by the host counts each statement as its own evaluation, as the Epsil runner does, so Block(r := fn(3), fn := Function(2z, z), (r, fn(3))) is still (6, 6), while the same block nested in another expression, or a do block in an Epsil statement, keeps fn(3).

  • In an Epsil program, a function statement is installed before the first statement runs when a statement before it calls the function: let a = String(g(3)) then function g(k) { k^2 } gives "9" (it gave "g(3)"; let a = g(3) gave 9 on both routes already, by resolving the stored call when a was read). The installation is skipped, and the program behaves as before, when a type statement comes before the function, when an earlier statement gives the name a value, when the body reads an earlier variable whose type cannot be read statically, or when the function is generic. An assignment of a function literal (g = (k) => k^2) takes effect when it runs, as before.

  • On the whole-block route, a let g: (integer) -> integer statement followed by function g(k) { 2k } now checks a call written before the let: g(1.5) is incompatible-type on every route (it was 3 on the block route, which ran the clause installed before the declaration).

  • The closure documentation now states what the engine does: a function literal captures the binding of a free variable, and reads the value that binding holds when the function is called; the user guides describe how a call resolves its head lexically and how a call before the definition behaves.

  • Bare list, set, dictionary, collection, and indexed_collection now admit arbitrary contents, matching their <any> forms. Bare tuple accepts every arity and slot type, including absent coordinates; tuple<any> still has one slot. Bare record covers records with absent fields. Explicit element constraints remain restrictive, and explicit <unknown> no longer collapses to the broader bare name. Numeric vector/matrix defaults are unchanged. Compiled omission-sensitive operations require evidence that raw Nothing positions cannot occur; use an explicit content constraint when the broader bare family no longer provides that evidence.

  • Raw Tabulate / Table, Scan, and Iterate results retain possible Nothing in their element types. A two-dimensional tabulation returns lazy rows: accessing a row or its count does not evaluate its cells or erase omitted positions. Shape preserves raw extents, including omitted cells and empty axes. ListFrom(row) explicitly collects and omits those entries. Compilation refuses omission-capable tabulations and scans until targets can represent omission distinctly.

  • The type broadcastable<T> is now T | list<T>. It was T or an indexed collection of T other than a tuple or a string, so the subtype relation was not transitive: tuple<integer> is an indexed_collection<integer>, which was a broadcastable<integer>, but tuple<integer> was not one. Now every subtype of list<T> (a vector, a matrix, a Range) is a broadcastable<T>, and a tuple, a string, a set and a value typed only indexed_collection<T> are not. A tuple is now provably disjoint from broadcastable<number>, as the rule that a tuple argument is passed whole requires. Evaluation does not change, with two exceptions that follow from the new meaning: ce.assign of a value typed indexed_collection<T> to a symbol declared broadcastable<T> is refused, as it is for a symbol declared list<T>; and Map(f, [(1, 2), (3, 4)]) with f: (broadcastable<number>) -> number is one incompatible-type error instead of a list of errors, one for each point.

  • The meet (intersection) of two collection types of different kinds is the narrower kind with the meet of the elements, not never: list<number> & indexed_collection<integer> is list<integer>, set<number> & collection<integer> is set<integer>, and tuple<number, number> & indexed_collection<integer> is tuple<integer, integer>. So is the meet of a list with dimensions and a list with none: list<number^2> & list<integer> is list<integer^2>. A pair that can share a matrix by different readings (list<number> & collection<list<integer>>, which an integer matrix inhabits), and a pair with two transparent aliases, are kept as written unless they are provably disjoint; a single transparent alias is met through its definition. One effect: with type alias json = list<json> | integer, Norm(j) for j: json is valid (it was refused when boxed), and with j = 5 it evaluates to 5.

  • The overlap test of types (typesOverlap()), used to admit arguments and to detect protocol conformances that collide, compares the arms of a union one by one, and two collections that share only the empty collection do not overlap at any level, as for a parameter that is not a union. So a call is refused when the argument type and a union parameter share only empty collections: f: (list<string> | string) -> integer refuses an argument typed list<integer> | integer, as f: (list<string>) -> integer refuses an argument typed list<integer>. Two collections whose elements are at different depths now overlap when a tensor can be in both: list<list<list<real>>> and list<number> share a rank-3 tensor of reals.

  • A list with dimensions whose element type is a top type (vector<unknown>, list<any^3>, list<(any | integer)^3>) holds scalars, not lists: list<list^2> and list<list<integer>^2> are no longer subtypes of vector<unknown> and list<any^2>, as list<integer^(2x2)> already was not. The relation was not transitive: list<integer^(2x2x2)> is a list<list^2>, which was a vector<unknown>, but list<integer^(2x2x2)> is not a vector<unknown>. The meet follows: list<any^3> & list<list^3> is never (it was list<list^3>), and the two types do not overlap. A list with no dimensions is unchanged: list<list^2> is a list<unknown>.

  • The meet of two collection types of the same kind no longer drops values that are in both. Two lists, two indexed collections or two collections whose elements are at different depths are kept as written: list<number> & list<list<integer>> was list<never>, although an integer matrix is in both. Two lists with different fixed shapes are met row by row: list<number^(2x2)> & list<list<integer>^2> is list<integer^(2x2)> (it was never), and list<number^2> & list<integer^-1> is list<integer^2> (it was never). A list with a fixed length and no common element is never: list<number^2> & list<string^2> was list<never^2>. A bare list is read as list<unknown>: list<any^3> & list is list<unknown^3> (it was never). The overlap test follows: list<number^(2x2)> and list<list<integer>^2> now overlap, which two protocol conformances on these types would show as a collision.

  • Two recursive aliases that can share no finite value are disjoint: with type alias A = tuple<A> | integer and type alias B = tuple<B> | string, A & B is never and the two types do not overlap (they were kept as written and overlapped). couldMatch() reads the arms of a recursive alias: with type alias json = list<json> | integer, couldMatch(number, json) is true (it was false). The test reads recursive aliases by their finite values, while the subtype relation reads them coinductively: with type alias V = tuple<V>, V is a subtype of A and of B although A & B is never. V has no value, so no value is lost.

  • A generic alias whose arguments grow at each unfold (A<T> = B<list<T>> | integer with B<T> = A<T>) no longer overflows the stack in the subtype, overlap and meet tests: past a limit on the open unfolds, the answer is the conservative one.

  • The meet, the overlap test, couldMatch() and provablyDisjoint() of types built from a chain of aliases with two branches at each level (Aₙ = list<Aₙ₋₁^2> | list<Aₙ₋₁^3>) take a time that grows with the depth of the chain. The work doubled at each level: at depth 20, the overlap test took about 7 s and the meet did not finish in 10 minutes.

  • A literal empty list at a required or optional point parameter is accepted and maps to [] without invoking the function, matching evaluation.

  • Interpreted calls and collection callbacks now use their captured lexical binding, including lazy and asynchronous evaluation. Updating that binding remains visible; an unrelated declaration with the same name cannot redirect the call. Forward calls resolve in their original lexical environment.

  • The coordinates of a point divided by a number that may be zero are typed real | infinity | nan, as a scalar quotient has been since 0.121.0: with p: tuple<integer, integer> and x: integer, p / x was typed tuple<rational, rational>, but x = 0 gives (~oo, ~oo). The same applies to a list of points and to a value typed broadcastable<vector<…>>. A divisor proven not to be zero keeps the precise type: p / 4 and p / n with n: integer<1..> are tuple<rational, rational>. Also, i / x is typed imaginary | infinity (was imaginary), and (1 + 2i) / x is typed number (was complex). An imaginary divisor is never zero, so w / j with w: complex and j: imaginary stays complex.

  • A collection divided by itself is no longer folded to 1: L / L with L: list<integer> stays L / L, and with L = [1, 2] it evaluates to [1, 1]. The fold x / x → 1 still applies to a symbol that is not declared as a collection.

  • A structural Divide(a, b, c) is typed as (a / b) / c. Only the first divisor was read: Divide(1, 2, x) was typed rational although x = 0 gives ~oo. A real numerator that may be infinite, divided by an imaginary number, is typed number (was complex): +∞ / i is ~oo.

  • Calls through callback parameters retain their lexical contracts, including inside nested lambdas such as Map(x ↦ h(x), xs). A same-named global no longer supplies the call's result type or introduces Nothing. A parameter first used as a call head now keeps its annotation and validates arguments against it. Ordinary calls and captured operators follow the same binding rule during body-based type inference. Compilation refuses a known captured function body when lowering it would substitute a different ambient binding.

  • Bare Map callbacks such as Map(h, xs) follow the callback's lexical binding. A parameter named after a global function or library operator now uses its own declared result type and argument contract. An opaque callback result stays any; a shadowed global cannot supply a narrower result or introduce Nothing into this callback's result. Reducer parameters also retain their own result type when named after a built-in arithmetic operator.

  • Corrected Map inference to retain possible nested collections and missing data when it excludes Nothing from callback inputs. Opaque any callback results remain any; they no longer copy the source element type. Positional callbacks over strings report character data. Compilation again accepts tuple mappings whose callback bodies prove they cannot return Nothing. Omission-capable sources remain refused; numeric collection annotations restore compilation for affected aggregate and predicate code. Explicit numeric callback contracts also compile for mutable local functions and callback parameters; opaque runtime callbacks remain refused.

  • Data tuples preserve Nothing coordinates: (1, Nothing, 3) has three positions. A tuple whose coordinates are all missing remains a tuple. Zip preserves these coordinates and all-missing rows, so source positions stay aligned. JavaScript and Python compiled tuples retain all-missing tuples; compilation refuses possible Nothing coordinates until targets can represent omission distinctly. Lists and TupleFrom retain their existing immediate omission behavior.

  • Numeric aggregates skip Nothing yielded by raw lazy views. Mean of a view exposing 1, Nothing, 3 now returns 2 instead of an incompatible-type error; its denominator counts only retained data. The other univariate statistics follow the same rule, and extrema such as Min now finish evaluating after an omitted datum. All-omitted input follows each operator's ordinary empty-input rule (Sum gives 0, Product gives 1). Source counts and positions are unchanged; Missing still propagates normally. Aggregate compilation refuses omission-capable source types, including collection<any>, until targets can distinguish omission from missing data.

  • Filter drops immediate Nothing inputs without calling its predicate. Filtering a raw view yielding 1, Nothing, 3 with an always-true predicate now yields 1, 3, rather than an incompatible-type error cell or a retained omission. Count(source, predicate) follows the same selection rule. Missing and nested markers still reach the predicate. Result types exclude immediate nothing; compilation refuses sources that might contain it until targets can distinguish omission from missing data.

  • Map bypasses its callback for immediate Nothing inputs and preserves their positions. A strict numeric callback over a view yielding 1, Nothing, 3 now yields its two results and Nothing, rather than an incompatible-type error cell. Multi-source maps bypass the whole row if any input is Nothing. Missing and markers nested inside a datum still reach the callback.

  • Covariance, PopulationCovariance, and Correlation skip whole aligned observations containing Nothing, instead of rejecting them. Raw source lengths must still match; all terms use the same retained observations. Compilation refuses these statistics and maps whose inputs or map results may contain Nothing until targets can preserve its distinct representation.

  • Dedup, Filter and TakeWhile of a string held by a symbol of a wider type give a string, as they do for a string literal: with c: list<integer> | string := "aab", Dedup(c) was ["a", "b"] and is now "ab"; Dedup(Filter(c, p)) is a string too.

  • Filter and Dedup over a dictionary give a Dictionary (also when the dictionary is an arm of a union type, through nested Filter/Dedup views, and for a bare record); they gave a Set of entry tuples (Set(("a", 1))). Filter over a record is typed dictionary<any>.

  • Map of a user function with scalar parameters over a list of lists is typed with lists as elements (Map(f, pr) with f: (number) -> number is list<list<number>>, was list<number>), as the call broadcasts over each row; a polymorphic callback with one parameter ((T) -> T where T) is typed the same way (list<list<integer>>). Map of a function literal written in place whose parameter is annotated as a scalar ((x: integer) ↦ x + 1) over a list of lists is refused when it is boxed; it was admitted and gave an error for each row. Over a source that can hold Missing, such a literal is typed with the NaN it gives for an absent cell: Map((x: integer) ↦ x + 1, [1, Missing]) is list<number> (was list<integer>, value [2, NaN]). A Missing that the body returns for a present cell stays in the type, also for several sources of an abstract collection type.

  • A view whose source can be absent (Map(x ↦ x + 10, c) with c: missing | list<integer>) previews as a list; it previewed as a Set. The preview, and a bounded materialization, of Dedup, Filter, Join or Append over more than five dictionary entries show the expression as written (Dedup(d)); they showed an incompatible-type error.

  • Two mutually recursive aliases that describe the same values are subtypes of each other: with a = list<b> and b = list<a>, a <: b is now true (it was false, a conservative cutoff). A self-referential alias such as R = list<R> is a subtype of list, list<unknown>, list<list<unknown>> and value; J = list<J> | missing is still not a subtype of unknown.

  • Dedup, Filter and Map accept a source typed missing | string (typed missing | string or list<character> | missing); it was an incompatible-type error. Map of a broadcastable operator over ragged rows (Map(Sqrt, [[1, 4], [9]])) gives list<vector<1> | vector<2>>; it was an incompatible-type error.

  • A comparison with an undefined operand is false in a branch condition too, as in Desmos and IEEE 754. An ordered comparison (<, ≤, >, ≥) or an equality (=) whose operand is NaN or Indeterminate already evaluated to False on its own; an If or a Which on it now takes the next arm (the else arm) instead of no arm. This holds in the interpreter and on the javascript, python and interval-js targets. Before and after:

    • \left\{\frac{0}{0}>0:1,0\right\} (also with <, ≤, ≥, =), and If(0/0 > 0, 1, 0): Missing before, 0 now.
    • \left\{x > 0: 1, 0\right\} compiled to JavaScript and run at {x: NaN}: NaN before, 0 now. The same on the Python target. The compiled guard no longer tests the operand for NaN (_.x === _.x); it still tests for a variable the caller did not supply (_.x !== undefined), which still gives NaN, also through arithmetic (2x > 0). The guard tests a variable of any type (p.x > 0 tests p), and a variable read through an assigned value (a := \sin x, then \cos a > 0) or through the body of a called user function. It does not test a variable read only by an arm of a nested If, Which or When: If(If(x > 0, y, 1) > 0, 1, 0) at x = -1 is 1 with y not supplied.
    • 0 < \sqrt{x} compiled to interval-js at x = [-1, -1], where the root has no value (empty): 'maybe' before, 'false' now. The same for ≤, >, ≥ and =.
    • On interval-js, a comparison with an operand that has a value over only part of the cell (partial) is now at most 'maybe': \sqrt{x} \ge 0 at x = [-1, 1] was 'true'. 0 < \sqrt{x} there stays 'maybe'.
    • A sum of such a piecewise, \sum_{n=0}^{10000} M(n) with M(n) := \{K(n) > 0: 1, 0\} and K(0) undefined, is now 10000 instead of NaN.

    Not changed: ≠ keeps its behavior (\mathrm{NaN} \ne 0 is True, and a branch on a ≠ with a NaN operand still takes no arm: Missing in the interpreter, NaN on javascript and python, 'maybe' on interval-js). Missing keeps its Kleene reading (Less(Missing, 1) is Missing): a comparison with a nested else-less If or default-less Which that selects no arm still takes no arm (If(If(c > 0, 1) > 0, 1, 0) at c = -1 is NaN compiled), and on interval-js that value is an empty with a missing flag, so Which(x > 5, 1) > 0 over x = [0, 1] stays 'maybe'. A comparison of two collections with an absent cell still takes no arm. The GPU targets (glsl, wgsl) already took the else arm. This reverses the rule of 2026-09-03, under which a NaN operand of any comparison made the branch take no arm (docs/ERROR-MODEL.md).

  • A value that has no order also makes a comparison false: the pole ComplexInfinity (~oo, the value of 1/0) and a number that is not real (i, \sqrt{-1}, \ln(-1), any value with a nonzero imaginary part). An ordered comparison (<, ≤, >, ≥) with such an operand is now False, alone and in the condition of an If or a Which, which then takes its next arm. +∞ and −∞ are real and keep their order. An equality with the pole is False (the pole has no value, as NaN has none), but an equality with a complex number keeps its meaning. An exact constant such as \ln(-1) is approximated to find its imaginary part (not when it holds an infinite Sum or Product or a Limit), and the imaginary part of a number literal is read exactly (1 + 10^{-800}i is not real). A comparison stays unevaluated only when its operands are free symbols or forms that depend on one (0 < x, 0 < ix): a value with no order decides False also next to a free symbol (x < i, x < \frac{1}{0}), as NaN does. A chain stops at its first pair that is False. Before and after:

    • 0 < \frac{1}{0}, 0 < i, 0 < \ln(-1), \pi + i < 4 + i, x < i: unevaluated before, False now.
    • \frac{1}{0} = \frac{1}{0} and \ln(-1) \le \ln(-1): True before, False now.
    • 0 < i < \mathrm{Missing}: Missing before, False now.
    • \int_0^1 x^{i\pi} dx: \frac{1}{1+i\pi} with the condition 0 < 1 + i\pi before, \frac{1}{1+i\pi} now (the convergence condition is on the real part of the exponent), and the same for the other convergent integrals of a power or an exponential with a complex exponent.
    • \log_{1+i}(x) = 2: the root 2i with the condition 0 < 1 + i before, 2i now (a base that is not real has the conditions b \ne 0 and b \ne 1).
    • \left\{\frac{1}{0} > 0: 1, 0\right\} and \left\{\sqrt{-1} > 0: 1, 0\right\}: a held Which before, 0 now.
    • \sum_{n=0}^{10} M(n) with M(n) := \left\{\frac{1}{n} > 0: 1, 0\right\}: a held expression before, 10 now.

    Not changed: \frac{1}{0} = 0 is False, i = i is True, i = 0 is False, \frac{1}{0} \ne 0 is True (≠ is not part of the decision), 0 < \infty and \infty = \infty are True, and Missing keeps its Kleene reading. The methods isLess(), isGreater() and the others still answer undefined for a complex operand.

    Compiled code: the arithmetic does not change, so a pole computed at run time is the IEEE Infinity, which has an order. \left\{\frac{1}{x} > 0: 1, 0\right\} at x = 0 gives 1 on javascript and on python (with a NumPy x; a plain Python float raises ZeroDivisionError), where the interpreter gives 0; \frac{1}{x} = \frac{1}{x} at x = 0 is true there. On interval-js, \frac{1}{x} over the point [0, 0] is empty, so that branch already gives 0. A non-real value at run time already gave false (\left\{\sqrt{x} > 0: 1, 0\right\} at x = -1 is 0). A closed comparison with such an operand now folds to the value of the interpreter on javascript, glsl, wgsl and python, with or without constantFold: \left\{\frac{1}{0} > 0: 1, 0\right\} compiles to 0 (it was 1), and 0 < \frac{1}{0} and \frac{1}{0} = \frac{1}{0} to false (they were true). i < 2 compiles to false (it was a compile-time decline; i < x still is one). interval-js does not fold a comparison: 0 < \frac{1}{0} and \frac{1}{0} = \frac{1}{0} there are still 'true'. The constant pole next to a variable is the IEEE Infinity: x < \frac{1}{0} at x = 1 is true on javascript, python and interval-js, where the interpreter gives False.

  • The subtype relation of lists now reads a value in every way it can be read, so the relation is transitive and a union or a meet of list types no longer drops values. Three rules changed. The rows of a list with dimensions are read whether or not its scalar element matches the target's element: matrix<string> is a vector<collection> as matrix<integer> always was (a string is a collection, so only its rank was read before), and matrix<integer> is a vector<integer | list>; the elements of a vector<unknown> or vector<any> stay scalars, and matrix<unknown> is not a vector<unknown>. A list declared in the nested spelling answers as its flat spelling toward every target: list<vector<integer^1>^2> is a list<number>, a collection<number> and an indexed_collection<real>, as list<integer^(2x1)> is (it was a matrix only). collection<E> and indexed_collection<E> peel any number of outer axes, as list<E> does: list<boolean^(2x2x2)> is a collection<vector<boolean^2>>. The meet follows: list<string> & vector<collection> keeps the string matrices (it was vector<string>). An outer list with no dimensions admits any shape of its rows, so its flat spelling has no dimensions: list<vector<integer^3>> is a list<integer> and a collection<integer> but not a matrix<integer> (its missing length was read as one axis of any length, which made it a matrix although it holds a 2×2 arrangement of 3-vectors), and at a matrix<T^(MxN)> parameter it pins no length (it pinned N). A random check of 72,000 pairs of list and collection types against concrete values finds no loss (it found 18).

Resolved Issues​

  • Factor merges equal factors into a power and finds every copy of a repeated root:

  • Factor((x^2-1)(x+1)) is (x-1)(x+1)^2; it was (x-1)(x+1)(x+1).

  • Factor(x^3+3x^2+3x+1) is (x+1)^3; it was (x+1)(x+1)(x+1).

  • Factor(x^5-x^4-2x^3+2x^2+x-1) is (x-1)^3(x+1)^2; it was (x^3-x^2-x+1)(x-1)(x+1).

  • Factor(x^4-2x^2+1) is (x-1)^2(x+1)^2; it was (x^2-1)^2.

  • A rational root is used in lowest terms, so Factor(4x^4+12x^3+x^2-12x+4) is (2x-1)^2(x+2)^2; it was (x+2)(x-\frac{1}{2})(2x-1)(2x+4).

With A and B matrices, Factor(A·B·A) keeps the product as written: matrix factors are not merged. PartialFraction now decomposes these denominators when they are given expanded, also with a negative leading coefficient (1/(-x^3+3x-2)). Before, it returned them unchanged.

  • A Multiply declared not commutative (ce.declare('Multiply', { commutative: false }, { extend: true })) removes its factors 1: a·1·b is a·b, and Factor(a·b·a) is a·b·a. They were kept, and Factor(a·b·a) gave a·1·b·1·a·1. No other operand moves, and no other simplification is applied to such a product.

  • Result types of broadcast arithmetic over nested lists hold their values. Rows of different lengths are no longer typed matrix: Sin(L) with L: list<list<integer>> := [[1, 2], [3]], and Sin([[1, 2], [3]]), are list<list<number>> (they were matrix and list<number>). A nested list of points plus a number (L + 0.5 with L: list<list<tuple<integer, integer>>>) is refused as an error, as a flat list of points is. Rows of points beside numbers keep point cells for Divide, Power, Mod, Root, Log, Round, Remainder and Arctan2, with the rank of the deepest operand (Power(NQ, V) is list<list<tuple<number, number>>>, was list<number>), also when the numbers can be absent; a divisor cell that may be zero gives number coordinates. On the derive route, Subtract of a nested list is no longer one level too deep, and Divide of a nested list of points keeps its points.

  • Subtype relation: matrix<T> is a subtype of broadcastable<T>, and couldMatch no longer answers false for pairs that share a subtype (tuples with an any slot against indexed_collection, tuple against tuple<any>, record{a: any} against dictionary<integer>, a flat list against a nested list such as list<integer> against list<list<integer>>, which a matrix matches both, and others). An operand typed with an alias is unfolded before the type variables are solved: Sort(a) with a: L, L an alias of list<integer | missing>, is list<integer | missing>; it was list<unknown>. A self-referential alias (cyc = cyc | list<integer>) no longer overflows the stack.

  • Total no longer builds the union any | unknown. Zip([1, Undefined], [2, Undefined]) is typed with missing elements, as with Missing.

  • Collection operator types: FlatMap over a source with absent cells keeps the absent cell (FlatMap(l, x ↦ [x, x]) with l: list<integer | missing> is list<integer | missing>, was list<number>; with an annotated parameter, the NaN of an absent cell is in the type; a callback that can answer a list or Missing is spliced arm by arm, so FlatMap([[1, 2], Missing], x ↦ Reverse(x)) is list<integer | missing>). couldMatch of a record against a collection of entries compares each required field with its own key (record{a: integer} cannot match collection<tuple<"b", integer>>). Map over an alias is a list (was collection<number>). Map of an operator whose declared result is any (Map(PointX, ["a", "b"])) is list<any^2>, not a copy of the source type. Map of a function literal over any elements whose body can return a collection (x ↦ Sqrt(x)) allows a collection element. Map over mixed non-numeric elements no longer states a length. A Map over a union with a dictionary or record arm types that arm as a collection of entries.

  • The derivative of a Sum no longer reads a global symbol as the index of the sum when a function called in the sum reads a symbol with the name of the index. With k := 2 and g := a ↦ a·k·x, Σ_{k=1}^{5} g(k) is 30x, but its derivative in x was 55 (the sum of k², the global k read as the index): it is now 30, as it is for the same sum with the index j. When k has no value, the derivative was 55 too: it is now 15k. When this occurs, the derivative renames the index to a fresh name, such as k_1: the derivative of Σ_{k=1}^{n} g(k) is Σ_{k_1=1}^{n} k·k_1.

  • \int \sin x + 1 parsed to Integrate(0) + sin(x) + 1: the integrand was lost. It now parses to Integrate(sin(x) + 1). A differential inside one term of a sum or a difference now ends the integrand at that term, and the terms after it keep their sign: \int \frac{x\,dx}{2} - 1 parses to Integrate(x/2, x) - 1 (it was Integrate(x/2, x) + 1), and \int 1 - \frac{dx}{x} to Integrate(1 - 1/x, x) (the dx stayed in the integrand). A parenthesized term in such a sum (\int (\sin x\,dx) + 1) no longer becomes a tuple, and with diagnostics: true the variable of the integration is no longer reported as undeclared in the integrand of such a sum.

  • A subscripted name in a binder position is a new name, also when its base is a list. With n declared as list<number>, a subscript on n is an index: n_{0} is At(n, 0). The parser read a binder spelled that way the same way, so \sum_{n_{0}=1}^{3}n_{0} was ["Sum", ["At", "n", 0], ["Tuple", "Nothing", 1, 3]] (a sum with no index) and it evaluated to 3·At(n, 0). It is now ["Sum", "n_0", ["Tuple", "n_0", 1, 3]] and evaluates to 6, as when n is not declared. The same applies to a product index, a comprehension variable ([n_{0}^2 \operatorname{for} n_{0} = [1,2,3]]), a local of a where clause or of a ; block (n_{0} \coloneq 3; n_{0}+1 is 4, not an assignment to a function named At), a function parameter (n_{0} \mapsto n_{0}^2, f(n_{0}) \coloneq n_{0}^2, which were a parse error and an invalid function), a for … from … to … do index, a limit variable, a quantified variable, the variable of a set-builder (\{n_{0} \mid n_{0}>0\}), an integration variable (dn_{0}) and a differentiation variable (\frac{d}{dn_{0}}, \partial n_{0} in a denominator). The occurrences of the binder in its scope are the new name too. Elsewhere, a subscript on a list is still an index: in \sum_{k=1}^{3}n_{k}, the body is At(n, k), and in \frac{\partial n_{0}}{\partial x}, the differentiand is At(n, 0). A top-level assignment n_{0} \coloneq 3 is not a binder: it is still the error expected-a-symbol, as before.

  • The variable of Integrate and Limit no longer reads a symbol of the same name inside a called user function. With k := 2 and g := a ↦ a·k·x, \int_0^1 g(k)\,dk was x/3 and is now x, the value of \int_0^1 g(t)\,dt; \lim_{k\to 1} g(k) was x and is now 2x; \int g(k)\,dk was -x k^3/6 + k x k^2/2 and is now x k^2. When k has no value, \lim_{k\to 1} g(k) is now k x (it was x) and \int_0^1 g(k)\,dk is k x/2 (it was x/3). These operators inline the body of g and select the variable by its name, so the k of the body became the variable. The variable is now renamed to a fresh name first. The fresh name is not a name of the expression and not the name of a declared symbol, with or without a value. The bounds of the integral and the point of the limit keep their symbols: \sum_{k=1}^{2} \int_0^k g(k)\,dk is 5x (the bound is the index of the sum), and \int_0^{k_1} g(k)\,dk is x k_1^2. An indefinite integral whose result also holds the global k keeps the fresh name, and the printed result shows it: \int g(k)\,dk is \frac{1}{2}k x k_1^2 when k has no value (k_2 when k_1 is declared). That result is correct: the k of g and the variable of the antiderivative are different symbols. Sum and Product already gave these answers. Solve(g(k) = 1, k) and Solve(\{g(k) = 1, k \in 1..3\}) had the same capture and answered [] (no solution); they now stay unsolved.

  • Integrate and Solve inline a function that is declared with a function type and then assigned (g: (real) -> real, then g := a ↦ a·x), as they do for a function that is only assigned. \int_0^1 g(t)\,dt stayed unevaluated and is now x/2. With g := a ↦ 2a + x, Solve(g(t) = 1, t) stayed unevaluated and is now [(1 − x)/2].

  • A recursive user function is no longer inlined by Integrate, Limit and Solve. With r(x) := r(x − 1) + 1, \int_0^1 x^2 + r(3)\,dx ran for about 11 seconds and ended with a stack overflow (RangeError). The call now stays as it is, and its evaluation stops at the recursion limit with a CancellationError at once, as it already did for a function declared with a function type.

  • An element read over a collection whose element type is any, or over a symbol declared any or value, is typed any. It was unknown, and arithmetic reads an unknown operand as a number: with P: any (or P: list<any>), P[i] was unknown and P[i] - P[j] was broadcastable<number>, although P can hold points and (3, 4) - (1, 1) is (2, 3). Now P[i] and P[i] - P[j] are any. This applies to At (a single index, a gather, several indices, a dictionary key, a dynamic index into a tuple or a record with an any field), First, Second, Third, Last and Field (D.a with D: dictionary<any> is any, was unknown). A function of such a read is any too: Sqrt(P[i]), Sin(P[i]) and h(P[i]) with h: (number) -> number were broadcastable<number>. A symbol whose type is inferred from its uses is not changed: with P undeclared, P[i] is still unknown and P[i] - P[j] still makes P an indexed_collection<number> (and a read of Rest(P), Take(P, 2), Sort(P), … is unknown as well). A bare list (a synonym of list<unknown>) still gives unknown. A tuple of such reads still compiles: (P[1], P[2]) with P: list<any> or P: collection<any> | tuple compiles to JavaScript, since a list element is never Nothing.

  • Sum and Product of terms typed any or value are typed any. They were number: with P: any := [(3, 4), (1, 1), (0, 0)], Sum(P[k], k, 1, 3) and Sum(P) evaluate to the point (4, 5).

  • A read of Take(P, 2), Rest(P), Drop(P, 2), Most(P) or Reverse(P) with P undeclared is unknown. It was character | missing, the element of the string option of the type list<unknown> | string, so Rest(P)[i] + 1 was an incompatible-type error.

New Features​

  • broadcast() and broadcastAsync() of a javascript result take shared inputs and write several values per cell (Tycho row 416). Two additions, for a kernel that reads the field values around a cell and writes several numbers per cell (the corners and edges of a 3-D implicit surface):

    • A shared input is bound once for the whole batch and read by index in the expression: with F: list<number> and i: integer, F_{i+1} over { i: Float64Array [0, 1, 2], F: [1, 2, 3, 4] } gives [1, 2, 3]. A variable whose declared type is a list is shared; the new shared option names any other one ({ shared: ['G'] }). A shared input is a plain array of numbers or a Float64Array, is not a column of the cells layout and not an axis of the grid layout, and goes to each worker of the pool once per broadcastAsync() call instead of once per slice. A variable read as a list was refused before ("cannot be broadcast"); a plain array for any other variable throws.
    • An expression whose type is a tuple of two or more elements that can be numbers (an element typed unknown, such as an undeclared symbol, counts) writes that many values per cell into one output array: (i, 2i) over { i: Float64Array [1, 2, 3] } gives [1, 2, 2, 4, 3, 6]. The result reports the count as broadcastOutputs, which a stored result keeps for createJavaScriptRuntime().loadBroadcast() and the pool's workers; the new outputs option overrides it. An element missing from an array answer is NaN, and a non-array answer or a thrown cell is NaN in every slot. done of broadcastAsync() is still a count of cells.

    An interval-js result throws a TypeError for either option.

  • The grid of the global option of FindFit now has a point in every basin of a frequency (Tycho row 407). The search sampled a searched parameter at a fixed number of points (400 for one parameter, 40 × 40 for two), so a wide range for a frequency found the best fit only when a point fell near it by chance. With a cos(b x + c) + d x + e on 200 points over x ∈ [0, 10] (true b = 2.2), b over [0.3, 120] and c over [0, 2π] gave a sum of squared residuals of 390 at b = 0.3; it now finds b = 2.2 (about 1e-27). Three changes:

    • A parameter that multiplies a variable inside Sin, Cos, Tan, Cot, Sec or Csc (b in cos(b x + c)) gets grid points π / (rate · span of x) apart (half that for Tan and Cot), up to 4,000 points, in every angular unit (ce.angularUnit). A parameter whose phase rate is not a constant (cos(b² x)) and a non-periodic model keep the previous grid.
    • A phase that is only added to the argument of a Sin or Cos multiplied by a linear amplitude (c in a cos(b x + c)), searched over at least one period, is solved by least squares with the linear parameters, since a cos(b x + c) = p cos(b x) + q sin(b x). It is then no longer sampled, and the search over b and c costs as much as a search over b alone. The fitted phase is in its range. Otherwise two searched parameters share a budget of 8,000 grid points.
    • A range can give the grid step as a third element: {global: {b: [0.3, 120, 0.05]}}. It sets the grid of the scan that solves the linear parameters; when the model is not linear in the other parameters, the solver still runs from each point of the coarse grid (24 points, or 6 × 6). A step on a phase keeps that phase on the grid instead of solving it by least squares. A step that is not a finite positive number is an unexpected-argument error, as is a pair of steps that asks for more than 8,000 points of the grid of that scan.

    With more than 300 data rows, the grid is scanned on a stratified sample of 300 rows (the same rows for the same data, not a regular sub-grid, so that an alias of the true frequency does not fit the sample), the 24 best grid points are solved again on every row, and the 6 best of those are refined on every row. When the deadline of the evaluation expires during the scan of the sample, the record (timedOut: True) has the best scanned parameters and residualNorm: NaN, since their residual over every row was not computed. On 1,000 rows with b over [0.3, 628] the search took about 410 ms and missed the fit (residual sum 1,947); it now takes about 260 to 330 ms and finds it. On 200 rows the deciding cases take about 45 to 70 ms (they took about 90 to 100 ms).

  • FindFit with the global option runs its grid scan on the engine's worker pool on the asynchronous route. When the engine has a kernel pool (ce.workers, see createKernelPool()), expr.evaluateAsync() sends the grid points of the scan to the workers in one slice per worker; the refinements by the solver stay on the calling thread. Each worker solves its points with the code the calling thread uses, on the same numbers, so the record is the record of evaluate(). The pool is used only when the model and the derivatives the scan reads compile to JavaScript that a worker can run (a model with a function that does not compile, such as LogGamma, runs on the calling thread), and only for a model that is linear in the parameters that are not searched (otherwise each grid point is a solver run, on the calling thread). A worker that fails or refuses the problem is replaced by the calling thread for its points, with the same result. The engine's iterationLimit applies on the workers too. An abort of the signal of evaluateAsync() rejects the evaluation with a CancellationError, whatever the reason given to abort(); the fit on the calling thread stops within a data row, and a slice that a worker runs finishes there and its result is dropped. On the asynchronous route, with or without a pool, the data operand is evaluated with evaluateAsync(), so data from an operator that has only an asynchronous handler is read. evaluate() does not use the pool. Measured with a cos(b x + c) + d x + e (benchmarks/workers/fit-parallel.mts, load average 6 on 18 cores): 1,000 rows with b over [0.3, 628] take 62 ms with 8 workers and 76 ms with 4, against 230 ms for evaluate(); the 200-row cases take 13 to 23 ms with 8 workers, against 34 to 42 ms.

0.159.0 2026-10-20​

Behavior Changes​

  • Absence checks treat both numeric markers alike: IsMissing(Indeterminate) is now True, and Indeterminate ?? 5 returns 5, as for NaN. Exact arithmetic and reducers still produce Indeterminate where they did before. Nothing passes through fallbacks and is not missing data.

  • Coalesce(x, RuntimeError("absent")) retains the successful type of x instead of becoming never. Coalescing removes only whole-value absence: a list's missing cells and a possible Nothing result remain in its type. Compilation refuses absence tests that might confuse Nothing with missing data until the target can represent that distinction.

  • LaTeX: an authored \mathrm{Nothing} (or \operatorname{Nothing}, also in braces or in \smash{…}) is an operand. Before, the parser skipped it, as it skips a presentational command, which lost the argument and broke the comma list around it: \mathrm{If}(x > 1, \mathrm{Nothing}, x) was If(Error(incompatible-type), x) and is now If(x > 1, Nothing, x), as on the ce.box route. f(a, \mathrm{Nothing}, b) gives the same arguments as f(a,,b).

  • LaTeX: a style switch (\displaystyle, \large, …), \color{…}, a strut or an invisible box (\phantom{…}, \vphantom{…}, \hphantom{…}) between an operand and an infix operator no longer changes the expression: x \displaystyle + 1 was the product x·1 and is now x + 1; x\vphantom{y} + 1 was x·1 and is now x + 1; a \cdot b \displaystyle + c is ab + c. The style of such a switch is not kept. The body of a switch now ends where the expression that holds it ends: x \displaystyle y + 1 was x·(y + 1) and is now x·y + 1, and 2 \displaystyle \frac{1}{2} + 1 was 3 and is now 2. A switch at the start of a group still applies to the whole group ({\displaystyle x+y}).

  • LaTeX: \smash{x} gives x; before, its content was discarded (\smash{x}+1 was 1). The option of \smash[b]{…} and of \placeholder[…]{…} is read as text, not as math.

  • LaTeX: an element of a comma list or a matrix cell that holds only a presentational command (f(a, \phantom{x}, b), \begin{pmatrix} a & \phantom{0} \\ c & d\end{pmatrix}) is an empty element. Before, the list ended there (f((a,), b)), and the matrix lost its row break. \phantom{y} x, with a space, was an unexpected-token error and is now x.

  • LaTeX: a keyword that does not apply is text, as a word that is not a keyword is. x \text{where} (no binding after where), x \text{for} y (no comprehension or condition after for) and x \text{if} (no then) were a Sequence of x and an unexpected-operator error; they are now x followed by the text, as x \text{hello} is. \operatorname{where} and \mathrm{for} that do not apply are symbols, as \operatorname{foo} is. Such a keyword in a group no longer breaks the group: \{x \text{where}\} is a set and \frac{x \text{ where }}{2} a fraction. A statement keyword that applies after an operand starts another expression for the \text spelling too: x \text{return} y was an error and is now x followed by Return(y), as x \operatorname{return} y already was. The input after such a statement is read too: a \text{return} b \text{return} c was a followed by Return(b), without c, and x \operatorname{break} y lost y. The same applies after a prefix operator that ends an operand: x \neg y \neg z lost Not(z), and x \lnot y ) dropped the ) with no error; it now reports an unexpected-delimiter error, so that input is no longer valid.

  • LaTeX: a keyword operator with nothing after it has a missing right operand, as the symbol operators have: x \text{and} is And(x, Error("missing")), as x \land is. The right operand was Nothing, so x \text{and} became And(x) and the error was lost. The same applies to or, iff, if and only if and such that.

Resolved Issues​

  • Compiled GammaRegularized(a, z) is correct for a non-positive order a, as .N() is (#457, contributed by enumeratio). With a and z declared real, compile(GammaRegularized(a, z)).run({a: -1.5, z: 0.001}) was NaN and is now 8894.8453395772…. At a non-positive integer a the compiled value is 0 for z > 0 and NaN at z = 0, as the interpreter answers; at z = 0 and a negative non-integer a it is ±Infinity by the sign of Γ(a). The kernel works in logarithms at a large negative order, so GammaRegularized(-150.5, 120) is -7.65e-105, not 0. A value outside the double range is ±Infinity, or a signed 0, as the IEEE rules give it. The machine kernels were also made more accurate in related places, each checked against mpmath:

    • The Python target gives the same values. GammaRegularized(a, z) lowered to scipy.special.gammaincc, which is nan for every a < 0, and the two-operand Gamma(s, z) refused to compile for a constant s ≤ 0. Both now use a helper that keeps SciPy for a positive order and computes a non-positive order itself; the helper also fixes Gamma(200, 1000), which overflowed to inf (its value is 6.3e162). compileLambda() cannot hold the helper, as for Power. A lambda keeps the inline lowering only where it is correct: GammaRegularized with a constant order of at least 1e-20, and Gamma(s, z) with a constant order from 2.5e-4 to 7.5. Any other order fails with "use compileFunction"; before, such a lambda gave nan or a wrong value.
    • Gamma, Digamma, Trigamma and ln|Γ| near a negative integer: the reflection formula now takes the sine of the exact remainder, so Gamma(-10.000000001) has at least 12 correct digits instead of 7. The interval enclosure of Gamma(-1.000001) is under 1e-12 wide (relative), not 3.5e-9.
    • GammaRegularized(a, z) for |a| ≤ 1/2 and Gamma(s, z) for -1 < s < 1/2 and a small z: GammaRegularized(1e-9, 0.99) had five correct digits, and Gamma(-0.9999, 0.001) was NaN.
    • Binomial(n, k) for an n close to a negative integer: Binomial(-51.999999999999986, 87) was 0 (its interval enclosure was [0, 0], which did not hold the true value -2.1e38), and Binomial(-426.1545291590769, 635.8454312348206) was -Infinity, not -1.18e305.
  • Log(1, base) threads over a list of bases instead of folding to the scalar 0: Log(1, [-10, e, 27]) was 0 and is now [0, 0, 0], as Log(8, [2, 4]) is [3, 3/2]. The Log(1, b) = 0 fold now applies only to a base that cannot be a collection, so a symbol declared real | list<real> is not folded either. A tuple base threads, as it already did for Log(8, (2, 3)): Log(1, (2, 3)) is (0, 0). A set base is an incompatible-type error, as it already was for Log(8, Set(2, 3)), where it was the scalar 0 before (#455, contributed by enumeratio).

  • LaTeX: a chain of \text keyword operators such as a \cdot b \text{and} a \cdot b \text{and} … took time that grows exponentially with the number of keywords (14 keywords: about 0.2 s; each two more: about 3.4 times longer). The parser now compares the braced text with the keywords instead of parsing the right operand to find out whether the keyword matches.

  • LaTeX: a chain of \text{ for } or \text{ where } clauses that do not apply (x_0 \text{ for } y_0 \; x_1 \text{ for } y_1 …) took time that doubles with each clause (12 clauses: about 0.3 s). Two dictionary entries read \text{for}, and each one parsed the rest of the input. The parser now calls the handler of a keyword once at each position, and keeps the result when it declines; 64 clauses take about 0.5 s. A chain of \text{for} clauses with \text{from} … \text{to} … \text{do} … at its end took about 3.7 s for 16 clauses (each four more: about 15 times longer); it takes about 0.06 s for 16 clauses and 0.5 s for 32. A chain of \text{for all} clauses is faster too (10 clauses: 1.7 s, was 21 s), but still grows exponentially.

  • The derivative of a Sum follows a call in a term or a bound into the body of the called function (Tycho ledger row 405). With g := a ↦ a·x, D(Sum(g(k), Limits(k, 1, 5)), x) was 0: the rule read only the names written in the term, and x is not one of them. It is now 15, the value that the derivative of each call (D(g(2), x) is 2) gives. A bound that calls a function whose body reads the variable (N := a ↦ ⌊a·x⌋, Limits(i, 1, N(1))) depends on the variable, so the derivative stays unevaluated, as for the bound ⌊x⌋; it was differentiated term by term. A function passed as an argument (G(h, k) with h a function) is followed too. A call of a function that is not pure (its body draws a random number) is not evaluated to find its body, and a call of a function whose body is not known that way is taken to depend on the variable: a bound with such a call keeps the derivative unevaluated.

  • The derivative of a call of a user function no longer reads an assigned value of its variable. With h := a ↦ a·x² and x := 5, D(h(k), x) was 0: the body of h was found by evaluating h, which gave 25k. The body is now taken from the function literal, with the arguments in place of the parameters, and is not evaluated, so D(h(k), x) is 2kx with and without a value of x. The derivative follows the name of its variable into the body of a called function in both cases; Integrate, Limit, Solve and Sum keep the rule of 2026-09-21, under which their variable does not reach into a called function. A free symbol of the body that is not the variable still reads its value: the derivative of q in its parameter, with q(a) := a x² and x := 5, is 25.

  • A parameter used as the name of a called function now takes a function argument in a derivative. With G := (f, a) ↦ f(a), D(G(h, k), x) stayed unevaluated, because the body of G applied to wildcards was an Error; it is now the derivative of h(k). D(G(Sin, x), x) is cos(x), and a function literal argument is applied: D(G(t ↦ t·x, k), x) is k. The derivative of Apply(t ↦ t·x, k) is k; it was t * Apply(Derivative("Apply", 1, 0), (t) => t * x, k), with the parameter t free in it. A recursive function (f(x) := x·f(x−1), or two functions that call each other) is not expanded: its derivative stays Apply(Derivative(f, 1), x), as before. A function composed with itself is expanded: with h := a ↦ a·x², D(h(h(x)), x) is 5x⁴. A call in an argument is never taken for the parameter (G(h, f(x)) is h(f(x))), and a function with no body passed as an argument keeps its own name (D(G(u, x), x) is u′(x)).

  • The derivative of a power, an exponential and the other operators sees a variable that a call reads in the body of the called function. With g := t ↦ t·x, D(g(k)², x) and D(e^{g(k)}, x) were 0: the rules tested only the names written in the operand. They are now 2k²x and k·e^{kx}.

  • On the interval-js target, a negative base under a float exponent that the interpreter reads as a rational with an odd denominator, but that is not the double nearest to that rational, has its real root (Tycho ledger row 406). x^{0.4545454545454545} (16 digits, read as 5/11) over [−1.1, −0.9] was empty, while evaluate() and the javascript target give −1 at x = −1; it is now [−1.0443, −0.9532]. The same holds for x^{0.6000000000000001}, a negative such exponent, and a root index such as \sqrt[2.2000000000000006]{x}. The new routine _IA.powRealBranch(x, e, oddNumerator) encloses ±|x|^e with the float e, the value the interpreter computes. An exponent that is the double of its rational (0.4, 1/2.2) still uses _IA.powRational.

  • A tuple with an absent slot is accepted as the source of Map, Filter, Reduce, Fold, Scan, TakeWhile, DropWhile, FlatMap, Any, All, MaxBy, MinBy, ArgMax, ArgMin, Dedup and Differences, as a list with an absent element already was. Map(x ↦ x + 10, t) with t: tuple<integer, missing> was an incompatible-type error; it is now typed list<number>. An alias of list<integer | missing> is accepted there too. The result of these operators and of Take, Reverse, Sort and Unique over a source that can be absent as a whole keeps its absent elements in its type: with w: missing | list<integer | missing>, Dedup(w) was typed list<number> | missing and is now list<integer | missing> | missing, and MaxBy(w, …) is missing | number. Dedup over a tuple or a list with a stated length is typed list<…>: Dedup([1, 1, 2]) was typed vector<integer^3>, which does not hold its value [1, 2].

New Features​

  • Batch evaluation of compiled functions. A javascript or interval-js compilation result has broadcast(inputs, options): the inputs of every cell at once, one Float64Array per free variable (a lo, hi pair per cell for interval-js) or one number for every cell, and one Float64Array of results: one value per cell, or a lo, hi, kind triple per cell with the INTERVAL_KIND codes (interval, empty, entire, singularPole, singularJump, partial, boolTrue, boolFalse, boolMaybe, error). A grid layout takes the arrays as the axes of a regular grid, and out is a caller-owned output array. Cell for cell, the value is the one run() gives; the conversions run once per batch instead of once per cell, which measured 20 % faster than run() per cell on the main thread.

  • Worker threads. broadcastAsync(inputs, options) runs a batch on a pool of worker threads when the host gives the engine one (ce.workers = createKernelPool({ create, size, prewarm })), and inline in slices between yields to the event loop otherwise or when the batch is too small to pay for the transfer. It takes a signal, a deadline in milliseconds and a chunk size, and resolves to { out, done }, the output and the number of leading cells that are valid. The worker is the new entry @cortex-js/compute-engine/kernel-worker, one self-contained file that holds the compiled-code runtime and the interval runtime and no engine; it rebuilds each compiled function from its text and runs the same loop as the main thread. Measured on an 18-core machine: an implicit plot of 512×512 interval cells takes 175 ms inline and 34 ms on 8 workers. The engine creates no worker on its own. Design and measurements: docs/plans/2026-10-20-worker-kernel-pool.md.

  • The interval runtime. createIntervalRuntime() from @cortex-js/compute-engine/interval rebuilds a stored interval-js compilation result with no engine behind it, as createJavaScriptRuntime() does for javascript code; the result now carries runtimeVersion, preambleOnce and preamblePerCall. Both runtimes gain loadBroadcast().

0.158.0 2026-10-20​

Behavior Changes​

  • A read past the end of a declared numeric list that holds no element is NaN, taken from the declared element type. With E: list<integer> assigned [], First(E), Last(E), At(E, 5) and E[1] were Missing, which their type integer | nan does not hold; they are now NaN, as a read past the end of a non-empty numeric list already was. A list of strings, an undeclared E := [] and the literal First([]) stay Missing.

  • A lazy view keeps a Nothing result in its own display, and erases it when it becomes a list. With m = Map(x ↦ If(x > 1, Nothing, x), [1, 2]), m.toString() was [1] while m counted 2 elements and m.at(2) was Nothing; it now prints [1, Nothing]. The same holds for Tabulate, Table, Scan and Iterate. A list built from the view erases Nothing as a list literal does, and its count agrees: ListFrom(m), m.evaluate({ materialization: true }), [...m, 9], ListJoin(m, [9]), Sort(m) and Tally(m) hold only 1, and Length(Sort(m)) is 1 (it was 3). A set-kind view does not count Nothing: Map(x ↦ If(x > 1, Nothing, x), Set(1, 2, 3)) has 1 element.

  • An overloaded function now accepts a symbol declared error that has no value, as a function with one signature does. With ce.declare('e', 'error') and ov declared ((number) -> number) & ((string) -> string), ov(e) is valid, typed number | string, and evaluates to ov(e). Before, it was the error "expected number | string, got error", while g(e) with g declared (number) -> number was valid. The same applies to a call typed error only by its declared result type (h(2) with h declared (number) -> error), and to an overload with a generic arm (((string) -> string) & ((T) -> T where T: number), also typed number | string). An arm whose parameter is a collection (list, tuple<…>) or a function still refuses such an argument. A function with several clauses selects no clause for it: with f(a: number) = a + 1 and f(a: string) = "s", f(e) stays f(e); it was the no-matching-clause error. A clause whose parameter is typed error or any is still selected for it. The library operators that have a string form as well as a collection form (Reverse, Sort, Take, Drop, Slice, Unique, Most, Rest, RotateLeft, RotateRight, Permutations, Combinations, SlidingWindow, RandomSample, RandomShuffle, DeleteAt, StringSplit, Chunk) now accept such an argument through their string form: Reverse(e) is valid, typed string, and evaluates to Reverse(e); it was refused. A parameter typed unknown now accepts such an argument on every route: a library operator with the signature (unknown) -> unknown and a function literal (a: unknown) ↦ a refused it.

  • A valid call of an overloaded function where no arm is selected is typed by the arms that the call can take at run time. With r declared real and G declared ((T) -> T where T: integer) & ((string) -> string), G(r) is typed integer, as with the ground form ((integer) -> integer) & ((string) -> string). It was typed string.

  • A sum or a product written with no bounds (\sum_k f, \prod_k f) runs over k = 1, 2, 3, … with every method: evaluate(), N(), simplify() and the derivative give the same result as for \sum_{k=1}^{\infty} f. Only N() did this before; evaluate() and simplify() kept the sum as it was. Now \sum_k \frac{1}{k^2} evaluates to \frac{\pi^2}{6}, \sum_k x^k to \frac{x}{1-x} when |x| < 1, \sum_k \frac{x^k}{k!} to e^x - 1, \sum_k k to \infty and \prod_k (1 + \frac{1}{k^2}) to \frac{\sinh(\pi)}{\pi}. \sum_k y stays as it is under evaluate() and simplify(), as \sum_{k=1}^{\infty} y does: its value depends on the sign of y. The derivative of \sum_k x^k is \frac{1}{(1-x)^2} when |x| < 1. The expression keeps its form with no bounds (\sum_{k}y is still written \sum_{k}y). A sum or a product with one bound only (\sum_{k=1} f) still stays unevaluated with every method.

  • A symbol whose name has a superscript part (__) is written in \operatorname{}, and reads back as the same symbol. Before, x__a was written x^{a}, which reads back as the power Power(x, a), and aleph__plus was written \aleph^{+}, which reads back as PseudoInverse(aleph). Now x__a is \operatorname{x^{a}} and aleph__plus is \operatorname{\aleph^{+}}. The superscripts and the subscripts are written in the order of the name, with a brace group where the order or valid LaTeX needs one: x__a_1 is \operatorname{x^{a}_1}, x_1__a is \operatorname{{x_1}^{a}} (it was x^{a}_1, the same as x__a_1), and two superscripts are nested, R_doublestruck__0__plus is \operatorname{{\mathbb{R}^0}^{+}} (it was \mathbb{R^{0,+}}, which does not parse). A style on the base stays on the base: x_bold__a is \operatorname{\mathbf{x}^{a}} (it was \mathbf{x^{a}}, which reads back as x__a_bold). A name with a base of more than one letter is written in \operatorname{} too: mass__earth is \operatorname{mass^{earth}} (it was \mathrm{mass^{earth}}, which also read back).

  • A wildcard name with a style or an accent reads back as the same name: the \_ is inside the style. _x_bold is \operatorname{\mathbf{\_x}} and _x_hat is \operatorname{\hat{\_x}} (they were \operatorname{\_\mathbf{x}} and \operatorname{\_\hat{x}}, which do not parse), and _x_1_bold is \operatorname{\mathbf{\_x_1}} (it was \operatorname{\_x_{\mathbf{1}}}, which reads back as _x_one_bold).

  • A name with a style on its base and a style after a subscript of digits reads back: x_bold_1_bold is \mathbf{\mathbf{x}_1} (it was \mathbf{x}_{\mathbf{1}}, which reads back as x_bold_one_bold). A name with two such subscripts is written in \operatorname{}: x_1_bold_1_bold is \operatorname{\mathbf{\mathbf{x_1}_1}}.

  • A symbol whose name has a subscript part that is a symbol (plus, minus, star, dag, circle, …) is written in \operatorname{}, and reads back as the same symbol. Before, x_plus was written x_{+}, which reads back as Subplus(x); x_1_plus was {x_1}_{+} (Subplus(x_1)), and x_a_dag was {x_{a}}_{\dag}, which is a parse error. Now they are \operatorname{x_{+}}, \operatorname{{x_1}_{+}} and \operatorname{{x_{a}}_{\dag}}. x_star is \operatorname{x_{\star}} (it was x_{\star}, which also read back). The operators do not change: Subplus(x) is still x_+. The parser reads \circ in a name as circle, so x_{\circ} is the symbol x_circle (it was a parse error).

  • A unicode escape in a symbol name stays in the part where it is: x_a____002012 (the parse of x_{a\unicode{"2012}}) is written x_{a\unicode{"2012}}. It was \operatorname{x\unicode{"2012}_{a}}, which reads back as x____002012_a. A subscript that starts with an escape, x_____002012, is written x_{\unicode{"2012}} (it was x^{,}_{002012}, which does not parse).

  • A name with the deg accent reads back: the parser reads \degree in the body of \mathrm{} or \operatorname{} as the accent, so \mathrm{x\degree} is the symbol x_deg (it was a parse error). Outside of a name, x\degree is still Degrees(x).

  • A subscript of digits with only the upright style is written without the style: x_1_upright is written x_1 (it was x_{\mathrm{1}}, which reads back as x_one). No spelling reads back as x_1_upright, and x_1 is the nearest name.

  • The term of Table is a function body: a name that a branch of the term assigns is read-before-declaration in a cell where the branch did not run (Table(Block(If(k = 2, h ≔ (z ↦ 2z)), h(k)), (k, 1, 3)) is [Error(…), 4, Error(…)]; it was [h(1), 4, h(3)]). The term of Sum and Product keeps its value from one index to the next, as before.

  • An assignment to a loop variable in the loop body changes it for the rest of that iteration only, and is no longer the scope effect: function g() { s = 0; for k in 1..2 { k = k * 10; s = s + k }; s } gives 30 (it was refused). Compiled code does not compile such a loop.

  • Compiled JavaScript and interval code throws read-before-declaration when a function-body local is read before any assignment gave it a value (after a branch that did not run), as the interpreter reports; it gave undefined. A read that follows an assignment on every path compiles without a check, and other targets refuse such a body.

  • In an Epsil function body, an assignment to a name that no enclosing scope declares makes a local of the whole body, also when the assignment is in a branch of an if, a loop body or a do block, as in Python: function g(c) { if c { t = 1 }; t } gives 1 for g(true) and the read-before-declaration error for g(false) (both gave the symbol t). A let in a nested block still declares a local of that block only, and a function literal owns the names of its own body. epsil check reports a read before the first assignment in a nested block, and does not report a read after an if that assigns in only one branch.

  • In a program evaluated as one block, a let of a library constant name (i, e, Pi, ExponentialE, …) shadows the constant only from its statement on: with function g() { i + 1 }, then let i = 2, g() gives 1 + i on every route (the block and compiled routes gave 3). A later let of a name that is not a constant is still visible to a function defined before it.

  • A negative integer power of an operand that can be a matrix stays a power: g(x)^{-1}, with g a function that was not declared, is Power(g(x), -1). It was the quotient 1/g(x), which is the element-wise reciprocal when g(x) is a matrix. When g(x) has a matrix value, the power is now the matrix inverse: with g returning [[2, 1], [1, 1]], g(x)^{-1} is [[1, -1], [-1, 2]] (it was [[1/2, 1], [1, 1]]). This applies to the call of a function that was not declared or that returns any, to a symbol declared any, and to a value of a type such as number | matrix. In a product with a matrix the power keeps its position: A·g(x)^{-1}·A stays as it is. The negative power of a scalar (x^{-1} for an undeclared symbol or a real symbol, \sin(x)^{-1}) and of a list of numbers is still a quotient (1/x), and the negative power of a matrix is still Inverse(M) or MatrixPower(M, -n). evaluate() and simplify() of a product keep the power: 2g(x)^{-1} stays 2g(x)^{-1} (it was 2/g(x)), and with the value above it is [[2, -2], [-2, 4]]. The quotient 1/g(x) is still a quotient. The LaTeX of the power is g(x)^{-1}, which reads back as the same power: y g(x)^{-1} is written yg(x)^{-1}, not \frac{y}{g(x)}. simplify() combines the integer powers of such an operand as evaluate() does: in a product with no matrix, f(x)^2 f(x)^{-1} is f(x) and f(x)^{-1} f(x)^{-1} is f(x)^{-2}; in a product with a matrix, only adjacent powers with exponents of the same sign are combined.

  • A power with the exponent 1 is its base for any base: g(x)^1 is g(x), and (f(x)^{-1})^{-1} is f(x) (it was f(x)^1, which evaluated to \sqrt[1]{f(x)}).

  • A sum of quantities where at least one operand is a temperature with an offset (degC or degF) now converts each operand to kelvin, with its offset, and adds the values in kelvin. The result is in K. Before, the result had the unit with the largest scale and the sum was wrong: Quantity(0, degC) + Quantity(0, degF) gave -17.78 degC; it is now 47567/90 K (273.15 K + 255.372… K = 528.522… K). An exact magnitude stays exact. The rule also applies when all the operands are in degC: 10 degC + 10 degC was 20 degC and is now 566.3 K (283.15 K + 283.15 K). A difference of two temperatures is the difference of their values in kelvin: 20 degC - 10 degC was 10 degC (which reads as 283.15 K) and is now 10 K; 20 degC - 10 degF was -3.33 degC and is now 290/9 K (32.22 K). A sum of temperatures with no offset (10 K + 10 K) does not change. A scalar multiple (2 · 10 degC) and a negation (-(10 degC)) are not changed: they keep the unit degC. So 20 degC - 2 · 10 degC is 20 degC - 20 degC, that is 0 K.

  • \sum_k y and \prod_k y, a sum or a product with no bounds whose body does not use the index, now keep their indexing clause in the canonical form: Sum(y, Limits(k, Nothing, Nothing)). They were y.

  • An Epsil program that is compiled, or evaluated as one Block, follows the order of its top-level statements for the names a function body owns: in function g(k) { t = k + 1; t }, then let t = 0, then [g(1), g(2), t], every route gives [2, 3, 0]. The compiled program gave [2, 3, 3], and the block evaluation gave the scope effect error. A read of a later name, and the order of declarations inside a nested block, do not change.

  • In a function body, a read of a name before the first assignment that makes it a local of the body is the read-before-declaration error, as for a let local read before its let: function g(k) { t = t + 1; t } gave t + 1 for g(1). Each call starts with no value for such a local, a loop body keeps the local from one turn to the next within a call, and a closure reads the local when it is called. epsil check reports the read before the program runs when the order is certain from the source. A block outside every function (a host Block, or a top-level do block or loop body) is not changed: Block(x ≔ x + 1; 2x) is still 2(x + 1). A call of a local that has no value yet is the same error: function g() { u = h(1); h = (x) => x; u } gave 1. A for loop whose collection evaluates to an error stops, and the error is its value (the loop ran no turn and the error was lost). epsil check also reports a let local used before its let when the order is certain from the source, and the run does not report the same use a second time.

  • A quantity with a dimensionless unit (percent, ppm, rad, deg, grad, turn, arcmin, arcsec) compares with a number as a value: 6 percent = 0.06, 180 deg = π and 30 deg = π/6 are True, and 6 percent < 0.07 is True (they stayed unevaluated). Two exact values are compared exactly, and a float within the tolerance. isEqual(), is(), isIdenticallyEqual(), the order methods, Equal, NotEqual, Less and the other relations, Max, Min, Sort and Clamp agree (Max(6 percent, 0.05) is 6 percent). A quantity with a logarithmic unit (dB, Np) is not compared with a number.

  • In LaTeX, a numeric constant expression written before a unit is a quantity: \pi\,\mathrm{rad} is Quantity(Pi, rad) (it was a product), and so are 10^{-30}\,\mathrm{rad}, \frac{1}{2}\,\mathrm{m}, \sqrt{2}\,\mathrm{m} and 2\pi\,\mathrm{rad}. A magnitude with a variable (x\,\mathrm{m}) is still a product.

  • The reciprocal and factor fraction styles no longer put a symbol, a number or a function in parentheses. a/b was a\times(b)^{-1} and \frac{1}{b}(a); it is now a\times b^{-1} and \frac{1}{b}a. In the reciprocal style the denominator gets parentheses where the base of a power does (a sum, a product, a power: a\times(2b)^{-1}). In the factor style the numerator gets parentheses where a factor of a product does (a sum, a negative value: \frac{1}{b}(a+c), \frac{1}{b}(-a)), and a numerator that starts with a digit gets an explicit \times (\frac{1}{3}\times2). A numerator 1 is no longer written in the factor style (\frac{1}{b}, not \frac{1}{b}(1)). The default style uses the factor style for a long numerator over a short denominator, so the default output changes too: \frac{1}{2}\mathrm{StieltjesGamma}(2)(x-1)^2. Every output parses back to the same value.

  • In Epsil, an assignment in a function body to a name that no enclosing scope declares when the function is defined makes a local of the function, with a new binding at each call. function g(k) { t = k + 1; t } then [g(1), g(2)] gives [2, 3]; the definition was refused with "writes outside a function require a declared scope effect". A declaration of the same name made later is not written: with let t = 0 after the function, [g(1), t] is [2, 0], also when t is declared by a later program on the same engine or with ce.assign(). An assignment to a name declared before the function (a top-level let or assignment, a parameter or a local of an enclosing function) still writes that binding and is the scope effect. A function defined before a top-level const does not write the constant, and epsil check does not report it; a function defined before a const of the same block is still reported.

  • Or, And, Nand, Nor, Implies, Any and All do not depend on the order of their operands when an operand fails. A True operand of Or or Nor, a False operand of And or Nand, a False antecedent or a True consequent of Implies, and a deciding element of Any or All decide the answer wherever they are, also after a failed operand: Or(1/"a" > 0, True) is True, as Or(True, 1/"a" > 0) already was, And(1, False) is False, and Any([1, 1/"a", 3], u ↦ u > 2) is True (they were the error). When no operand decides, the answer is the error of the first failed operand. Evaluation still stops at the first deciding operand, but the operands after a failed operand are now evaluated, with their side effects (also Epsil && and ||). Xor and Equivalent need every operand and are not changed. ForAll, Exists and ExistsUnique over a finite domain follow the same rule: Exists(x ∈ [1, 2], …) whose body fails at x = 2 and is False at x = 1 gives the error (it was False), Exists(x ∈ [1, 2], x > n) stays unevaluated (it was False), and ForAll over {1, 2, 3} and over [3, 2, 1] give the same answer.

  • Any and All over a source that is not known to be finite (Range(1, +oo), Repeat(5)) read at most iterationLimit elements (1024 by default) and then stay unevaluated, as IndexOf does. Before, a walk with no decision did not end; a decision after the limit, such as Any(Range(1, +oo), u ↦ u > 5000), was found after a long walk and now needs a larger ce.iterationLimit. A source known to be finite is still read to its end.

  • Two quantities whose units have different physical dimensions are not equal: 6 W = 6 m and 0 m = 0 s are False, 6 W ≠ 6 m is True, and isEqual() and isIdenticallyEqual() return false (they stayed unevaluated or gave undefined). A quantity with a dimension and a number are not equal either: 6 m = 6 is False, and a quantity is never equal to a string or a boolean (6 m = "a" and 6 percent = True are False). Order has no meaning across dimensions, so Less, isLess(), Max, Min, Sort and Clamp stay unevaluated for such operands. A unit that is not in the unit table, and a dimensionless quantity compared with a number (6 percent = 0.06), stay undecided.

  • In a block written in LaTeX, k \coloneq v still declares a local k. A new keyword, let (\operatorname{let}, \text{let}), writes a declaration with a type, \operatorname{let} s\colon integer\coloneq 1, and a declaration with no value, \operatorname{let} s. A bare Assign in a block, which changes a k outside the block, is written \mathrm{Assign}(k, v) and reads back without a Declare: it was written k\coloneq v, which read back as a local, so a LaTeX round trip left the outer k unchanged. A typed Declare in a block was dropped and is written with let. let also works in a where clause and in a row of a one-column cases block. A function defined as f(x)\coloneq\left(j\coloneq x; j\right) has the block as its body; the body was a block inside a block. \mathrm{let} is now a keyword, so a symbol named let is read as one, as if and where already are. After let, = is also the assignment, as in Epsil (\operatorname{let} k = 2), and a type that is not one (\operatorname{let} s\colon\mathbb{Z}) is an invalid-type error. A name is declared once per block: x\coloneq 1; x\coloneq x+1; x is 2 (it was a variable-redeclaration error). The rows of a one-column cases block are read as a ; list is, so a k\coloneq v row declares a local k (it changed the k outside).

  • Assigning a value to a constant has a stable error code, and epsil check reports it. ce.assign() and the Assign operator still throw Cannot assign a value to the constant "c", and the error now has code: 'constant-assignment' and constantName. In Epsil, const c = 1 then c = 2 is reported before the program runs, as a static error with the code constant-assignment and a note on the const; before, epsil check reported nothing and the run reported a runtime-error whose code was the message text. The check covers a const of the program at any depth and each name of a destructuring const, wherever the assignment is, also in the body of a function defined before the const. A let, a parameter or a loop variable that shadows the constant is not reported. The run reports each assignment once: it does not repeat an assignment that the check reported, and it reports an assignment that the check could not see, such as an assignment to a library constant (Pi) or to a constant of an earlier program. The error value is ["Error", ["ErrorCode", "constant-assignment", "c"]].

  • A unit has one canonical form: its unit symbols with exact exponents, those with a positive exponent over those with a negative exponent, in the canonical order. Quantity arithmetic, Quantity, UnitConvert and the LaTeX parser give this form, so an evaluated quantity and the parse of its LaTeX are the same expression: (4 m)^(-1/2) is Quantity(1/2, Divide(1, Sqrt(m))) (it was Power(Power(m, 1/2), -1)), (m/s)^2 is m^2/s^2, and m^(2/3)·m^(2/3) is m^(4/3) (it was m^1.3333333333333333). The unit of AvogadroConstant is Divide(1, mol), and the text form writes a reciprocal unit with a negative exponent (3 m^-1).

  • Quantity arithmetic keeps the units it combines and no longer replaces them with a named derived unit: (2 V)·(3 A) is 6 A·V (it was 6 W), (2 N)·(3 m) is 6 N·m (it was 6 J), and (1 J)/(1 kg) stays J/kg. UnitSimplify still gives the named unit (UnitSimplify(6 A·V) is 6 W), and equality is not changed: 6 A·V equals 6 W.

  • A List or Set literal that keeps a failed cell ([1, Error(…), 3]) is typed list<unknown> (it was error, so a variable that held it was a scalar): with fs := [Error(…)], ListJoin(fs, [2]) is [Error(…), 2] (it was [[Error(…)], 2]), and a variable declared list accepts the value. An absent cell keeps its arm: [Error(…), Missing] is typed list<unknown | missing>.

  • A List or Set that holds a failed cell can be iterated. In Epsil, with g(x) = 1/x and xs = [1, g("a"), 3], a for loop runs one turn for each cell, and Map, Filter, a comprehension and an arithmetic broadcast give the failed cell to the body as the value of that element: map((u) => u + 1, xs) is [2, Error(…), 4]. Before, the loop ran no turn, Map returned the list unchanged, and the comprehension stayed unevaluated. Length and Count give the number of cells (3), and First, At, Reverse and Take keep the failed cell in its position. An operator that needs the value of every cell gives the error of the failed cell: Sum, Product, Max, Min, Mean and the other statistics, Sort, ArgMax and MaxBy. A comprehension guard that fails on an element keeps the error in that position, as Filter does; before, the element was removed. The same rules apply when the failed cell is a list that holds the error: Mean([[1, 1/"a"], 2]) is the error, and Map(u ↦ 1, [[1, 1/"a"], [3]]) is [1, 1]. Membership and equality give the error when the answer depends on a failed cell: 2 ∈ [1, 1/"a"] and [1, 1/"a"] = [1, 1/"a"] are the error, while 1 ∈ [1, 1/"a"] is True and [1, 1/"a"] = [2, 1/"a"] is False. The equality of two collections first checks the facts that decide the equality of valid collections (a set and a list, different counts, a finite and an infinite collection), then reads the cells only as far as needed, so [1, 1/"a"] = Range(1, 10^12) is False. IndexOf, Count with a value, Union, Intersection, SetMinus and SymmetricDifference follow the same rule. Unique, Tally and Dedup follow it when there is another element to compare: Dedup([1, 1/"a", 1]) is the error, because whether a neighbor is a duplicate depends on the value of the failed cell, while Unique([1/"a"]) is [1/"a"]. The list still carries its error (isValid is false), and it still does not compile. A Tuple or Dictionary with a failed cell is not changed.

  • A point divided by a matrix is the matrix of the point divided by each cell, as a point times a matrix is: (1, 2) / [[1, 2], [3, 4]] is [[(1, 2), (1/2, 1)], [(1/3, 2/3), (1/4, 1/2)]] (it stayed unevaluated and was typed number).

  • isGreater, isGreaterEqual, isLess, isLessEqual and the operators Greater, GreaterEqual, Less, LessEqual read the range of a declared type: with p: integer<0..>, p.isGreaterEqual(0) and p ≥ 0 are true (they were undefined). A float within the tolerance of a number is not ordered strictly against it: ce.number(1e-12).isGreater(0) is false, as isEqual(0) is true (it was true); an exact value is still ordered exactly (10^{-30} > 0).

Resolved Issues​

  • A Table whose term assigns a variable gives its list in a function body too: Apply(Function(Block(Table(Block(Assign(a, k), a), (k, 1, 3))))) gave an unevaluated Tabulate(…), and gives [1, 2, 3], as at the top level.

  • A closure created in a branch or a loop body of a function, whose own body assigns a name, is no longer refused with the scope effect error: its body keeps its own locals.

  • A numeric type with an excluded bound excludes it when a value is checked: with g declared (real<0<..>) -> real, g(0) is an incompatible-type error (it gave a value), on evaluate(), .N() and a symbol holding 0; the compiled JavaScript call gives NaN and the interval call gives empty.

  • A compiled call of a function whose parameter has a declared type agrees with the interpreter. With let g: (integer) -> integer then function g(k) { k + 1 } (or g = (k) => k + 1), the compiled g(1.5) gave 2.5 where the interpreter gives an incompatible-type error, and g(x) with x = 1.5 did the same. An argument that cannot be of the declared type now fails the compilation. An argument whose type is not proven is tested when the code runs: the JavaScript call gives NaN and the interval call gives empty for a value outside the type. The GLSL, WGSL and Python targets, which have no such test, refuse the call when the type allows only some real numbers (an integer type, a bounded real type, rational). An argument whose type is declared, or a constant, compiles with no test.

  • A product of a number and a number to a negative integer power, such as 3·10^-30, (3/7)·10^-30 or 3·2^-3, is written so that it parses back to the same expression (3\times(10^{-30}), \frac{3}{7}\times10^{-30}, 3\times2^{-3}). It was written \frac{3}{10^{30}}, which parses back as a rational.

  • A product nested in a product that has a matrix keeps the order of its factors. With A a matrix and g, k functions that were not declared, ['Multiply', 'A', ['Multiply', g(x), k(x), g(x)]], A\cdot(g(x)\cdot k(x)\cdot g(x)) and A(g(x)k(x)g(x)) were A·k(x)·g(x)·g(x), because the nested product was sorted on its own before the matrix was seen. They are now A·g(x)·k(x)·g(x), like the product written without the nesting. A negation of a nested product and a nested product divided by an integer are spliced too: A\cdot(-g(x)k(x)) is -(A·g(x)·k(x)). A product with no factor that can be a matrix has the same canonical form as before, and a juxtaposition in parentheses that is not a product (a mixed number such as A(2\frac{1}{3}), an imaginary number such as A(2i)) is read as before.

  • Parsing a nested juxtaposition in parentheses takes a time proportional to its depth. (g(x)(g(x)(…)A)A)A with 8 levels took about 7 seconds, because each level was made canonical several times.

  • The empty numerator of a product whose factors are all negative powers is written 1: x^{-1}y^{-1} in raw form is \frac{1}{xy}, not \frac{}{xy}.

  • On the glsl and wgsl targets, the product of the imaginary unit and a complex value is a rotation of that value: iz with z complex compiles to vec2(-z.y, z.x), i\tanh(z) to _gpu_cmul(vec2(0.0, 1.0), _gpu_ctanh(z)) and t + i\tanh(z) adds the two complex terms. They compiled to vec2(0.0, z), vec2(0.0, _gpu_ctanh(z)) and vec2(t, _gpu_ctanh(z)), and a shader vec2 constructor reads only the first component of a vec2 argument, so the value was (0, Re w) where i·w is (−Im w, Re w). The product with a real value (it is vec2(0.0, t)) is unchanged.

  • A negative base under a decimal exponent, or a decimal root degree, that reads as a rational with an odd denominator compiles to the real signed root on every target, as it evaluates: x^{\frac{1}{2.2}} (5/11) at x = −1 is −1 on javascript, interval-js (_IA.powRational(x, 5, 11); it answered an empty interval), glsl, wgsl and python, and \sqrt[2.2]{x} gives the same (it compiled to Math.pow, NaN for a negative x), and \sqrt[2.5]{x} (x^0.4, an even numerator) compiles to the positive real |x|^{0.4}. The compiled code uses the exponent reading of the interpreter, so an exponent such as 0.3 keeps its complex value. On interval-js the rational power is used only when the literal is the double of that rational (0.6, 1/2.2); a literal that is not (0.6000000000000001) keeps the previous lowering. In the interpreter, Root of a negative radicand under a non-integer degree is a^(1/b) as Power reads it: Root(-8, 0.5) is 64 (it was −64), Root(-8, 2.5) is (-8)^{2/5} = 2.297 (it was −2.297) and Root(-8, 2.4) is the complex principal value.

  • At with a NaN, indeterminate or infinite member in a list index gives the hole in that member's slot and reads the other members: At([10, 20, 30], [NaN, 1]) is [NaN, 10] and At([(1, 2), (3, 4)], [NaN, 1]) is [Missing, (1, 2)], the same hole an out-of-range member gives. The call stayed unevaluated. The compiled javascript _SYS.at agrees (it gave a whole-result NaN).

  • A compiled Epsil program checks a function definition against the effects that an earlier declaration of its name states: with let g: (integer) pure -> real then function g(k) { Random() }, the compilation is refused, as the interpreter refuses the definition (incompatible-type). The compiled program ran the definition.

  • A call argument that is a symbol declared without a value in the caller's scope keeps that declaration when the function body is evaluated: with g := a ↦ a·x and c := 5 in the global scope, after pushScope() and declare("c", "real"), g(c) is c·x (it was 5x), and with declare("i", "real") in the pushed scope, g(i) is i·x (it was ImaginaryUnit·x). A call frame's parent is the scope that defined the function, so the body's read of the argument found the outer c or the library i by name. A free symbol of the body still reads the definition scope, and a loop or sum index still reads by name.

  • A symbol with two or more subscripts is written with nested subscripts, so it reads back as the same symbol. The symbol x_1_2 (the parse of x_1_2, of {x_1}_2, and the canonical form of Subscript(Subscript(x, 1), 2)) is now written {x_1}_2; it was x_{1,2}, which reads back as Subscript(x, Sequence(1, 2)). Three subscripts give {{x_1}_2}_3. A name with an upright base keeps the second subscript outside the \mathrm{}: speed_1_2 is {\mathrm{speed_1}}_2 (it was \mathrm{speed_{1,2}}, which did not parse). A name with digits before a subscript keeps them in the name: x1_2 is \mathrm{x1_2} (it was x_{1,2}). A single subscript (x_1, x_{12}), a subscript with a comma list typed as x_{1,2}, and a matrix element (M[1, 2]) are written as before. A name whose base is an indexed collection does not read back in either spelling: with A a matrix, the symbol A_1_2 is written {A_1}_2, which reads back as Subscript(At(A, 1), 2) (A_{1,2} read back as At(A, 1, 2)).

  • A symbol with a style and a subscript reads back as the same symbol. \mathbf{x}_1 (the symbol x_bold_1) was written \mathbf{x_1}, which reads back as the different symbol x_1_bold; it is now written \mathbf{x}_1, and x_bold_1_2 is written {\mathbf{x}_1}_2. The parse of \mathbf{x_1}, the symbol x_1_bold, was written x_{\mathbf{1}}, which reads back as x_one_bold; it is now written \mathbf{x_1}, and x_1_2_bold is written \mathbf{{x_1}_2}. The other styles work the same way (\mathit, \mathbb, \mathcal, …). The upright style is the exception for a style after a digit subscript: x_1_upright is still written x_{\mathrm{1}}, which does not read back.

  • Two braced subscripts inside \mathrm{}, \mathbf{} or \operatorname{} are two parts of the name: \mathrm{x_{1}_{2}} is the symbol x_1_2, as x_{1}_{2} is. It was x_12. The body of such a command can also start with a brace group that has its own subscripts: \mathbf{{x_1}_2} is the symbol x_1_2_bold.

  • A sum or a product written with no bounds (\sum_k x^k, which is Sum(x^k, Limits(k, Nothing, Nothing))) is no longer read as a sum over the single point k = Nothing: \sum_k x^{k-1} evaluated to \frac{1}{x}, \sum_k x^k and \prod_k x^k to an error, and \sum_k y and \prod_k y became y. It runs over k = 1, 2, 3, …; see the entry about sums with no bounds under "Behavior Changes". simplify() keeps a sum or a product with one bound only as it is: \sum_{k=1} k gave \frac{1}{2}.

  • \sum_{k=1}^{\infty} \frac{x^k}{k!} evaluates to e^x - 1. It was e^x - x^0, which has no value at x = 0, where the sum is 0. The same applies to the sum from any lower bound above 0.

  • simplify() of a geometric series with an infinite upper bound and a free ratio keeps the sum as it is: \sum_{k=1}^{\infty} x^k simplified to \frac{x - x^{\infty}}{1 - x}, and x^{\infty} is not a value. evaluate() still gives \frac{x}{1-x} when |x| < 1. With a number ratio the closed form still applies: \sum_{k=0}^{\infty} (\frac{1}{2})^k simplifies to 2.

  • simplify() of a sum or a product over an infinite range (\sum_{k=1}^{\infty} f, or \sum_k f with no bounds) no longer puts the infinite bound into a closed form when the result depends on a symbol of unknown sign or has no value:

    • \sum_k 0 simplified to Indeterminate; it is 0, also under evaluate().
    • \sum_k y simplified to \infty \cdot y, which is wrong for y = 0; it stays as it is. A body of known sign gives \infty or -\infty (\sum_k 3 is \infty).
    • \prod_k 1 simplified to Indeterminate; it is 1. A product of a constant c is the limit of c^n: 0 for |c| < 1, \infty for c > 1. evaluate() and N() now give the same values (\prod_k \frac{1}{2} is 0; it stayed unevaluated). For c ≤ -1, and for a free body (\prod_k y simplified to y^{\infty}), the product stays as it is. The comparison with 1 uses no tolerance: an exact number is compared exactly, and a float within the engine tolerance of 1 (1.000000000001) keeps the product as it is, since the float does not tell on which side of 1 the value is. A body that is not pure (\mathrm{Random}()) is not compared, and an empty range (k from \infty to 5) is not read as an infinite one.
    • \sum_k (1 + xk) simplified to \infty, which is wrong for x < 0, and \sum_k kx to \infty \cdot x; both stay as they are. With no free symbol the closed form still applies: \sum_k (1 + 2k) is \infty.
    • \sum_{k=0}^{\infty} (-1)^k simplified to Indeterminate; it stays as it is, as under evaluate().
  • simplify() of a geometric series with an exact ratio keeps it exact: \sum_{k=1}^{\infty} (\frac{1}{\sqrt{2}})^k simplified to the float 2.414…; it is now 1 + \sqrt{2}, as under evaluate(). The finite form \sum_{k=0}^{n} (\frac{1}{\sqrt{2}})^k keeps the exact ratio too.

  • The derivative of a sum over an infinite range is no longer the series of the derivatives when that series has no closed form: the derivative of \sum_{k=1}^{\infty} \frac{\sin(kx)}{k} was \sum_{k=1}^{\infty} \cos(kx), which diverges, while the derivative is -\frac{1}{2} on (0, 2\pi). It now stays unevaluated. A sum with a closed form is differentiated through it: the derivative of \sum_{k=1}^{\infty} \frac{x^k}{k!} is e^x, and the derivative of \sum_{k=1}^{\infty} x^{k-1} is \frac{1}{(1-x)^2} when |x| < 1. explain('D') shows the rewrite of the sum to its closed form as a step before the steps of its derivative. A finite range is still differentiated term by term.

  • compile() of a sum or a product with no bounds reports the infinite upper bound, as for \sum_{k=1}^{\infty} f. It reported that the Nothing marker cannot be read as a variable. Both fall back to the interpreter.

  • simplify() of a sum or a product with more than one indexing clause no longer reads the second index as a free symbol and drops its clause: Sum(i·j, Limits(i, 1, 3), Limits(j, 1, 2)) gave 6j. It now stays as it is under simplify(), and evaluate() still gives 18.

  • Reading a variable whose value is an expression with no canonical form (r = Map(t, (x) => x * 2), with the arguments in the wrong order) no longer loops forever: it gives the same result as the expression written in place.

  • In a comprehension, an element that does not match a tuple pattern ([p + q for (p, q) in [(1, 2), 5, (3, 4)]]) is the incompatible-type error in its position, and the other elements are kept ([3, Error(…), 7]); the comprehension stopped at that element. A for loop still stops on it.

  • A trigonometric function of an angle quantity with an exact magnitude is exact: \sin(30\,\mathrm{deg}) evaluates to 1/2 (it was 0.4999999999999999), because the angle is converted with the exact factor of its unit (30 deg is π/6).

  • A comprehension whose domain evaluates to an error has that error as its value: Length, Count, Sum and an element read of it give the error.

  • An Epsil function declared with a type and then defined (let g: (integer) -> integer, then function g(k) { k + 1 }) compiles on JavaScript and interval-js and gives the interpreter's value; the compilation failed with Unknown operator \g``.

  • Compiled code refuses a named function definition that the interpreter refuses because of its effects: a body that writes a variable declared before the function without the scope effect, or a body whose effects are not in the stated effects (pure). With let t = 0 before function g(k) { t = k + 1; t }, the compiled program gave [2, 2] for [g(1), t]; it now fails with the interpreter's message, on every target.

  • A quantity whose compound unit cancels is the plain number of its magnitude: ["Quantity", 2, ["Divide", "m", "m"]] and 2\,\mathrm{m/m} are 2 (they were Quantity(2, 1), written \mathrm{Quantity}(2, 1)). A named unit (percent, rad) and a compound unit that does not cancel (in/ft) stay units.

  • A number raised to a negative integer is written as a power in LaTeX: 10^{-30} and 2^{-3} (they were \frac{1}{1\cdot10^{30}} and \frac{1}{8}), and the LaTeX parses back as the same expression.

  • isEqual(), is(), isIdenticallyEqual() and the order methods compare a quantity whose magnitude is an expression with no unknowns by its value, as Equal does: Quantity(2, m).isEqual(Quantity(1.5 + 0.5, m)) is true (it was undefined).

  • Quantity arithmetic and unit conversion of float magnitudes use the working precision: (π·(1 rad)).N() is 3.14159265358979323846 rad (it was 3.141592653589793 rad), and 1.1 m + 1.3 cm is 1.113 m. At precision = 'machine', machine numbers are used as before.

  • Two quantities with different logarithmic units are not equal: 1 dB = 1 Np was True, because both units have a nominal scale of 1.

  • A mixed number such as 2\frac{1}{3} is read only when the integer, the numerator and the denominator are exact integers: 2\frac{1.0}{3} and 2.0\frac{1}{3} are the inexact product 2/3, and a float numerator keeps all its digits when it is serialized.

  • Serializing a juxtaposition no longer changes its value when a factor-style fraction that ends in a digit comes before a fraction (\frac{1}{x}\times3\times\frac{1}{2}), or when a negative fraction follows another factor (2(-\frac{3}{4}); it was 2-\frac{3}{4}, which reads back as 5/4).

  • The subscript of a function application written as a name with parentheses ((g(x+1))_{k}, (\Gamma(x))_{1}), or of a base that ends in a superscript ((\exponentialE^{x})_{1}, (f^{\prime})_{1}), is written in parentheses, so it reads back as written. g(x+1)_{k} read back as g·(x+1)_k.

  • An argument whose value is an error gives that error at every parameter type. With let w = Ln("a"), function op(a: unknown) { a } and op(w) gave a new error, "expected unknown, got error at w". So did a parameter typed as a collection (list, tuple<…>) or as function, a parameter typed by a type variable with a bound (Conjugate(w), Remainder(w, 2), a function declared (T) -> T where T: number), and the condition of if and Which. A function with several clauses (op(a: number), op(a: string)) gave "expected number | string, got error". The callback of a collection operator gave "expected function, got error": Any(xs, w), All(xs, w), Filter(xs, w), Reduce(xs, w), Scan(xs, w), TakeWhile(xs, w), MaxBy(xs, w) and Map(w, xs). The call now evaluates to the error of Ln, with the frame of the call, as it already did for a parameter typed number or with no type, and for Sort(xs, w). An element read directly (Map(w, xs).at(1), a walk of Filter(xs, w)) is that error too. An operator that binds or reads such an operand as an expression is not changed: Integrate(w, Limits(w, 0, 1)) is still 1/2. An argument of the wrong type is still refused, and Map(w, f), with the arguments in the wrong order, still reports the wrong order.

  • A symbol declared error that has no value, and a call typed error only by its declared result type (g(2) with g: (number) -> error), are checked by the same rule at a parameter typed by a type variable as at a ground parameter. Neither holds an error value, so the call evaluates to itself. Conjugate(e) and a function declared (T) -> T where T: number applied to e gave "expected number, got error"; they are now valid, as Sqrt(e) and a function declared (number) -> number applied to e are. The condition of If and Which admits them too, as a parameter typed boolean does, and selects no branch. A collection parameter (list, or a type variable bounded by a collection) still refuses them, and so does an overloaded function, with ground or generic arms.

  • Map(f, xs) with a callback known to be a function with no known result type (f declared function), or with a symbol that holds an error, is typed with unknown elements: Map(f, [1, 2]) is list<unknown^2>. It was typed as the source, vector<integer^2>. A callback that is not declared still gives the type of the source.

  • An integer followed by a fraction is read as a mixed number only when the numerator and the denominator of the fraction are number literals. 2\frac{1+1}{3} was read as 2 + 2/3; it is now the product 4/3, as 2\frac{x}{3} is a product. 2\frac{1}{3} is still 7/3. A mixed number in raw form (["InvisibleOperator", 2, ["Divide", 1, 3]]) is written 2\frac{1}{3} with every fraction style; with the reciprocal style it was 2\times3^{-1}, which reads back as 2/3.

  • In a product that has a matrix, a factor that can be a matrix keeps its position. This covers the call of a function that was not declared (g(A), g(x)), a symbol declared any, and a value of a type such as number | matrix. Before, such a factor was sorted in front of the matrices, and the powers of a matrix on each side of it were merged: with A a 2×2 matrix, A·g(A)·A evaluated to A^2·g(A), and A^2·g(A)·A^3 to A^5·g(A). Both are wrong when g returns a matrix that does not commute with A. evaluate(), simplify() and Expand now keep A·g(A)·A. A quotient whose denominator can be a matrix also keeps its position, because the quotient is element-wise: (A/g(x))·A evaluated to A^2/g(x), A·(1/g(x))·g(x)·A to A^2, and, with B a matrix, (A/B)·A to A^2/B. Factors of a scalar type (real, integer, an undeclared symbol) still move to the front, and a product with no matrix is unchanged.

  • Expand does not distribute a product with a matrix over a sum that has a scalar term. The sum of a matrix and a scalar adds the scalar to each element, so A·(B + r) is not A·B + r·A: Expand(A·(B + r)) gave A·B + A·r, and now stays A·(B + r). A sum of matrices is still distributed, and each term keeps the order of its factors: Expand(A·(B + C)·g(x)) is A·B·g(x) + A·C·g(x).

  • In Epsil, the source range of an expression includes the parentheses of its operands. The range of (a) + (b) was a) + (b, and the range of the statement let v = (1) + (2) ended before the last ), so an error reported on that statement did not underline the last ). This applies to every operator, call, member access, index, if … else, is, loop collection, comprehension and pattern with a parenthesized operand. A single parenthesized expression (a) keeps the range of a.

  • In Epsil, an error in an operand of a call that canonicalization reorders is underlined on the right operand. With let w: any = "s", the statement let v = Ln(w) * 2 underlined 2, because Ln(w) * 2 is 2·Ln(w) in canonical form; it now underlines Ln(w). This also applies to a library operator such as Xor and to a user function marked commutative or associative: with w = Ln("a") and op commutative, op(z, w) underlined z. When the operand is not sure, as in Ln(w) + Ln(w), the whole call is underlined.

  • A function marked commutative or associative passes an error value through, as the same function without the word does. With w = Ln("a") and function op(a, b) commutative { [a, b] }, op(z, w) gave a new error, "expected unknown, got error", in place of the error of Ln. A parameter without a type is now checked as any at the call, as it is without the word.

  • In the unit argument of the siunitx commands \qty, \SI, \unit and \si, a . or a ~ between two units is a product of the units, as in siunitx. \qty{3}{m.s^{-1}} and \qty{3}{m~s^{-1}} are the quantity 3 m/s, the same as \qty{3}{m\cdot s^{-1}}, and \qty{3}{kg.m^2.s^{-2}} is 3 kg·m²/s². The . form gave parse errors, and the ~ form gave 3 per millisecond. A decimal point in an exponent (\qty{2}{m^{1.5}}) is not a separator, and a space is still not a product: \qty{3}{m s^{-1}} is 3 per millisecond, as LaTeX prints it.

  • A siunitx unit argument that is not a unit gives an unknown-unit error with the text of the argument: \qty{3}{foo} is ["Quantity", 3, ["Error", "'unknown-unit'", ["LatexString", "'foo'"]]]. It was an unexpected-command error on \qty, and the arguments were parsed as a list.

  • The derivative of a sum or a product is computed. It stayed unevaluated: \frac{d}{dx}\sum_{k=1}^{3} k x^k gave the same expression back, and now gives 9x^2 + 4x + 1. A sum is differentiated term by term when its bounds do not depend on the variable, also with a symbolic or infinite bound: \frac{d}{dx}\sum_{k=0}^{n} x^k is \sum_{k=0}^{n} k x^{k-1}. A product with integer bounds and at most 24 factors is expanded, then differentiated: \frac{d}{dx}\prod_{k=1}^{2} (x+k) is 2x + 3. A product with a symbolic bound or more factors stays unevaluated. When the index of the sum or product is the variable (\frac{d}{dx}\sum_{x=1}^{3} x), the derivative is 0. When a bound depends on the variable, the derivative stays unevaluated.

  • A fraction written with the nice-solidus fraction style reads back as the same division. ce.parse('x/10').toLatex({ fractionStyle: () => 'nice-solidus' }) gives {}^{x}\!\!/\!{}_{10}, and parsing this LaTeX gave Superscript(Error('missing'), x); it now gives Divide(x, 10). The parser reads only this exact shape as a division: {}^{a}, any number of \!, /, any number of \!, {}_{b}. A {}^{a} or a {}_{b} alone keeps its reading.

  • The base of a subscript is written in parentheses when it is compound, as the base of a power is, so the LaTeX reads back as the same expression. Subscript(x+1, 2) was written x+1_{2}, which reads back as x + BaseForm(1, 2); it is now written (x+1)_{2}. A product, a negation, a negative number, a power, a fraction and a factorial are also fenced: (2x)_{1}, (-2)_{k}, (x^2)_{1}, (\frac{x}{y})_{1}.

  • The limit at infinity of a difference of two radicals that grow at the same rate has a value: \lim_{t\to\infty} (\sqrt{t+1} - \sqrt{t}) is 0, and \sqrt{t^2+3t} - \sqrt{t^2+t} gives 1 (they stayed unevaluated). The same is true for a radical minus a term that grows at the same rate: \sqrt{t^2+t} - t gives 1/2 at +\infty, \sqrt{t^2+t} + t gives -1/2 at -\infty, and \sqrt[3]{t^3+t^2} - t gives 1/3. The limit code multiplies the difference by its conjugate, \sqrt{u} - \sqrt{v} = (u - v)/(\sqrt{u} + \sqrt{v}). A difference that grows without bound, such as \sqrt{2t} - \sqrt{t}, still gives +\infty. The limit of an absolute value at infinity also has a value: \sqrt{t^2+1} is +\infty (it stayed unevaluated) and |(1 - 2t)/(t + 3)| is 2. A limit at infinity whose value is a ratio of radicals is exact: \lim_{t\to\infty} \frac{\sqrt{2t^2+1}}{t} is \sqrt{2} and \lim_{t\to-\infty} \frac{\sqrt{3t^2+1}}{t+1} is -\sqrt{3} (they were the floats 1.414… and -1.732…).

  • A limit at infinity of the form x·f(c/x) is exact: \lim_{x\to\infty} x\ln(1+\frac{\pi}{x}) is π (it was the float 3.14159265356268, with wrong last digits), x\sin(\frac{\sqrt{2}}{x}) is \sqrt{2}, x^2(1-\cos(\frac{\sqrt{3}}{x})) is 3/2 (it was 1.4999999839369884), and x(e^{1/x}-1) is 1 (it stayed unevaluated). The limit code reads the value from the series of the function with x = 1/u at u = 0. evaluate() no longer returns a float for a limit whose value it cannot find exactly: the limit stays unevaluated, and .N() gives the float.

  • Whole-number powers of one matrix combine. With A declared matrix<2x2>, A^2 A^3 evaluates and simplifies to MatrixPower(A, 5), and A A^2 to MatrixPower(A, 3) (they stayed products, such as MatrixPower(A, 2) * MatrixPower(A, 3)). A^2 A^2 is MatrixPower(A, 4) (it was MatrixPower(MatrixPower(A, 2), 2)), and (A^2)^3 is MatrixPower(A, 6). Only positive whole-number exponents combine, and only for powers that no other matrix separates, because the product of matrices is not commutative: A^2 B A^3 stays as it is. A^2 A^{-1} stays as it is too, because A^2 A^{-1} = A is true only when A is invertible.

  • Solve answers an equation with a trig function of a periodic argument, such as sin(sin(x)) = 0. Such an equation is periodic, with the period of the inner trig function. Solve(sin(sin(x)) = 0, x) is [πt], ce.parse('\sin(\sin(x))=0').solve('x') is [0, π], and over [-7, 7] the roots are -2π, -π, 0, π, 2π (there was no answer since 0.148.0). An equation with no real root gives the empty list: cos(sin(x)) = 0 is [], because |sin(x)| ≤ 1 < π/2. The solver uses each period of the outer function that the inner sin or cos reaches: sin(4·sin(x)) = 0 holds for 4·sin(x) = 0, π and -π, and the roots of sin(x) = -π/4 are now in the answer. A scan of one period with an interval proof checks each list. tan(4·sin(x)) = 1 and cot(4·sin(x)) = 1 are answered the same way, with the half-turn period of tan and cot. When the inner function reaches so many periods of the outer one that more than 16 roots would be added, there is no answer, and it comes quickly (sin(200·sin(x)) = 1/3, sin(1000·sin(x)) = 0). A trig function of an argument that is not periodic (sin(x²) = 0, sin(eˣ) = 0) still gives no answer.

  • For an unknown declared real, a root written with arcsin or arccos of a constant in [-1, 1] is kept. With x declared real, sin(x) = arccos(9/10) gives [arcsin(arccos(9/10)), π − arcsin(arccos(9/10))], and cos(sin(x)) = 9/10 gives its four roots; both gave no answer, because the type of arcsin(c) is complex when it does not use the value of c. sin(x) = 2 still gives [].

  • Solve(cos(x)^2 = 1/9, x) is [πt − arccos(1/3), πt + arccos(1/3)] (it stayed unevaluated). The roots -arccos(-1/3) and arccos(1/3) − π are in the same family, but their difference did not evaluate to 0: the solver now uses arccos(-c) = π − arccos(c) to compare them.

  • expr.solve() no longer lists one root twice for a periodic equation. It gives one root of each family in one period, but sin(x) = -1 gave [-π/2, 3π/2] and 3cos(x) + 3 = 0 gave [π, -π], two roots that differ by the period 2π. They now give [-π/2] and [π]: of two roots that differ by a multiple of the period, the first one is kept. expr.explain('solve') gives the same list, with a step that names each root it removes.

  • subs() reads only the own properties of the substitution object. A symbol named like a property of a JavaScript object, such as constructor or toString, was replaced by that inherited property: ce.box(["Add", "x", "constructor"]).subs({ x: 1 }) gave "Undefined" + 1. It now gives constructor + 1.

  • A comprehension or a lazy collection that calls a parameter or a local function applies that function after it leaves the function or block that made it: function g(f) { [f(x) for x in [1, 2]] } applied to (a) => a + 1 gives [2, 3], and so does do { let f = (a) => a + 1; [f(x) for x in [1, 2]] }; both stayed unevaluated. A map or filter callback that calls a parameter works the same way, also when the collection is passed to a function that has a parameter with the same name (it used that other function). A call of a function parameter inside a Sum, a Product or a comprehension in a function body calls the parameter: (g) ↦ Σ_{k=1}^{3} g(k) applied to a ↦ a + 1 is 9 (it was g(1) + g(2) + g(3)), and Σ_{k=1}^{3} h(k) uses a function h assigned after the sum was written.

  • A comprehension over library functions or pure user functions prints its elements: [f(0) for f in [Sin, Cos]] prints [0, 1].

  • In LaTeX, a bracket pair of two function literals after \in is a list, not an interval: \prod_{q\in [a\mapsto a+1, a\mapsto 2a]} q(3) is 24.

  • In a function body, an assignment in an if branch to a local that a later statement of the body assigns writes that local: with function g(k) { if k > 1 { t = 5 }; t = t + 1; t }, g(2) is 6 (it was t + 1).

  • A function held by the index of a comprehension is applied where the index is the callback of a lazy collection operator: [Map(q, [1]) for q in [a ↦ a + 1]] is [[2]] (it was [[q(1)]]), and Filter([1, 2], q) with the same kind of index no longer throws. A call of such an index in an inner comprehension uses the function of its own element, also when the elements are read in another order. Evaluating a comprehension whose index is called like a function, such as [k(1) for k in [a ↦ 0, a ↦ 1]], no longer declares k in the global scope.

  • A view whose callback holds an error under a selection, such as Map(u ↦ If(u = 2, 1/"a", u), Range(1, 5)), is a collection whose second element is the error: Sum and Mean over it give the error, Length gives 5 and First gives 1. Before, Sum gave back the view itself and Length stayed unevaluated.

  • A pipe into Map over a list with a failed cell gives the same value as the direct call: [1, 1/"a", 3] |> Map(f, _) and Apply(z ↦ Map(f, z), [1, 1/"a", 3]) stayed an unevaluated Map, and a loop over that value ran no turn. Substituting such a list with subs() also keeps the expression bound. In Epsil, a statement whose value is a map or filter over a list literal with a failed cell, such as let ys = map((u) => u, [1, g("a"), 3]), is reported as a runtime-error; a cell with an effect is not evaluated for this check.

  • Angle quantities with exact magnitudes compare exactly. The scale of deg, grad, turn, arcmin and arcsec is a rational multiple of π, so 180 deg = π rad is True with no rounding, and 1 deg < (π/180 + 10^-30) rad is True (it was False, a tie within the tolerance). A quantity in daltons compares exactly with the value stored in the unit table (CODATA 2018). Unit conversion results do not change.

  • In Epsil, a failed cell in the collection of a for loop or comprehension header is reported. With g(x) = 1/x, for x in [1, g("a"), 3] { n = n + 1 } ran 3 turns and reported nothing, because the body does not use x and a header is not a statement. The loop still runs 3 turns, and the list gets a runtime-error with the same message and code as the statement let xs = [1, g("a"), 3]. Each header is reported once, also in a nested loop or in a function that is called more than once. A header that is a variable (for x in xs) is not reported again, because the statement that gave xs its value is. A comprehension header is reported when the program reads the list.

  • A tuple whose type has an absent slot, such as a symbol declared tuple<integer, missing>, is treated as a tuple. It was not, because the bare type tuple admits value slots only. So [...t, 5] and {...t, 5} spread it (they are now the spread-tuple error, as for any tuple), and Join(t, [3, 4]) with t := (1, Missing) had 4 elements (it now has 3, the first of which is the tuple). (1, 2) = p, with p declared tuple<integer, integer | missing> and no value, was False; it now stays undecided, as for a tuple with value slots. A union of a number and such a tuple joins with a list (Join([3, 4], s)), where it was an incompatible-type error. A compiled Dot of a tuple of such tuples is declined, as for other tuples of tuples.

  • A tuple or list literal compared with a value whose type only may be a tuple or a list stays undecided. (1, 2) = x with an undeclared x was False, and so was the comparison with a symbol declared tuple<integer, integer> | missing or integer | tuple<integer, integer>. [1, 2].isEqual(x) was false for an undeclared x or for a symbol declared list<integer> | integer; it is now undefined. A literal is still unequal to a value that cannot have its kind: [1, 2] = (1, 2) and (1, 2) = 5 are False, and so is a tuple literal compared with a value of a nominal type.

  • A type alias that names itself as an arm of its own union, such as cyc = cyc | tuple<number, number>, no longer overflows the stack. Spread, Join, arithmetic, comparisons and At of a symbol of such a type threw or logged Maximum call stack size exceeded. Such an alias is now a subtype of what its other arms are subtypes of: with cyc = cyc | 0, Norm(c) is valid and evaluates to |c|, where it was an incompatible-type error, and Join([3, 4], c) with cyc = cyc | tuple<number, number> appends c as one element. An alias that names itself inside a constructor, such as json = list<json> | integer, is unchanged.

  • The static type of a list plus or times a number contains the result when an element of the list is itself a collection. [[1, 2], 3] + 0.5 was typed list<integer | list<integer> | real>, which does not hold its value [[1.5, 2.5], 3.5]; it is now typed list<list<real> | real>. The same holds for an element whose type is an alias of a list (list<myints> + 0.5 is list<list<real>>), for a list of ranges ([1..3, 5] · 0.5), and for a list of tuples with an imaginary component times i. A sum or product that stays an integer keeps its type: [[1, 2], 3] + 1 is still list<integer | list<integer>>.

  • The static type of a collection operator keeps the missing element of an operand whose cells can be absent. With L: list<integer | missing>, Join(L, [2]) and ListJoin(L, [2]) were typed list<number>, which excludes the Missing cell of the value; they are now typed list<integer | missing>, as Append(L, 2) already was. The same holds for DeleteAt, Partition, Chunk, ChunkBy, GroupBy, Zip, TakeWhile, DropWhile, RandomShuffle, Dedup, Cycle, ListFrom, SetFrom, DictionaryFrom, Values, Map with a function literal, Find, MaxBy, MinBy, and the set operations (Union(S, {2}) with S: set<integer | missing> is set<integer | missing>, where it was the bare set). A position or a count over such a list is an integer: IndexWhere, ArgMax, ArgMin and CountIf were typed number, and Position list<number>. Flatten of such a list is list<any>, where it was the bare list. An operand typed with any elements is treated the same way, because any admits an absent value: Flatten(M) with M: list<list<any>> and Union(A, {2}) with A: set<any> are list<any> and set<any>, where they were the bare list and set.

  • A sum or product whose operand type has an imaginary member is typed complex, because neither is closed over the imaginary numbers (i + (-i) = 0, i · i = -1). With r: imaginary | integer, r + 1 was typed imaginary | integer, which excludes its value 1 + i; it is now complex. The same holds for an alias of such a union, for list and tuple cells ([i] + [-i] is vector<complex^1>, where it was vector<imaginary^1> with the value [0]), for a list of points plus a point, and for imaginary | nan, which becomes complex | nan. A sum of two lists of ranges, [1..3] + [1..3], is typed list<list<integer>^1>, where it was list<range^1>.

  • Arithmetic over a list whose element can be a list or absent has a static type that holds the result. With L: list<list<integer> | missing> and L := [[1, 2], Missing], L + 1 was typed list<list<number>>, but the absent element becomes NaN; it is now typed list<list<number> | nan>. A function over a list of rows and numbers keeps the rows: Sin(L) with L: list<list<integer> | integer> was typed list<number> and is now list<list<number> | number>. An absent row of points stays Missing, also when the point type is the bare tuple (it was NaN).

  • Collection operators whose result can hold Missing say so in their type. Iterate(f, Missing) and an Iterate whose callback can return Missing are typed list<any>, where they were the values-only list. First([]), Last([]) and At([], 2) are typed missing, where they were unknown. Map with a callback that is declared for numbers or booleans over a source whose elements can be collections (Map(Sqrt, U) with U: any) admits a collection element; with a callback such as IsPrime or Not over a list that can hold Missing, the element type has a missing arm. Map with a user function that has no declared result types the call: with g := c ↦ If(c == "a", "zz", c), Map(g, ["a", "b"]) was typed as the source and is now typed by what g returns. A Zip element whose items are all absent is Missing through .at() and iteration too, as it already was in the printed value, and compiled JavaScript writes it as an absent value; the Python target declines such a Zip.

  • A complex number whose imaginary coefficient is written as an inline fraction keeps its value in a LaTeX round trip. At a deep level, or with an inline fractionStyle, Complex(1/10, 1/10) was written 1/10+1/10\imaginaryI, which reads back as 1/10 + 1/(10i), the opposite imaginary part. It is written 1/10+(1/10)\imaginaryI (found by Tycho's round-trip audit of its Desmos corpus, whose failing trees are now part of npm run check:roundtrip).

  • A mixed number keeps its tree in a LaTeX round trip (Tycho ledger row 390). 2\frac{1}{2} is the sum Add(2, 1/2), which was written 2+\frac{1}{2} with prettify: false and read back as 5/2. It is written as the mixed number whenever the parser rebuilds the same sum from it, with or without prettify: -2\frac{1}{2} was written -\frac{1}{2}-2, and 500\frac{999}{1000} was written 500+\frac{999}{1000}. The pretty MathJSON of -3\frac{1}{4} is ["Add", -3, ["Rational", -1, 4]] (it was ["Subtract", ["Rational", -1, 4], 3]). The fraction of a mixed number is always written \frac{…}{…}: with an inline fraction style, -2\frac{1}{2} was written -21/2. A fraction that is an integer is not a mixed number: 2\frac{4}{2} is 4 (it was the sum Add(2, 2)), and Add(2, Rational(3, 1)) is no longer written 2\frac{3}{1}, which read back as 6.

  • Printing an unevaluated lazy view does not run a side effect. toString(), the latex getter and toLatex() evaluated the cells of a view over a list literal to show a preview: printing Dedup([1, H(1), 2]), with H declared pure: false, called H twice, a view over Random() printed a different list each time, and a comprehension whose body assigns an outside variable did the assignment. Such a view now prints as written (Reverse([1, H(1), 2])), and its value comes from .evaluate(). A view over pure functions, or one that writes only to its own local variables, still shows its preview. The same holds when the view calls a comprehension index or a callback parameter whose functions can be impure: [q(1) for q in [a ↦ H(a)]] and Map(w ↦ w(1), fs), with fs holding such functions, print as written.

  • In Epsil, a function statement in a function body works when its name is also the name of a library constant. function o() { function i() { 2 }; i() } then o() gave Error(ErrorCode("constant-assignment", "i")) instead of 2; the same happened for e, Pi and the other library constants. The definition was written to the library constant instead of making a binding local to the call. It is now local to each call of the outer function, as at the top level, and the library constant outside the body does not change.

  • Two quantities whose units have the same dimension compare by their physical value on every route. isEqual(), isIdenticallyEqual(), is(), isLess(), isLessEqual(), isGreater() and isGreaterEqual() gave undefined or false for 1000 m and 1 km, because the unit symbols counted as unknowns; now they agree with Equal. NotEqual is the negation of Equal (1000 m ≠ 1 km is False, 6 W ≠ 7 W is True; both stayed unevaluated). Max, Min, Clamp and Sort order such quantities with no tolerance: Max(1 km, 999 m) and Max([1 km, 999 m]) are 1 km (they stayed unevaluated). The comparison converts both quantities to the finer of the two units, so 1 eV = 2 eV is False (it was True, because the difference in joules was smaller than the tolerance). It applies temperature offsets: 0 °C = 273.15 K is True and 0 °C < 1 K is False (they were False and True). Two exact magnitudes are compared with exact arithmetic, also when they are too large for a double (10^400 m < 10^401 m) or when their double cancels: 1 m = (1 + 10^-16) m is False. Under .N(), the exact magnitude of a quantity decides, as it does for a number: 1 m < (1 + 10^-30) m is True. The conversion is exact between two identical units and for every unit defined by an exact decimal number (in, ft, mi, lb, eV and all SI prefixes); the angle units and Da are compared within the tolerance. A magnitude with no numeric value or a complex value, even with a very small imaginary part, leaves the comparison unevaluated: x m = y m was True and x m < y m was False. Quantities of different dimensions, and a quantity and a plain number, stay unevaluated.

  • Sort and Ordering of a lazy source give the error of an element that fails: with h(2) an error, Sort(Map(h, [2])) was [Error(…)] and Sort(Map(h, [3, 2, 1])) stayed unevaluated. Each element of the source is read once. A membership test (Element, NotElement, Contains) over a lazy source that computes a failed element gives that element's error when it finds no match: 5 ∈ Map(h, [1, 2]) was False. A Range or an Interval keeps its arithmetic test, and an infinite source is not walked.

  • Count(xs, p) gives the error when the predicate fails on an element, as CountIf does. Before, it counted that element as selected: with h(2) failing, Count([1, 2, 3], (u) => h(u) > 0) was 3.

  • On interval-js, a Which or an If arm is also narrowed by a decimal bound written in LaTeX (x \ge 0.5) and by an exact bound that a double holds (\frac{1}{2}) (Tycho ledger row 397). Over [0.49, 0.51], \begin{cases}0 & x\ge0.5\\ \arccos(2x) & \text{otherwise}\end{cases} was partial (domain clipped) and is an interval. A bound that no double holds (\frac{1}{3}) is compiled to an enclosure of its value and still does not narrow.

  • #454 A Fold whose step reads its accumulator by index, such as q(s[1]) with q: (integer) -> list<integer>, is typed from its seed again. The index read had typed the accumulator as a tuple of one integer, and the fold as broadcastable<tuple<integer>>, so compiling the fold at a list<integer> parameter, or compiling acc + fold[1], threw. The fold with seed [1, 0, 1] from the issue is now typed list<integer | nan^3> | vector<integer^2>.

  • An If or Which whose condition may be a list of booleans or a single boolean keeps, in its type, each branch whose element type is not known. With u: list, If(u[2] != 0, p, u) was typed broadcastable<tuple<integer>> for p: tuple<integer>, but returns u itself when the condition is a single False.

  • A seeded Fold or Reduce over a collection that can be empty includes the type of the seed in its type: Fold((a, k) => "x", 0, Range(1, n, 1)) was typed string, but is 0 for n = 0.

  • Assumptions in nested scopes combine: with assume(b ≥ 0) and, in a nested scope, assume(b > 0), b.isPositive and b.isGreater(0) are true (they were undefined), whatever the order of the two facts. With p: integer<1..> and assume(p ≥ 0), p.isPositive is true.

  • On the asynchronous route, a user function whose argument contains a free symbol awaits its body: with f := x ↦ AsyncOnly(x), where AsyncOnly has only an evaluateAsync handler, f(1 {0<t}) gives the value of AsyncOnly(1 {0<t}) (it gave AsyncOnly(1) {0 < t}). The application memo, a list-producing recursion and a numeric evaluation no longer give the synchronous answer for a body that applies such an operator. A recursion over a free symbol still stays inert.

  • After a quantity with the unit newton (\mathrm{N/A^{2}}) is parsed, the operator N still evaluates, and (2 m)^n is written (2\,\mathrm{m})^{n}, so it parses back as the same expression.

  • A list of rows of points whose components have different types ([[(1, 1/2), (1/2, 1)], [(1, 4/3), (3/4, 1)]]) is typed as a 2×2 matrix of points (it was typed as a list of points), and a matrix of points plus a point is typed as a matrix of points (its type held an extra | tuple<…>). A point divided by a vector or matrix with a cell that can be zero has a type that admits the division by zero: (1, 2) / [1, 0] is [(1, 2), (~oo, ~oo)], typed list<tuple<infinity, infinity> | tuple<rational, rational>>. The product of a point and a tensor of rank 3 evaluates, as their quotient does, and a product of a list of points, a matrix and scalar factors is typed as a matrix of points.

  • A Union with more than 100 elements counts and lists a repeated element of a list operand once (Union(L, S).count gave 297 for 150 distinct values).

  • Epsil: an error in the branch of an else if, or in any call whose operator occurs more than once in the statement, is underlined on the branch or argument that holds it (it was the whole statement). When the source does not show which operand of a sum or product holds the error, the whole sum or product is underlined, never an operand that cannot cause it. epsil check reports a function statement for a name that an earlier const bound, with the code invalid-clause-definition, and the run does not report it a second time.

  • With the Rubi rules loaded (loadIntegrationRules), a definite integral of an integrand that is real on the interval no longer gives a complex value: ∫₁²⁰¹¹ √x/(√(2012−x)+√x) dx is 1005 (it was 1005 − 503 ln(−1005) + 503 ln(1005), which is 1005 − 1580.2i). When the integrand is proven real on a finite interval and the difference of the values of the antiderivative at the bounds has an imaginary part, that value is refused: the built-in antiderivative or an exact reduction gives the value, and otherwise the integral stays unevaluated. A correct complex value is kept: ∫₋₁³ √x dx is 2√3 + (2/3)i.

  • simplify() reduces more exact constants:

    • A sum of logarithms of positive rationals or surds with rational coefficients: −2 ln 2 − 2 ln(3/2) + 2 ln 3 is 0 (it stayed as it was), and ln 2 + (1/2) ln 3 is (1/2) ln 12.
    • A quotient whose denominator is a power or a product of a + b√c: (12 − 7√3)/(3 − 2√3)² is −√3/3, and −π(12 − 7√3)/(3 − 2√3)² is (√3/3)·π. So the value of ∫₀^π 1/(2 + cos x) dx simplifies to π√3/3.
    • An odd or even function of a negative constant that is not one number: arctan(−3 + 2√2) is −arctan(3 − 2√2), so arctan(−3 + 2√2) + arctan(3 − 2√2) is 0.
  • With the Rubi rules loaded, a definite integral whose Rubi value is real but written with terms that are not real gets the form of the built-in antiderivative: ∫₂^∞ 1/(x² − 1) dx is ln(3)/2 (it was iπ/2 + artanh(2)).

Performance​

  • On the javascript target, a seeded random draw that reads no input of the compiled function, such as WithRandomSeed(191268, RandomChoice([0, 1], 6561)), is computed once per compiled function, on its first call, and the stored value is read after that: the draw is a pure function of its seed. It was drawn again at every call, about 6,561 numbers per sample of a surface indexed by 9θ + 8φ (0.027 ms per call against 0.0002 ms for sin(9θ + 8φ); now 0.0003 ms). A draw whose seed or body reads an input, a loop index or a caller function, and an unseeded draw, are still per call.

  • A type string that names a type declared with ce.declareType, such as the signature (integer_partition) -> number, is parsed once and then read from a cache. Before, it was parsed twice at every ce.declare. Declaring 1000 functions with such a signature takes about 10 ms, where it took about 47 ms (#453).

  • Equal of two sets, the duplicate removal of Union, and membership in a literal set or list of 32 or more elements group the elements by hash: with 20,000 elements, set equality takes about 4 ms (it took 1.6 s), and the count of a Union of two sets about 50 ms (it took about 3 minutes).

  • A list in which every cell failed is data: it evaluates to itself and is not walked again at each use. In Epsil, 3200 turns of fs = [...fs, g("a")] with a failing g("a") take about 3 s (they took about 20 s).

New Features​

  • FindFit takes an optional fifth argument, a dictionary of options. The global option, FindFit(data, model, params, vars, {global: {b: [0.1, 6]}}), searches the named parameters (at most two) over the given range for the lowest sum of squared residuals, instead of returning the local minimum nearest the start. In the MathJSON dict spelling the range is a two-element array. When the model is linear in every other parameter, the range is scanned on a grid, the other parameters are solved by linear least squares at each grid point, and the lowest grid minima are refined by the bounded Levenberg–Marquardt solver; otherwise the solver runs from each point of a coarser grid and the lowest residual wins. The searched parameter stays inside the range; a range is intersected with the parameter's own (a, a0, lo, hi) bounds. The result record is unchanged; its iterations is the total over every solver run, and a deadline that expires during the search returns the best point found with timedOut: True. An unknown option key, a name that is not a parameter, or a range that is not two finite numbers with lo < hi is an unexpected-argument error. Example: on the five points (1, 4.3), (3.22, 1.18), (5.27, 0.01), (10.49, 1.48), (7.12, 3.44) with the model a\cos(bx+c)+dx+e, the fit from the default start has period 8.0 and a sum of squared residuals of 0.36; with b searched over (0.1, 6) it has period 2.9 and 0.0057.
  • Integrate gives exact values for more families of definite integrals: substitutions that it composes with the existing solver, Beta and Beta-prime forms with logarithmic moments, rational functions of trigonometric and hyperbolic functions, products of Floor, Ceil and FractionalPart with polynomials, polynomially weighted series and telescoping products, and reciprocal pairs of high-degree rational functions. For example, ∫₀¹ x(1−x)^99 dx is 1/10100, ∫₁²⁰¹¹ √x/(√(2012−x)+√x) dx is 1005, and ∫_{1/5}^{5} (1+x²)/(x²+x⁹) dx is 24/5. Most of these reductions run only when the existing routes give no exact value; the splitting at discontinuities, the high-degree reciprocal pairs and the Beta forms with a large power run first, because the existing routes would expand them. Each reduction proves its domain and convergence conditions before it returns a value. The Rubi rules also gain three substitution utilities (SubstForFractionalPowerOfLinear, InverseFunctionOfLinear, SubstForInverseFunction) (#445, contributed by KingArth0r).

0.157.0 2026-10-19​

New Features​

  • NSolve(eq, x) accepts the same arguments as Solve and returns numeric approximations of the solutions.
  • Sum, Product and Table accept the shorthand iterator {i, n}, from 1 to n: Sum(k^2, {k, 3}) is 14. LaTeX tuple iterators such as \mathrm{Integrate}(x, (x,0,1)) also parse correctly.
  • More number theory and combinatorics functions operate element by element on lists: NthPrime([1, 2, 3]) is [2, 3, 5], and Divisors([6, 8]) is [[1, 2, 3, 6], [1, 2, 4, 8]]. Boole, Degrees and DMS also accept lists.
  • Collections can provide three optional handlers, countOf, indexOf and atBigint. Permutations and Combinations use them to answer Element, At, IndexOf and Count without enumerating their elements.

Behavior Changes​

  • Sets are unordered. Equivalent sets now have the same canonical order and compare equal with isSame: {2, 1} prints as {1, 2}, and ListFrom({2, 1}) is [1, 2]. Set patterns match in any order. Set builders, ellipsis notation, and structural or raw sets retain their written order; effects in a canonical set run in canonical order.
  • Operations that depend on position, including Take, Reverse, Sort, Insert, Find, Partition and Scan, consistently reject sets. Convert a set to a list before using these operations.
  • Solve returns families for periodic equations: Solve(sin(x) = 1/2, x) gives [2πt + π/6, 2πt + 5π/6], with integer t. Substitute integers for t or supply a bounded domain to obtain individual values. expr.solve() still returns principal solutions. Cases whose full families cannot be represented remain unevaluated, including unsupported complex families and families requiring additional restrictions.
  • Factorial2 supports negative integers: (−1)!! = 1, (−3)!! = −1, and (−5)!! = 1/3. Negative even integers give ~oo instead of NaN.
  • Quantity arithmetic preserves exact magnitudes: Quantity(3, m)·π is 3π m, and 3 m / 2 m is 3/2. Use .N() for approximations. Conversions by integer factors preserve exactness; other conversion factors give floats.
  • Integer functions preserve float inputs in their results: Fibonacci(5.0) is 5.0, and Divisors(12.0) contains floats. Non-integer inputs remain errors where integers are required.
  • Values supplied as complex.js objects are inexact. For an exact Gaussian integer, use ce.number({ re: 2, im: 3 }) or ce.box(["Complex", 2, 3]).
  • Global declarations cannot replace library constants such as Pi or True. Parameters and local scopes can still use these names. Sequence definitions also cannot replace constants.
  • Float serialization preserves float values through LaTeX and MathJSON round trips. Repeating decimal marks are automatic only for exact values in LaTeX; toMathJson() now defaults repeatingDecimal to false.
  • SVD and SingularValues generally leave exact matrices symbolic under evaluate(); use .N() for numeric decompositions. Small matrices with supported exact singular values retain those results.
  • Rounding functions accept poles: Floor(~oo) is ~oo. Complex numbers with an infinite real part and finite imaginary part become signed real infinity. Sum([1+i, +oo]) and Mean([1+i, +oo]) both give +oo.
  • Epsil reports errors in nested statements, once per source location. Block locals belong to each execution; reading or writing a let or const before its declaration reports read-before-declaration.
  • Products and quotients of square roots stay exact when the combined radicand is above 10⁶: \sqrt{1009}\sqrt{1013} is √1009·√1013 instead of the float 1010.998…, and √1009/√1013 is no longer evaluated to 0.998…. Use .N() for a float. (√−13)^3 evaluates to −13√13·i.
  • An element-wise Which or else-less If with a point branch gives Missing at a position that no clause selects: Which([1, 5] < 3, (1, 2)) is [(1, 2), Missing] instead of [(1, 2), NaN]. A number branch still gives NaN.
  • D(x ↦ a+b, x) canonicalizes to D(a+b, x), so a LaTeX round trip keeps the same tree. A function literal whose body is a single symbol is kept (Tycho row 391).
  • After f(x) is parsed, fy and 2f report the same incompatible-type error as f\times y and 2\times f; they were products. An application such as 2f(x) does not change.
  • IsSquare, IsTriangular, IsOctahedral, IsCenteredSquare, IsHappy and IsAbundant of a symbol stay unevaluated; they answered False.
  • An ordering comparison of a set and a list, such as Less(Set(5, 6), [1, 2]), is an incompatible-type error; it was compared element by element.
  • Join or Append of a dictionary and an element that can never be a key-value entry is an incompatible-type error when boxed: Join(Dictionary(x: 1), [2, 3]) stayed an unevaluated Join.
  • After ce.declare() gives a function a signature, an Epsil function statement for that name replaces the body and keeps the declared signature; it added a clause. A statement with parameters outside the declaration reports invalid-clause-definition.
  • cantorEnumerateComplexNumbers() yields pairs of fractions [[n, d], [n, d]]. The exported factorial2() gives the recurrence value for an odd negative integer.

Issues Resolved​

  • Numeric evaluation of user functions, blocks and lazy Map now agrees with direct evaluation, including exact arguments near a rounding boundary: Map(Sin, [Pi]).N() is [0], and Map(Floor, [(25! − 1)/24!]).N() is [24].
  • Improved rounding of elementary and special functions at high precision, including exact rational arguments, complex angles and incomplete gamma and beta functions. Very small probability tails retain their significant digits (#450, contributed by enumeratio).
  • Solve handles integer-valued float exponents, rejects extraneous roots more reliably, and finds numeric polynomial roots up to degree 1000 within its complexity limits. x^{2.0} = 4 gives 2.0 and -2.0; real-domain pure powers avoid computing complex roots.
  • Improved Series, Limit and Residue at algebraic and symbolic points, including 1 + √2 and π/2. One-sided limits retain their direction; parameter-dependent discontinuities and unsupported branch cuts remain symbolic. For example, lim_{x→a⁺} 1/(x−a) is +∞.
  • Derivatives of blocks with pure local assignments now evaluate: D(Block(k ≔ 2; kx²), x) is 4x.
  • Lazy collections with unknown lengths support more positional reads and reductions: Last(FlatMap([1, 2, 3], x ↦ [x, x])) is 3. Incomplete collections remain symbolic rather than returning partial results, and operations requiring all elements respect maxCollectionSize.
  • Filtering and deduplication use evaluated elements: with x := 5, Dedup([x, 5]) is [5]. Sorting rejects comparators that cannot accept every element. Operations on restricted collections preserve their conditions.
  • Tabulate with a non-positive dimension and ascending Table ranges with no elements return []: Table(k², {k, 2, 0}) is []. Use an explicit negative step to count down.
  • Large Permutations, Combinations and PowerSet no longer overflow the stack or stop early. Permutations and combinations support direct indexing, membership and position queries without enumerating every element.
  • Quantity LaTeX round trips preserve symbolic magnitudes, fractional unit powers, compound units and non-ASCII names such as µm, Å and Ω. Exact magnitudes use siunitx, for example \qty[parse-numbers=false]{2\sqrt{2}}{m}; unsupported unit notation uses Quantity(...). Temperature conversion gives 20 °C = 68 °F accurately.
  • LaTeX round trips preserve derivatives, range steps, blocks, products after sums or limits, and If without an else branch. Small negative numbers and unit exponents print correctly, including with prettify: false (Tycho rows 387–390 and 393–395).
  • Corrected result types for nested list broadcasting, absent points, logical operations, float arithmetic, poles and zero-length collections. Invalid point products and divisions consistently report errors.
  • Compiled assignments preserve caller inputs and the caller's vars object. Python and shader targets handle more blocks, chained assignments and conditionals; unsupported forms decline compilation instead of producing invalid code. Python also handles Unicode name collisions and real sum bounds.
  • Python supports Sum and Product bodies containing statements in supported statement positions. WGSL supports assignment branches used as statements.
  • Compiled functions validate argument counts and preserve absent values during interpreter fallback. Interval results consistently use { kind: 'interval', value: { lo, hi } } or { kind: 'empty' }; conditionals report partial when some inputs have no value and respect branch domains (Tycho row 392).
  • Shader trigonometry is more accurate with large constant offsets. Supported GLSL sine hashes have improved accuracy within the reported float32Bounds; inputs outside those bounds give NaN. Other sensitive Mod/Fract expressions report float32-unsafe-argument warnings. compileFunction() and compileShader() return code without this metadata (Tycho rows 382 and 384).
  • Asynchronous evaluation preserves conditions, awaits operators with only async handlers, and isolates concurrent call and block state. Function redefinitions update dependent expressions consistently.
  • Epsil handles recursive callbacks, standalone do scopes and lists containing errors without host exceptions. Unbounded recursion reports the engine's recursion limit consistently.
  • Operator extensions correctly apply changes to associativity, commutativity and effects. Removing commutativity preserves canonical operand order.
  • Compiled Floor, Ceil, Round, Truncate, Fract, Mod, Sign, Heaviside and comparisons of an exact constant give the interpreter's value, also with constantFold: false: Floor((25! − 1)/24!) compiled to 25 and now gives 24. A rounding function of an integer variable that cannot be rewritten, such as Floor(k·π), declines compilation.
  • An assignment used as an operand compiles correctly: (t ≔ 1) + 2 compiled to t = 1 + 2, which assigned 3. JavaScript and GLSL write (t = 1) + 2; Python and WGSL decline.
  • Sums and products with more than about 120,000 operands no longer throw, NotGreater evaluates, Map accepts Subtract, Negate, Multiply and Divide over points, and -3\,\mathrm{m} parses as the quantity −3 m.

Performance​

  • Faster construction of large sets, matching of large sums, and compilation of expressions with shared subexpressions.
  • Faster numeric evaluation of lazy maps and long sums, with repeated work reused in compiled functions. A 6,000-term numeric sum inside a mapped Sin takes about 60 ms instead of 5.6 s.
  • Faster high precision incomplete gamma and beta evaluation; for example, Gamma(10⁴, 10001) at 524 digits takes about 36 ms instead of 4 s.

0.156.0 2026-10-17​

Issues Resolved​

  • User functions preserve exact arguments for rounding and comparisons under .N(): a function applying Floor to (25! − 1)/24! returns 24.
  • Lazy Map and Filter evaluate impure list elements correctly. Numeric rounding functions also preserve exact surviving elements when a list drops an element that evaluates to Nothing.
  • Range membership and integer-grid Linspace samples remain exact beyond the safe integer range.
  • Inverse trigonometric functions near complex branch points and HurwitzZeta with severe cancellation remain symbolic when machine precision cannot produce a reliable answer.
  • Corrected types for arithmetic on unions of numbers, points and lists, coordinate accessors, and parameters inferred from index reads.
  • Float half powers retain their float spelling in text output: 2^0.5 prints as 2^(0.5).
  • Declarations inside blocks correctly update dependent expressions.

Performance​

  • Faster repeated evaluation of symbolic function results and lazy broadcasts.

0.155.0 2026-10-17​

New Features​

  • ce.undeclare(name) removes a value or function binding from the current scope, including functions defined by multiple clauses.
  • ce.tolerance = "auto" makes tolerance follow the working precision. An explicitly numeric tolerance now survives precision changes (#449).
  • A new broadcast exemption, 'surplus-operands', makes a call with extra operands one error before any broadcast. Numerator, Denominator and NumeratorDenominator use it: Denominator(5, []) is an error, not [].

Behavior Changes​

  • Numeric element-wise operations on symbols typed any or value retain types that allow point and collection results.
  • Compiled predicates must return booleans. Invalid predicates fail compilation or report an error at run time instead of using truthiness.
  • Compiled numeric parameters receive a NaN default for absent arguments; this can change the returned JavaScript function's length property.
  • Unit names such as m, s and K are no longer declared in the system scope, and a Quantity no longer declares its unit names. A function defined in a parent scope does not see a value that a host assigns to one of these names in a child scope unless the parent scope declares the name first, for example with ce.declare('s', 'number').
  • Sum(a) and a = [1, 2] stay unevaluated until a has a value when a is declared any. Operator extensions can change associativity and effects. The type setter refuses a function signature on a constant.

Issues Resolved​

  • Functions inferred from index reads accept list arguments again, including p ↦ p[1] + p[2] (#448). Improved tuple, point, type-alias and overload handling throughout calls.
  • Async user functions, including multi-clause and recursive functions, await their bodies and preserve arguments across overlapping calls. Materializing async maps evaluates their elements asynchronously.
  • Lazy views evaluate their sources once; folds that build lazy accumulators no longer become exponentially slower (#444).
  • Quantity arithmetic preserves symbols and exact roots: 3 m · x is Quantity(3x, m), and sqrt(2 m²) is √2 m. Repeated unit factors combine into powers.
  • Corrected float division, powers and result types. Sqrt(-13)/3 gives (√13/3)i instead of NaN, and DivisorSigma(1, -12) is 28 (#446, contributed by enumeratio; #449).
  • Improved incomplete gamma accuracy for negative non-integer orders and small positive arguments. Numeric integration handles slowly converging endpoint singularities more accurately and detects poles in exponential denominators.
  • Invalid If arity is reported correctly. Derivatives of unsupported blocks and lazy operators stay symbolic without executing their bodies.
  • Sequence definitions respect scopes and constants; removed sequences no longer appear in isSequence() or listSequences().
  • Named and field calls follow redefinitions, parameter names and current bindings. Epsil static checks agree with these rules.
  • Shader sums and products support complex bodies. JavaScript complex mode handles real powers with complex results, and compiled sum indices no longer collide with block locals.
  • Python compiles blocks ending in assignments correctly and rejects reserved names. Reusing a compilation target respects current variable mappings.
  • Point division by a list works in the interpreter and compiled JavaScript: (1, 2)/[1, 2] is [(1, 2), (1/2, 1)].
  • Collection errors include their source trace without reporting failed operands twice. Numerator and denominator operations report extra arguments (#442, contributed by enumeratio).
  • Compiled JavaScript Gamma(x, y) computed Γ(x); it now computes the upper incomplete gamma. The interval target declines the two-argument form (#446, contributed by enumeratio).
  • At over a collection of infinite count whose handler declines a position stays unevaluated instead of giving NaN (#449).
  • LaTeX derivatives with a symbolic order parse as D(f, {x, n}): \partial_x^{n} f was read as a first derivative. \partial_x takes the expression that follows it: \partial_x f + g is D(f + g, x).

Performance​

  • Faster repeated evaluation of symbolic values and nested user functions.

0.154.0 2026-10-15​

New Features​

  • LatexSyntax.dictionary returns the entries of an instance.

Behavior Changes​

  • A tuple with only absent coordinates evaluates to Missing.
  • Functions that consume whole collections keep list arguments whole even through arithmetic or local aliases: with h(xs) := mean(xs^2), h([1, 2, 3]) is 14/3.
  • Measurements propagate uncertainty through more numeric functions and statistics. Sorting, extrema and clamping compare their nominal values.
  • Matrix operations preserve float results when any entry is a float. Exact integer powers are matrix powers; float powers are element-wise: [[1,2],[3,4]]^2.0 is [[1,4],[9,16]] with float entries.
  • Float exponents retain their inexact meaning in simplification and polynomial operations; 0 / 2.5 is 0.0.
  • Function results are checked against their declared result types.
  • Append and Join over a source whose type does not fix its kind are typed collection<T>, not list: with a declared any, Append(a, 1) is typed collection<any>.
  • Comparisons with a Measurement use its nominal value: Less(Measurement(0.7468, 0.001), 1) is True. Floor, Ceil, Round, Truncate and Mod of a measurement return the bare result for its nominal: Floor(Measurement(0.7468, 0.001)) is 0.
  • ce.declare() throws The symbol "Sq" is already declared in this scope when Sq is an operator declared earlier without a signature (the second declaration replaced the first). To change an existing definition, use ce.declare(name, patch, { extend: true }).
  • The add and mul rational helpers exported by @cortex-js/compute-engine/numerics return [±Infinity, 1] for an infinite operand times a finite non-zero one (they returned [NaN, 1]). pow of an infinite base follows IEEE 754 (it threw a RangeError).
  • In Epsil, a bare name -> value call argument is a rule in every position: f(GridLines -> [10, 10]) round-trips. map(x -> x^2, xs) reports mapsto-arrow-expected, with a fixit that replaces -> by => (#435).

Issues Resolved​

  • Definite integrals preserve convergence conditions, detect interior and endpoint divergences more reliably, and evaluate more absolute-value and piecewise integrands exactly: ∫₀^{2π} |sin x| dx is 4 (contributed by KingArth0r).
  • Symbolic integration respects angular units: in degree mode, ∫₀^{180} sin x dx is 360/π.
  • Numeric integration handles near-divergent but convergent powers correctly; ∫₀¹ x^{-0.9999} dx is 10000. Limits compare exponential and algebraic growth correctly: lim_{t→∞} t/e^{√t} is 0.
  • Complex special functions select the correct branch, including LerchPhi(0, 1/3, -8). Argument supports the requested numeric precision, and compiled Gamma handles complex arguments (#428, #439, contributed by enumeratio).
  • Matrix powers follow later assignments of matrix values, preserve matrix shapes, and reject non-square matrices. Empty axes remain in shape types: [[], []] has a 2×0 matrix type.
  • Function calls and named arguments follow redefinitions and updated parameter names. Indexed parameters infer each coordinate separately, and async sums preserve the index captured by callbacks.
  • Lazy collection operations evaluate computed sources correctly. Nested maps and folds preserve accumulator values (#438).
  • Epsil reports errors inside pipe stages, with diagnostics on the relevant argument. Serialized rules preserve library names.
  • Custom LaTeX notation applies inside rewrite rules. Delimiter groups and sequences round-trip correctly, and logarithms stop before an integral's differential. Kronecker delta indices accept spacing and commas.
  • Shader loops follow positive and negative range steps. Compiled calls check parameter types, and compilation metadata describes the emitted numeric mode.
  • Numeric infinities, sequence validation and operator redeclarations report consistent results and errors. Simplifying an unbounded recursive call keeps it symbolic.
  • Compiled HurwitzZeta, LerchPhi, PolyLog, EllipticK, the complete EllipticE, Hypergeometric2F1 and LambertW return complex values in auto and complex mode, where they returned NaN: compiled LerchPhi(3, 2, 1) is 0.7734 - 1.1505i. The GLSL and WGSL targets decline a provably complex value (#434, contributed by enumeratio).
  • Transpose of an operand with no known shape is typed unknown, so MatrixMultiply(q, Transpose(q)) with a free q stays unevaluated instead of returning an incompatible-type error (#439, contributed by enumeratio).

Performance​

  • Faster arithmetic with complex factors and type queries for very large integer literals (#431, contributed by enumeratio).

0.153.0 2026-10-13​

New Features​

  • A host that replaces the At lowering on the javascript target can read a list through a boolean mask with the runtime helper _SYS.atMask(base, mask). Any other index is read by _SYS.atNoWrap.

Behavior Changes​

  • Compiled JavaScript run() expects inputs in the declared shape and returns results that callers must treat as read-only. Pass plain arrays; convert typed arrays once with Array.from(). Keep entries numeric for real list inputs, and copy returned arrays or complex objects before modifying them.
  • simplify() folds pure constant subexpressions whose evaluation gives a numeric literal, with default rules, custom rules or rules: null. Exact symbolic values such as Ln(2) stay symbolic. Numeric relations simplify to truth values: (3 < 4).simplify() is True (#306).
  • Gamma(n) evaluates exactly for positive integers: Gamma(4) is 6 (#429).
  • Numeric evaluation reaches single-statement user-function bodies directly.
  • code of compiled JavaScript reads a constant list of three or more finite real numbers from a _lit_<n> local defined in preamble. A host that evaluates code without run() must include preamble (or preambleOnce once and preamblePerCall on every call). A compile hook must treat an array operand as read-only.
  • CompilationResult.entryPlan no longer has the per-entry checks (entries). A runtime that loads an older stored plan ignores the field.
  • The .pow() method evaluates the root it builds from two number literals: ce.number(-8).pow(ce.number([1, 3])) is -2, not Root(-8, 3) (#430).

Issues Resolved​

  • Wallis-like products with symbolic bounds evaluate to closed forms: ∏_{k=2}^{n} (1 − 1/k²) is (n+1)/(2n).
  • Roots evaluate in one pass with list exponents or assigned fractional exponents: (-8)^{[1/3, 5]} is [-2, -32768] (#430).
  • Async N(x, p) and numeric accuracy goals yield and honor cancellation (#392).
  • Higher-order maps use the callback passed as an argument under .N(). Functions reading stored random expressions retain independent draws.
  • Calls with list arguments have result types derived from their bodies, including parameters passed through unchanged (Tycho row 373).

Performance​

  • Constant lists and matrices in compiled JavaScript are reused across calls.

0.152.0 2026-10-11​

New Features​

  • An operator definition can set evaluatesOperands: true when its handler evaluates every held operand and reads nothing else of them. Under evaluateAsync, the operands are then awaited in order and the handler runs on the values.

Behavior Changes​

  • Provably divergent series evaluate to signed infinity: Σ_{n=1}^{∞} 1/n is +∞. Comparisons with infinity remain undecided when the other operand is not known to be finite (#326).
  • Reduce(…).N() respects the engine's working precision.
  • An engine constructed with precision: 'machine' keeps the faster double-precision fold for Reduce(…).N().

Issues Resolved​

  • Infinite telescoping sums and Wallis products evaluate and simplify correctly: Σ_{k=1}^{∞} 1/(k(k+1)) is 1, and 2∏_{n=1}^{∞} 4n²/(4n²−1) is π (#323, reported by 314systems).
  • Equivalent spellings of sum and product bodies produce the same closed forms.
  • Empty folds and reductions evaluate their initial value. Numeric reductions evaluate elements such as Ln(2) before combining them (#425, reported by enumeratio).
  • Compiled folds support accumulators indexed by their own elements (#425, reported by enumeratio).
  • Async evaluation yields and honors cancellation inside relations, user functions and more lazy operators (#392, contributed by enumeratio).

0.151.0 2026-10-08​

Behavior Changes​

  • Lists and tuples evaluate finite lazy elements while preserving nesting: [map(x => x + 1, [1, 2])] remains [[2, 3]], including when materialized.
  • Integer operations preserve exact integer arguments under .N(): DigitSum(2^1000).N() is 1366.
  • Mod of exact radicals remains exact: Mod(√2, 1) is √2 − 1.

Issues Resolved​

  • GCD and LCM return exact results for large integers: GCD(2^100, 2^50) is 2^50.
  • Mapping lambdas preserves errors and absent values consistently with named callbacks. Mapping a list of functions applies each function correctly.
  • Substitution reaches dictionary values, and dictionaries containing functions round-trip through LaTeX.
  • Parse diagnostics have correct spans after Unicode scripts. Missing scripts retain their bases; Greek names, spaced subscripts and script ordering parse consistently.
  • Loops with symbolic range bounds compile, and literal bounds give indices ranged types. Functions declared to return real values use real arithmetic.
  • If result types retain unknown branches. Errors caused by incorrectly shaped compiled inputs identify the binding.

Performance​

  • Faster interval evaluation of constant subexpressions inside user functions.

0.150.0 2026-10-08​

New Features​

  • New ce.withEvaluationContext(options, fn), for the synchronous work that an evaluateAsync handler does after an await: ce.withEvaluationContext(options, () => x.canonical).evaluateAsync(options).

Behavior Changes​

  • Solve returns every complex polynomial root when no domain is specified, or when solving over the complex numbers. Supported closed forms remain exact: x³ − 2x + 1 = 0 gives [1, (-1 + √5)/2, (-1 − √5)/2].
  • Compiled integer operations reject values outside the safe integer range instead of returning rounded answers. Compiled collection parameters reject unsupported string inputs.
  • Shader targets reject constant collections with more than 256 elements.
  • List literals with lazy spreads evaluate to ordinary lists.
  • Synchronous work that an evaluateAsync handler does after its first await (ce.box(), ce.parse(), evaluate(), canonicalizing a held operand) sees the global scope, not the index of an enclosing Sum. Run it inside ce.withEvaluationContext(options, fn).
  • DSolve writes a complex characteristic root and its conjugate as real modes: y''' - y = 0 gives c₂·e^(-x/2)·cos(√3x/2) + c₃·e^(-x/2)·sin(√3x/2).
  • In the lenient grammar, √2 √3 and √2 ∞ report ambiguous-radical (Tycho row 367).
  • A shader target declines a constant collection of more than 256 elements with the diagnostic code inline-collection-too-large, whose message names the element count and the limit (Tycho row 368).

Issues Resolved​

  • Concurrent async evaluations keep separate scopes, and async handlers resume in the correct scope. N, arithmetic, Evaluate and ReleaseHold yield and await async-only operations (#392).
  • Recursive functions accept whole collection arguments, lazy arguments retain caller bindings, and callbacks over named functions retain computed elements.
  • Exact Range elements remain distinct beyond 2^53. Append permits forward indexing into an infinite source (#422).
  • Seeded random collections retain their seed inside dictionaries, and spreading a map over a set runs its callback once per element.
  • Improved contour and residue integration with float bounds, affine circle notation, exact complex poles and trigonometric multiple angles. Poles inside infinite integration intervals no longer produce finite answers.
  • Contour integrals respect user functions that shadow library names and detect nearby singularities exactly.
  • Series(Zeta(s), s, 1, n) returns the requested Laurent expansion using Stieltjes constants. EllipticPi supports more complex arguments (#421, #422).
  • Improved types for odd real roots, Zip, Repeat, and collections whose elements may be absent.

Performance​

  • Numeric sums and products scale better with many terms. Constant numeric lists are cheaper to compile for interval evaluation.

0.149.0 2026-10-07​

Breaking Changes​

  • Round(x, step) now rounds to a multiple of step. Round(226, 10) is 230; Round(3.14159, 2) is now 4. To round to two decimal places, use Round(3.14159, 1/100) for exact 157/50, or Round(3.14159, 0.01) for float 3.14. Translate decimal-place arguments from other tools accordingly (#417).
  • Negative Take and Drop counts work from the end. Take([1, 2, 3, 4, 5], -2) is [4, 5], and Drop(..., -2) is [1, 2, 3]. Strings behave similarly: Take("hello", -2) is "lo". Negative counts over infinite or unknown-length sources remain unevaluated (#414, reported by enumeratio).
  • expr.count can return a bigint. Its type is now number | bigint | undefined; large finite counts use bigint, while infinite counts use Infinity. Check the type before arithmetic with a JavaScript number. For example, Count(Range(1, 10^20 + 1)) is exactly 100000000000000000001. Indexing from the end of collections with bigint counts is not yet supported (#416, reported by enumeratio).
  • Public complex types now use complex.js 2.4. Update imports of the Complex class from complex-esm to complex.js.

New Features​

  • ce.roundingTies selects the tie rule for Round: "away-from-zero" (default), "to-even", "toward-zero", "toward-positive-infinity", or "toward-negative-infinity". Compiled functions retain the rule selected at compilation. Remainder keeps its existing tie rule (#417, requested by enumeratio).
  • Floor, Ceil and Truncate accept a step: Floor(226, 10) is 220, Ceil(226, 10) is 230, and Truncate(-226, 10) is -220. Exact steps give exact results; float steps give floats (#417, requested by enumeratio).
  • Definite integration supports more piecewise functions, including If, Which, extrema and rounding functions: ∫₋₁¹ max(x, 0) dx is 1/2, and ∫₀³ floor(x) dx is 3 (#413, contributed by KingArth0r).
  • A count collection handler can return a bigint for any count; expr.count converts a safe integer to a number (#416).

Behavior Changes​

  • Running an Epsil program with executeEpsil or the epsil command removes the engine's default iteration limit, so a loop runs until it ends. The engine then keeps no limit for later evaluations, Epsil or not. To keep a bound, assign ce.iterationLimit before running a program.
  • The branch k of LambertW(z, k) must be an integer: LambertW(-1, -0.1) is an incompatible-type error. The branch can be named: lambertW(-0.1, branch: -1) in Epsil, or ["LambertW", -0.1, ["NamedArgument", "'branch'", -1]] in MathJSON (#418, reported by enumeratio).
  • simplify() combines powers with the same base in a product, as it does in a quotient: x^a·x^b is x^(a+b). evaluate() is unchanged (#415, reported by enumeratio).
  • String operators accept a character wherever they accept a string: ToUpperCase(s[1]) is no longer a type error. A symbol with no declared type used at such a parameter is inferred character | string, not string.
  • When the operand types do not select one signature of an overloaded operator, the result type is the union of the possible results: with s of unknown type, Slice(s, 1, 2) is typed list | string, not list.
  • Effect inference is more precise. A local that nested functions only read needs no scope effect, a function has the effects of its let values (let x = Random() makes it random), and a dictionary has the effects of its values ({"a" -> Random()} is random, not pure).

Issues Resolved​

  • Exact range counts, extrema and statistics no longer depend on rounded bounds. Large range statistics avoid enumeration: Sum(Range(1, 10^20)) can return its exact closed form.
  • Take and Drop clamp very large or infinite counts to a finite source's length. Collection queries stay symbolic when elements cannot be determined.
  • Improved numeric stability for very small or large norms and complex values: Norm([3e-200, 4e-200]) is 5e-200 at machine precision.
  • Float division avoids an extra rounding: 0.3/0.3 is 1. Compiled rounding of large integers and Remainder ties agrees with the interpreter.
  • Exact list sums and differences, irrational integration bounds and determinants retain exact values: [√2, 2] + [1, 1] is [1 + √2, 3]. Matrix power rules no longer combine element-wise and matrix operations (#415).
  • Polynomial operators read assigned values. Type inference narrows unions correctly and no longer mistakes collection parameters for matrices.
  • Improved PolyLog and LerchPhi at large orders and arguments; unsupported cases remain symbolic instead of returning inaccurate values (#412, reported by enumeratio).
  • LaTeX preserves parentheses around primed expressions, recognizes Unicode number-set letters, and accepts spaces before postfix operators. Epsil reports -> used in place of a lambda's => (#420, reported by enumeratio).
  • Lenient parsing reports ambiguous-radical for √2 x (Tycho row 366). \mathbb{Z}^- and \mathbb{R}^- name NegativeIntegers and NegativeNumbers.

0.148.0 2026-10-05​

New Features​

  • N(x, [p, a]) accepts significant-digit and absolute-accuracy goals, stopping when either is met. Use PositiveInfinity to disable one goal: N([π, 1000π], [PositiveInfinity, 3]) is [3.142, 3141.593]. The engine's precision is preserved; results remain symbolic if a goal cannot be met (#391).
  • Compiled JavaScript can run without a Compute Engine instance. Import createJavaScriptRuntime from @cortex-js/compute-engine/runtime and use runtime.load(result) to load a compilation artifact. Runtime versions must match. Functions supplied as source cannot retain enclosing closures (#372, contributed by enumeratio).
  • ResidueClass(k, n) represents modular values: ResidueClass(7, 5) is ResidueClass(2, 5), and 1/ResidueClass(3, 7) is ResidueClass(5, 7). Arithmetic requires compatible moduli and invertible divisors. QuotientRing(Integers, n) now contains residue classes rather than integers (#399, #411, contributed by enumeratio).
  • Contour integration supports circles, rectangles and simple polygons through ce.contourIntegrate(), ContourIntegrate, and LaTeX \oint notation. Reports include poles, residues and the integral. A pole on the contour gives Indeterminate; unsupported cases remain symbolic (#410).
  • More real integrals evaluate exactly using residues: ∫_{−∞}^{∞} sin(x)/x dx is π, and ∫₀^{2π} dx/(2 + cos x) is 2π/√3. Principal values must be requested explicitly with RealLineContour(True).
  • Complex numeric evaluation now covers Bessel and Airy functions, Sinc, Fresnel functions, and every integer branch of LambertW. Error functions and trigonometric integrals also support imaginary-axis arguments.
  • CompilationResult has the new fields runtimeVersion, preambleOnce, preamblePerCall, callCode, entryPlan and reconstructionDigits. createJavaScriptRuntime() accepts the options random, frame, iterationLimit and deadline. With random: null, a draw throws a CapabilityDeniedError; test e.name, not instanceof (#372).

Behavior Changes​

  • Absolute-value bars on a set or string give its size: |{1, 2}| is 2.
  • Solve returns a list only when it can establish completeness. Otherwise it stays unevaluated, and expr.solve() returns null. Unsupported computed collections are type errors rather than empty solution lists.
  • N consistently returns inexact numbers, preserves ce.precision, and rounds collection elements: N([1/3, Pi], 4) is [0.3333, 3.142] (#391, #409).
  • Numeric operations reject set operands rather than pairing them by position. Scalar function parameters broadcast over tuple components: Apply((u: real) ↦ 2u, (a, b)) is (2a, 2b).
  • Calls awaiting a list's value remain symbolic until it is known, preserving future broadcasting. Invalid scalar arguments give consistent type errors.
  • Argument/Arg returns angles in the engine's angular unit.
  • Lenient parsing reads √12 as √(12), multi-digit subscripts as whole subscripts, and sin^{-1}(x) as arcsin(x). It adds ambiguity diagnostics for inputs with another common interpretation.
  • The default time limit for loadIntegrationRules() is now 120 seconds.
  • evaluate() returns a sequence term that is exact but not a number literal: for B_n = π·B_{n−1}/n with B_0 = 1, B_10 is π^10/10!; it stayed unevaluated. ce.getSequenceTerms() returns such terms, and ce.getSequenceCache() holds only exact terms.
  • When the unknown has no declared type and no domain is given, Solve gives all roots of a polynomial, complex roots included (x^2 + 1 = 0 is [i, −i]), and only the real solutions of other equations. A parameter of the equation with no declared type is real.
  • N(Measurement(v, δ), p) keeps the uncertainty and rounds v, so N(Integrate(…), p) is a Measurement.
  • Derivative(f) is a function literal also when its closed form holds the derivative of another function: with h(x) := g(x)·x, Derivative(h) is (x) ↦ x·Apply(Derivative(g, 1), x) + g(x).

Issues Resolved​

  • Dictionaries and strings containing special characters round-trip through LaTeX and MathJSON. Fractions, negative terms, derivatives and compound powers serialize with the grouping needed to preserve their meaning (#345).
  • Lenient parsing preserves digits, Greek names, function subscripts and script order. Epsil accepts f(x) := body as a function definition (#400, contributed by enumeratio).
  • Solve respects bounded domains and angular units, solves more systems and avoids incomplete root lists. Lambert W templates retain only real roots where real solutions are required.
  • Improved real and complex special-function accuracy, including Bessel, Fresnel, error and integral functions. LambertW(-1/e) and LambertW(-1/e, -1) are exactly -1.
  • Limits at infinity respect complex values and unknown coefficient signs; NLimit at −∞ samples negative arguments. Divergent integrals and numeric error estimates are handled more reliably (#396, #405).
  • Lazy collections retain local bindings and update when sequence dependencies change. Unavailable elements no longer make finite collections appear empty.
  • Point norms validate component lengths, handle empty point lists and compile on more targets. Function literals and named calls enforce parameter types consistently.
  • Added symbolic derivatives for Arctan2 and more multi-argument functions; partial derivatives compile and evaluate more efficiently.
  • Simplification preserves list shape, respects user replacements of library functions, and applies angular identities only in radians. Integration also respects user function definitions and avoids unbounded searches.
  • Shader compilation supports function primes, fixed-length maps, vector squares and more applied derivatives.
  • A memoized sequence term is computed again when a symbol that its recurrence reads changes. A first read of a large index no longer overflows the call stack: F_2000 of the Fibonacci recurrence gives its 418 digits.
  • Arcosh(0) and Arcoth(0) evaluate to iπ/2, and EllipticK(0) and EllipticE(0) to π/2. A sum whose radical-coefficient terms cancel is 0: (√2/4·x).sub(√2/4·x) was 0·x (#409).
  • Count of set operations is correct: Count(SymmetricDifference({1, 2, 3}, {2, 3, 4})) is 2; it was 1.
  • With a user value of Pi, evaluate() no longer uses the library π: with Pi equal to 3, sin(π) is sin(3); it was 0.
  • D(f(x), f) stays symbolic; it was 0.
  • A product whose LaTeX ends with a symbol keeps a \times before a parenthesized factor: 2v\times(x+1). The form 2v(x+1) read back as a call of v.
  • Compiled JavaScript calls the current value of a block-local function that is assigned again: let h = (k) => k + 1; h := (k) => k + 2; h(3) gives 5, as in the interpreter. It gave 4.

0.147.0 2026-10-02​

New Features​

  • Added Wolfram Language aliases EulerPhi, PartitionsP and Det.
  • Lenient parsing reports more ambiguous notation, including inverse functions, primes and scripts.

Behavior Changes​

  • Totient and NPartition evaluate at zero and negative integers.
  • Exact non-integer factorials remain symbolic under evaluate(); .N() follows the working precision and supports factorials beyond float64 range.
  • Host complex values with integer parts are exact; computed complex floats retain their inexactness, including very small components.
  • Arctan and Arccot use branch-cut values consistent with odd symmetry: Arctan(-2i) is approximately −π/2 − 0.549i.
  • Lenient parsing gives ** the same precedence as ^: -x**2 is -(x²). Chains containing only ** still associate from the right.
  • An exact complex number divided by an exact number is one exact complex literal: i/3 is ["Complex", 0, ["Rational", 1, 3]], and 2/i is -2i.
  • Numeric routines return floats also when the value is an integer: NIntegrate(1, 0, 2) is 2.0, not 2. This also applies to NLimit, ND, NDSolve, FindFit and FindRoot.
  • The rational eigenvalues of an exact matrix larger than 3×3 are exact, and Eigenvectors then gives exact vectors.

Issues Resolved​

  • Exact multiples of π reduce correctly under .N() and in compiled code. Compiled negative-base powers agree with the interpreter's real-root rules.
  • Improved complex Gamma, Factorial, BarnesG and inverse trigonometric evaluation beyond the double range. Branch cuts, small imaginary parts and high precision complex results are preserved.
  • Compiled JavaScript retains small complex components and consistently represents complex infinity. Compiled StieltjesGamma is accurate through order 30.
  • Numeric functions evaluate random operands once. NaN components propagate correctly through complex results.
  • Lenient parsing handles spaces and duplicate superscripts consistently; primed function names and factorials serialize with correct grouping.

0.146.0 2026-10-02​

New Features​

  • Added modular roots and reconstruction: PowerModList(3, 1/2, 11) is [5, 6], PowerMod(4, 1/2, 7) is 2, and RationalReconstruction(6, 11) is 1/2. Also added PrimitiveRootList and a target-list form of MultiplicativeOrder.
  • Added DirichletEta, DirichletBeta, DirichletCharacter and DirichletL: DirichletEta(1) is ln 2, DirichletBeta(1) is π/4, and DirichletCharacter(5, 2, 2) is i.
  • Added generalized Stieltjes constants: StieltjesGamma(0) is EulerGamma; StieltjesGamma(n, a) supports orders through 30.
  • Added LogGamma, BarnesG and LogBarnesG. LogGamma is the analytic continuation of log gamma, with branch values distinct from GammaLn. BarnesG(5) is exactly 12; PolyGamma(-1, z) is LogGamma(z).
  • Added ClausenCl(n, θ): ClausenCl(2, π/2) is CatalanConstant.
  • Added StirlingS2 as an alias for Stirling.

These additions were contributed by enumeratio (#395).

Behavior Changes​

  • Integrals, sums and products with only one bound remain unevaluated.
  • Divergent definite integrals return signed infinity or Indeterminate when divergence is established. Unresolved pole locations remain symbolic.
  • NIntegrate tries quadrature first, and constant numeric integrals can be folded during compilation.
  • PrimitiveRoot and MultiplicativeOrder ignore the modulus sign.
  • In \sum_{k=1} and \prod_{k=1}, the subscript is the lower bound, not the upper bound.
  • A sum with a lower bound and no upper bound is no longer a sum to infinity under .N(). For an infinite sum, write the upper bound +∞ (\sum_{k=1}^{\infty}).
  • The signature of NIntegrate is (function, lower:number, upper:number) -> number. A Tuple of bounds or a missing bound is an error. For a multiple integral, use Integrate(f, Limits(x, …), Limits(y, …)).N().
  • Compiled Python inverse trigonometric and inverse hyperbolic functions of a complex argument always return a Python complex, also when the value is real.

Issues Resolved​

  • Numeric integration handles endpoint singularities, moving poles, nested divergent integrals and reversed bounds more reliably. Slowly converging integrals have improved values and error estimates.
  • Oscillatory semi-infinite integrals work in interpreted and compiled code: ∫₀^∞ sin x/x dx is π/2 to about 11 decimal places.
  • Definite integration detects poles from assigned values, constants and logarithmic denominators. Flat MathJSON bounds parse consistently for integrals, sums and products.
  • Improved inverse trigonometric and hyperbolic accuracy for large, small and complex arguments. Python and shader targets follow the correct branch cuts.
  • GammaLn is more accurate near 0, 1 and 2. Compiled JavaScript preserves small complex results.
  • FromDigits and ChineseRemainder reject non-integer elements; infinite input collections no longer exhaust memory. Stirling and Eulerian numbers outside their valid triangle return 0.
  • Redefined control operators compile as user definitions. Copies of library operators retain their names and supported derivatives.
  • Missing arguments and invalid logarithm arity report normal expression errors. Restrictions with infinite condition lists no longer run out of memory.
  • ∫ ln(x + 1) dx is (x + 1)·ln(x + 1) − x. It was (x + 1)·ln(x) − x + 1, and ∫₀¹ ln(x + 1) dx was +∞.
  • Compiled Python Arccot of a complex argument with a negative real part is no longer off by π.

0.145.0 2026-10-01​

New Features​

  • Add notation after engine construction with ce.latexSyntax.addEntries(entries) (#393).
  • Load custom libraries with ce.loadLibrary(library), and query a definition's library with ce.libraryOf(name) (#393).
  • Extend an operator with ce.declare(name, patch, { extend: true }). Unspecified fields are retained; addSignature adds an overload (#394).
  • Operator definitions can provide partial derivatives with the derivative key. Evaluation handlers receive a precision option for numeric requests (#393).
  • Lenient parsing reports more ambiguous-* diagnostics for notation such as x^2y, sin x y, 1..10..2 and log(x, 2). Use ce.parse(text, { strict: false, onAmbiguity: 'error' }) to turn ambiguous readings into expression errors; the default is 'report'.
  • The new exponentialE serialization option sets how ExponentialE and Exp are written. The imaginaryUnit option now also sets how ImaginaryUnit and complex numbers are written. The defaults are \exponentialE and \imaginaryI.

Behavior Changes​

  • A name in parentheses is a factor: (f)(x) is a product.
  • Lenient parsing accepts +- as ± and -+ as ∓.
  • Four diagnostic codes are renamed; update handlers that match them: letter-run-split → ambiguous-letter-run, implicit-product-in-denominator → ambiguous-denominator, spaced-digit-groups → ambiguous-digit-groups, and letter-before-decimal → ambiguous-letter-decimal.
  • LaTeX output uses standard notation for LCM, Log2, Log10, number sets and Degrees (#345, contributed by enumeratio).
  • A named call through a variable whose declared type gives no parameter names is the error argument-names-unavailable. For a variable declared with an unnamed signature, the error code was argument-name-unknown. Call such a variable with positional arguments, or declare it with a signature that names its parameters.
  • Lenient parsing reads mod, pow, trunc, Re and Im before a parenthesis as Mod, Power, Truncate, Real and Imaginary: mod(7, 3) evaluates to 1.

Issues Resolved​

  • Declared symbols mapped with vars remain run-time inputs even when they have assigned values. Integrals over mapped inputs retain closed forms. To force numeric quadrature with a mapping, use source other than a plain read, such as { a: '(_.a)' }.
  • Python and shader targets reject reserved identifier mappings and names.
  • Calls through record and dictionary fields respect declarations and named arguments. Caller symbols sharing parameter names retain their meaning (#390).
  • Custom libraries load in the requested dependency order, and standard-library selection includes required dependencies. Failed declarations restore the previous binding.
  • Derivatives and compilation respect unchanged copies of library definitions (#394). Minimal library configurations can construct function literals.
  • Numeric integration uses adaptive quadrature even when an integrand cannot compile. Imaginary factors serialize without unnecessary parentheses.
  • A prefix \mp or ∓ has a reading: \mp 1 is MinusPlus(0, 1). Calls whose arguments contain Floor, Ceil, Round or Truncate are no longer much slower than in 0.141.0.

0.144.0 2026-10-01​

New Features​

  • Lenient parsing accepts more plain-text notation: signed and compound exponents, full letter subscripts, in, infinity, sign, sgn, chained comparisons and √(x+1). Optional diagnostics flag ambiguous readings.
  • About reports examples and keywords, and lists operator attributes, including laziness (#398).

Behavior Changes​

  • Solve returns a free parameter for identities and remains symbolic when it has no justified candidate root (#397).
  • Exact rational Range bounds and steps produce exact elements (#397).
  • Logarithm combination and splitting require provably non-negative arguments (#397).
  • The attributes entry of About is a list of strings, not one string: About(Add) gives ["commutative", "associative", "idempotent", "lazy"]. A program that read the entry as a string must read it as a list.

Issues Resolved​

  • Solve handles more linear and logarithmic equations and preserves exact exponential roots: Solve(eˣ = 3, x) gives ln(3).
  • Derivatives respect custom library definitions and user replacements: after Sinh(x) := 3x, D(Sinh(t), t) is 3 (#393).
  • Library loading order and operator signature extensions behave consistently (#393, #394).
  • Corrected definition metadata and exact zero cases of LerchPhi (#397).
  • LaTeX avoids duplicate grouping, recognizes arccot, and reads scientific notation with a braced base 10. The resolveApplication hook sees names before parentheses consistently in lenient mode.

Performance​

  • Faster compiled JavaScript range processing and scalar callbacks (#387, #388). Deeply nested lenient exponents parse quickly.

0.143.0 2026-10-01​

New Features​

  • ReplaceAt, DeleteAt and Insert compile to JavaScript and Python (#386).
  • Max, Min, Length, Count, Sum and Product of an operand typed as an abstract collection compile to JavaScript (#385).

Behavior Changes​

  • Nested list types describe their corresponding matrix shapes.
  • Sum and Product preserve point, row and matrix result types.
  • Extrema of dictionaries and sums of nonnumeric scalar elements report type errors consistently.
  • A symbol declared with a nested list type is a matrix: with x declared list<list<integer^2>^2>, x^2 is the matrix power, not the square of each element.

Issues Resolved​

  • Compiled sums, products and reductions handle real and complex collections correctly, or decline unsupported shapes instead of returning NaN or strings.
  • Numeric Reduce over rows works without an initial value: Reduce([[1, 2], [3, 4]], Add).N() is [4, 6].
  • Compiled statistics no longer return incorrect values for nested lists, and annotated parameters validate absent arguments.
  • Arithmetic methods preserve assigned-variable dependencies; multiplication by zero no longer hides an assigned NaN.
  • Corrected function assignment for parameters that admit missing values, dictionary-join diagnostics and missing operands of Sum/Product.
  • HurwitzZeta supports integer orders with arguments far left of the imaginary axis. Simplification propagates NaN through extrema.

Performance​

  • Registering chains of dependent functions no longer takes exponential time.

0.142.0 2026-09-30​

New Features​

  • ce.conformsTo(type, protocol) queries protocol conformance (#362, contributed by enumeratio).
  • Collection length slots accept type variables. DMS angles accept additional arc-minute and arc-second spellings and can omit minutes (#364, proposed by enumeratio; #338, contributed by yelliver).

Behavior Changes​

  • Rounding, step functions and comparisons use exact operands under .N(). Two exact numbers compare without tolerance, including near boundaries.
  • Full materialization returns every element of a finite lazy collection (#380, reported by enumeratio).
  • Functions enforce explicitly annotated parameters, while unannotated parameters retain inferred behavior. Absent arguments at annotated parameters are errors; numeric parameters still accept NaN.
  • Assignments store the current elements of lazy collections that depend on variables. Known valid literal indices no longer add an absent result type.
  • Range, Linspace, comprehensions and Tabulate are typed list<T> (Tycho row 337).
  • A rounding function, Fract, Mod, Sign, Heaviside or a comparison of an exact constant expression is resolved under evaluate() by raising the precision: Floor(π·10³⁰) is the exact integer 3141592653589793238462643383279, and Fract(π) is π − 3.
  • To get the display preview from evaluate(), pass materialization: [5, 5]. toString() and .latex still print the preview.
  • To accept an absent argument at an annotated parameter, annotate it T | missing and test isMissing(p), or remove the absent case at the call (first(…) ?? fallback).
  • A function declared with ce.declare(name, { signature }) checks its evaluated arguments against the scalar parameter types. With f declared (integer) -> unknown and assigned x ↦ x + 1, f(1.5) gave 2.5 and is now an incompatible-type error.
  • A parenthesized derivative operand ends at its closing parenthesis: \frac{d}{dx}(x)+1 is D(x, x) + 1, where it was D(x + 1, x). A factor written after the parenthesis is outside the derivative: \frac{d}{dx}(x+1)(x-1) is D(x+1, x)·(x-1) (#336).

Issues Resolved​

  • Improved HurwitzZeta, Zeta, LerchPhi, PolyLog, incomplete gamma and high-order polygamma evaluation near poles, branch boundaries and difficult orders. More real arguments follow working precision; unreliable cases stay symbolic. More special functions broadcast over lists (#374, contributed by enumeratio).
  • Interval enclosures for Gamma, GammaLn, Binomial and Factorial contain their true values. Compiled binomial values agree outside 0 ≤ k ≤ n (#384, reported by enumeratio).
  • Lazy operators and compiled JavaScript handle collections that may be absent. Sums of points and rows retain their shape (#383).
  • Folds that build lists compile and preserve accumulator types. Recursive collection functions and destructuring loop declarations compile correctly (#369, reported by enumeratio; Tycho rows 338 and 339).
  • WGSL shaders using NaN or infinity compile in browsers. Shader LerchPhi and PolyLog support more arguments and improved accuracy.
  • Unsupported searches remain symbolic instead of reporting index 0. Invalid infinite indices and indeterminate integrands return consistent values (#368, reported by enumeratio).
  • Epsil serialization scales linearly with nesting depth. Nested function slots, complex literals, NaN patterns and absent set spreads behave consistently (#379, #381, reported by enumeratio).
  • Protocol dispatch binds Self to the selected implementation's type. Compiled ranges no longer capture user variables named i or _e (#362, contributed by enumeratio; #367, reported by enumeratio).
  • Floor, Ceil, Round, Truncate and Fract of an exact number are exact: Floor((25! − 1)/24!) was 25 and is now 24, and Fract((25! − 1)/3) was −1/3 and is now 2/3 (#382, reported by enumeratio).
  • A Pipe stage that uses no topic slot and whose value is not a function is an incompatible-type error: 5 |> Max(3) gave 3.
  • ce.number([n, 0]) is ~oo for n ≠ 0, as ce.box(["Rational", n, 0]) is; it was NaN. ce.number([0, 0]) is Indeterminate.
  • The materialization option no longer truncates the operands of an eager operator: Length(Range(1, 5000)).evaluate({ materialization: true }) was 11.
  • A fold's accumulator is typed from its seed and its combiner's result. Map and Filter over a fold that builds a list give a list: they gave a set (Set(7, 3, 5) where the fold's value is the list [6, 2, 4]).

Performance​

  • A predicate over a finite Range compiles to a counting loop on the JavaScript target, without building the range: Count, CountIf, Any, All, Length(Filter(…)) and Filter over a range (#373).
  • A protocol member call no longer parses the member's signature again at each call. A call on a scalar receiver costs about 1.3× an equivalent plain function, where it cost about 12× (#363, contributed by enumeratio).

0.141.0 2026-09-29​

New Features​

  • Added Indeterminate and isIndeterminate for exact indeterminate forms. 0/0 and other exact indeterminate calculations now use this value; .N() and compiled code use NaN. It is distinct from an absent value: IsMissing(Indeterminate) is False (#355).
  • epsil --compile compiles and runs JavaScript, using machine arithmetic. Unsupported constructs and unbound symbols report runtime errors.
  • ce.withStepBudget({ steps, label }, fn) bounds cooperative engine work. Exhaustion throws CancellationError with cause 'step-budget' (Tycho row 326).
  • Added LerchPhi(z, s, a), the Lerch transcendent, and support for non-integer and complex orders of PolyLog: PolyLog(2.5, 0.5).N() is about 0.5549972787175124. Unsupported numeric cases remain symbolic (#340, contributed by enumeratio).
  • PolyGamma, Digamma and Trigamma support complex arguments (#340, contributed by enumeratio).
  • Added exact eigenvalues for supported 2×2 and 3×3 matrices, plus evaluation of AdjugateMatrix and PseudoInverse.
  • PolyLog and LerchPhi compile to JavaScript, GLSL and WGSL for real operands.
  • CancellationCause gains 'step-budget', and isTimeoutCancellation is true for both a timeout and a spent step budget.

Behavior Changes​

  • Decimal spellings preserve floats through Epsil and LaTeX round trips, including integer-valued floats. Rounding a float produces an exact number (#351).
  • User functions that pass parameters to whole-collection operators receive lists whole. Operations on abstract collection types preserve possible broadcast result types.
  • Restricted infinite or unknown-length collections pair with list conditions over the conditions' length. List literals with spreads remain lists.
  • Indeterminate is a reserved name.
  • Code that tests a result with x.isSame(ce.NaN) no longer matches an exact indeterminate form: use x.isNaN, which is true for both values, or x.isIndeterminate. ["Divide", 0, 0] canonicalizes to Indeterminate.
  • A list literal with a spread canonicalizes to the new operator ListJoin: [...a, 0] is ListJoin(a, [0]), where it was Join(a, [0]).
  • An integer-valued float serializes as 2.0 in LaTeX and {num: "2.0"} in MathJSON, so it reads back as a float; it was 2. With fractionalDigits: 0 (LaTeX) or digits: {fractional: 0} (MathJSON) the integer spelling is kept.
  • -(2x) canonicalizes to Multiply(-2, x); it was Negate(Multiply(2, x)). -\frac{x}{2} is Multiply(-1/2, x), the same expression as -\frac{1}{2}x (#345, contributed by enumeratio).
  • Beta(2, 3), Zeta(3) and Lb(x) serialize to LaTeX as \mathrm{B}(2, 3), \zeta(3) and \log_2(x). A negative numeric fraction writes its sign in front: Rational(-1, 2) is -\frac{1}{2}, Divide(x, -4) is -\frac{x}{4}.
  • Map, Filter and Scan over a symbol declared collection<T> that holds a list give a list: Map(x ↦ 2x, P) for P = [3, 1, 1] is [6, 2, 2], where it was Set(6, 2).
  • ce.Zero.div(ce.PositiveInfinity) is 0, as the Divide operator gives; it was NaN. A float numerator over an infinity is the float 0.

Issues Resolved​

  • Closures preserve nested block locals and each loop iteration's variable. Recursion no longer overwrites caller indices or locals (Tycho row 330).
  • Unannotated Epsil helpers and collection-building recursion compile to JavaScript. Count(xs, predicate) also compiles (Tycho row 332).
  • vars mappings remain dynamic when reached through assigned expressions; program-defined functions are excluded from free symbols (Tycho row 328).
  • Definite integration handles absolute values and signs of linear arguments, integration by parts, and integration variables with assigned values (#352).
  • Improved hypergeometric accuracy and high precision real zeta evaluation. Complex Arcosh and incomplete gamma select correct values (#353, #354; #340, contributed by enumeratio).
  • Unknown-length indexing stays symbolic, collection restrictions retain their types, and list and set spreads use the supplied argument's elements (Tycho rows 331, 334 and 335).
  • Derivatives, negative large numbers, beta and zeta functions, logarithms and matrix rank serialize correctly. Epsil handles a * 2n and library names in the MCP compile tool (#345, contributed by enumeratio; Tycho row 333).
  • A compiled for loop over a list of complex values reads the loop variable as complex. It read it as real, so comparisons on it were false and divisions gave NaN (Tycho row 332).
  • More exact special values: Log(1/8, 2) and Lb(1/8) are -3, Sinc(Pi) is exactly 0, Arccot(1) is π/4, and ComplexRoots(1, 4) is [1, i, -1, -i].

Performance​

  • Type queries and declarations for nested dependent functions no longer take exponential time (Tycho rows 329 and 336).

0.140.0 2026-09-27​

New Features​

  • ce.number({ re, im }) accepts JavaScript numbers or BigDecimal values for each component.
  • isComplex is public on the number-literal interface.

Behavior Changes​

  • Decimal spellings consistently produce floats at machine precision, and a float operand makes numeric results inexact.
  • Integrate applies coordinate by coordinate to tuples.
  • More logical connectives use three-valued logic for absent operands. Compiled absent points, tuples and lists use undefined in arithmetic.
  • User bindings shadow capitalized library names. Binding Nothing, Missing or Undefined is an error.
  • Length of an infinite collection is +oo. StringFrom of text or a symbol returns its text.
  • serializeEpsil writes the lowercase spelling of a library name: ["Sin", "x"] is sin(x) and "Pi" is pi. The new option libraryNames: 'mathjson' restores the previous output, and the new isBound option reports names bound outside the expression.
  • A round trip through parseEpsil alone gives the raw lowercase head (["sin", "x"]). Run resolveLibraryNames to read it back to Sin.
  • isMachineNumeric is false for a number or list that holds an integer-valued float such as 2.0.
  • Timing returns an unnamed tuple, tuple<number, value>: read the parts as Timing(expr)[1] and Timing(expr)[2]. Timing(expr).result was an incompatible-type error.
  • x^{0.5} stays Power(x, 0.5). Only an exact 1/2 becomes \sqrt{x}.

Issues Resolved​

  • Complex results retain working precision and imaginary components outside double range. Exp(Ln(−2)).N() is −2.
  • Mod retains remainders for floats near the double range. Compiled logical operations preserve three-valued results.
  • GroupBy supports character keys. Epsil serializes library names and dictionaries containing lists correctly.

0.139.0 2026-09-27​

New Features​

  • Epsil comprehensions build lists, sets and dictionaries: [x^2 for x in 1..10 if x % 2 == 1], {x % 3 for x in 1..10}, and {s -> length(s) for s in ["ab", "cde"]}. Later clauses can use earlier bindings, and tuple patterns can destructure elements.
  • Comprehension and Loop clauses accept guards.
  • SVD supports complex matrices under .N().

Behavior Changes​

  • Decimal literals such as 1.0 and 0.0 consistently remain floats. 1.0/3 is a float, and x^1.0 retains its exponent. Integer-valued BigDecimal inputs to ce.number() are exact.
  • Derivative LaTeX puts the differentiand in the numerator: D(x², x) is \frac{\mathrm{d}x^2}{\mathrm{d}x}, preserving the meaning when other terms follow it.
  • A float 0 or ±1 produced by a computation is kept: 0.5·2x evaluates to 1x (it was x), 0.5x + 0.5x is 1.0·x, and 0.0 + x stays x + 0.0. A float 0 still absorbs a product: 0.0x is the float 0.
  • GCD, LCM and Factorial2 of big integers are exact at the default precision. They were floats.
  • Compiled comparisons with a Missing or Undefined operand give the undecided value (undefined, Python None). They answered false. An If or Which over such a comparison takes no branch and gives NaN.
  • A lazy collection whose element type has a nan member from an element read is accepted at a list, collection or indexed-collection parameter when its element type without nan fits. An eager list that holds NaN is still rejected.

Issues Resolved​

  • Complex division avoids overflow and underflow, including in compiled JavaScript. Real and imaginary components retain working precision.
  • Compiled comparison chains preserve decided links with absent operands; interpreter fallback uses the target's absent-value representation.
  • Lazy collection operators evaluate computed sources and retain absent element types. Map with a named callback uses its signature.
  • List-valued integrands integrate element by element. Hyperbolic and angular special values evaluate consistently.
  • Xor and Equivalent accept absent operands, and failed operands preserve their error type.
  • Comprehension clauses see only earlier indices; free-symbol reporting and Epsil set-builder serialization follow clause order.
  • An exact base raised to a rational power stays exact: 8^{2/3} is 4, and (8x+8)^{2/3}·y no longer gives a float. A root that is not exact stays a symbolic factor. (-8)^{2/3} is 4, the real root (it was 8^{2/3}·i).

0.138.0 2026-09-27​

New Features​

  • Zeta supports complex orders and a second argument; added HurwitzZeta. For positive real base points, Zeta(s, a) agrees with HurwitzZeta(s, a). Zeta(2, 1/2) is exactly π²/2. Complex and two-argument numeric values use machine precision in this release. Both compile for real operands (#340, contributed by enumeratio).

Issues Resolved​

  • Hyperbolic special values evaluate and simplify: Sinh(0) is 0, Cosh(0) is 1, and Coth(0) and Csch(0) are ComplexInfinity (#341, contributed by enumeratio).
  • More number theory functions evaluate on large prime squares. ModularInverse(3, -7) is -2, and large multiplicative orders are faster (#339, contributed by enumeratio).
  • Corrected complex and incomplete EllipticE, including large arguments (#346, contributed by enumeratio).
  • Powers, nested quotients, factorials and derivatives serialize with grouping that preserves their meaning: (1+i)^2 retains its parentheses (#345, contributed by enumeratio).
  • Corrected three Chebyshev identities used by loadIdentities() (#343, contributed by enumeratio).

0.137.3 2026-09-26​

Issues Resolved​

  • Function result types update when the functions they call change.

0.137.2 2026-09-26​

Performance​

  • Functions with unknown parameters no longer cause slow type updates in other functions.

0.137.1 2026-09-26​

Issues Resolved​

  • Functions declared with an unknown result infer current result types, including recursive functions.
  • Declaring mutually dependent functions no longer takes minutes.

0.137.0 2026-09-26​

Behavior Changes​

  • Arithmetic and numeric functions have more precise types for infinities, poles, bounds, scalar-or-list unions and absent results. Functions declared with an unknown result report the type inferred from their body.
  • Function literals broadcast over collections like named functions. Tuple arguments remain whole where appropriate, and unequal list lengths report dimension errors. Pipe stages follow the same rules as direct calls.
  • Absent numeric collection reads return NaN; operations over absent collections return the appropriate absent result. Searches find explicit NaN, Missing and Undefined entries.
  • A predicate returning Missing does not select an element. Membership remains symbolic when an unknown element could change the result.
  • A user function mapped over lists of different lengths reports incompatible-dimensions: for f := (x, y) ↦ x + y, f([1, 2], [1, 2, 3]) was [2, 4]. Use Zip to truncate.

Issues Resolved​

  • Numeric powers, roots and exponentials retain very small complex results and handle exact values beyond double range. At machine precision, integers beyond double range approximate to signed infinity.
  • Nonconvergent infinite series remain symbolic. Range extrema and membership handle endpoints correctly.
  • Matrix norms and eigenvalue routines handle extreme entry magnitudes; repeated eigenvalues receive a basis of eigenvectors.
  • Reductions preserve absent entries, sorting by a key puts them last, and joins with unresolved conditional operands remain symbolic.
  • Multiple integrals evaluate scalar bounds once. Rebuilt sums, products, loops and comprehensions retain index types.
  • Compiled JavaScript handles complex division and recursive complex matrices. Python norm, coordinate and absent-operand handling is more consistent; shader and interval targets handle poles and tiny angles more reliably.
  • ((2·10^{30}+1)/10^{30})·2^x no longer simplifies to 2^{x+1}.
  • Compiled JavaScript: a Sum with a square root that is real by its type no longer gives NaN.

0.136.2 2026-09-25​

Issues Resolved​

  • Function result types follow their bodies for nan | real arguments.
  • Multiplying a list by a factor that may be NaN retains NaN in the element type.

0.136.1 2026-09-25​

Behavior Changes​

  • Arithmetic methods treat Missing and Undefined as NaN rather than dropping them. Numeric out-of-range reads and traces of absent values also return NaN.
  • Sort and Ordering put absent elements last.
  • SVD returns singular values in descending order and handles a wider range of entry magnitudes.
  • Canonicalization no longer removes an absent operand from arithmetic: Multiply(0, 2, Missing) and Divide(Missing, Missing) are NaN (they were 0 and 1).

Issues Resolved​

  • Small singular values are retained for matrices with widely varying entries.
  • Negative even roots preserve their principal complex values; ce.number(-8).root(-3) is −1/2.
  • Point subtraction propagates absent values as Missing. Collection selection and reordering preserve absent entries in their result types.
  • Restricted points, lists and point lists compile correctly for interval evaluation. JavaScript sorting, out-of-range reads and restricted point arithmetic agree with the interpreter.
  • Arithmetic retains existing errors instead of replacing them with new ones.
  • At high precision, the square root of a negative real is imaginary: √(−4) is 2i (it was NaN).

0.136.0 2026-09-25​

Improvements​

  • Functions consuming whole lists infer and compile their results from the actual arguments. Seeded list draws compile on interval-js.

Behavior Changes​

  • A false numeric restriction gives NaN; an undecided restriction over a list remains a single restricted value. Operations on absent collections return the absent value appropriate to their result.
  • Undefined elements and arithmetic operands are typed as absent.
  • Dot([1, Missing], [1, 1]) is NaN.
  • Csc and Cot of a tiny nonzero angle give a signed infinity: Csc(5e-324) is +oo and Csc(-5e-324) is -oo (both were ~oo), also in compiled JavaScript and Python.

Issues Resolved​

  • Exact complex roots and exponentials retain symbolic values: √i is (√2/2)(1+i). Imaginary integer literals are exact.
  • Rational numeric approximation is correctly rounded at machine precision. Trigonometric functions handle large exact integers, large multiples of π and exact special angles correctly.
  • Number literals follow precision changes, large doubles use exponent notation, and ComplexInfinity has a consistent MathJSON form.
  • Perfect-power roots of large integers evaluate. Artanh near zero and Zeta near its pole retain small differences.
  • Points, matrices and lists preserve absent coordinates and elements; coordinate operations, norms and mapped functions return NaN or Missing consistently with their result types.
  • Range retains endpoints on its step grid. Compiled ranges respect compound bounds and steps, and shader coordinate access handles absent points.
  • Map consistently rejects reversed argument order. Parenthesized list arguments and products before comprehensions parse correctly.
  • Large nested delimiters no longer overflow the stack. Seeded interval draws and numeric operations over measurements evaluate correctly.
  • On interval-js, a point within rounding error of a pole of Tan, Cot, Sec or Csc is singular. It gave a very large finite interval, which plotted as a wrong segment.
  • A bare function over a list compiled to wrong values: s(L) returned { re: NaN, im: NaN } objects instead of numbers.

0.135.0 2026-09-25​

Improvements​

  • Custom operators can set threadsConditionals to preserve restrictions around whole-value operations: f(When(v, c)) becomes When(f(v), c).
  • simplify() applies more loaded Fungrim identities, including Hypergeometric0F1(3/2, -z²/4) → Sinc(z), and retains simpler results.
  • The Epsil CLI accepts LaTeX input and output: epsil --from latex --latex -e '\frac{1}{2}+\frac{1}{3}' prints \frac{5}{6}.
  • Epsil MCP tools accept and serialize LaTeX. The compile tool generates JavaScript, GLSL, WGSL, Python or interval code with free-symbol types.
  • The Epsil MCP server serves a Compute Engine guide for AI agents as the resource epsil://docs/compute-engine-api.

Behavior Changes​

  • A parenthesized point with list coordinates represents a list of points.
  • Point arithmetic rejects invalid scalar additions, point products and divisions consistently, including restricted values and lists of points.
  • Map over a range or indexed collection has type list<T>, and rational products have rational types.
  • With A and B lists, (A, B) canonicalizes to PointList(A, B) and serializes as \operatorname{PointList}(A, B).
  • Arithmetic or a function such as Sin applied to a Tuple with a list coordinate is an incompatible-type error: Sin(Tuple([1, 2], 3)) was ([sin(1), sin(2)], sin(3)). Use PointList(A, B) for a list of points.
  • In strict mode, [1,2,3][1:2] is an incompatible-type error (it stayed unevaluated). Use [1,2,3][1...2].
  • Sin, Power and a scalar factor of a restricted point whose condition is false give Missing, not NaN. Test the result for Missing instead of using isNaN.
  • Compilation error messages start with what could not be compiled, then give the reason. Code that matched the old text must match the new text; diagnostic.code is unchanged.

Issues Resolved​

  • Restricted points and point lists work with Distance, Norm, Dot, Cross and coordinate accessors. False restrictions give Missing; numeric restrictions applied element by element preserve absent cells.
  • Compiled point arithmetic, norms and distance agree with the interpreter. Free-symbol reports correctly exclude block locals, and compilation failures give clearer reasons.
  • Error functions broadcast over lists. Types of large element-wise results preserve the expression's declared result and possible missing values.
  • Bracketed lists, spaced indices and point-list coordinates parse and round-trip correctly. Empty collections serialize compactly.
  • Polynomial GCD and simplification avoid unreliable calculations over long floats. Invalid function assignments report errors consistently.

Performance​

  • Faster scalar interpretation, ordering of nearby constants and repeated numeric derivative evaluation.

0.134.0 2026-09-24​

Behavior Changes​

  • Ordering functions distinguish nearby values without tolerance: Max(1e-12, 2e-12) is 2e-12. Relational comparisons retain their tolerance. Sort and Ordering stay symbolic when order is undecidable and put NaN last.
  • Rgb preserves extended sRGB channels. Color output uses gamut mapping: ColorToString(Oklch(0.7, 0.4, 30)) is #ff5843. Added GamutMap(color, gamut?) and Display P3 output. Hsv and Hsl clamp saturation, value and lightness and normalize hue.
  • Only safe JavaScript integers are boxed as exact integers. Use a bigint or numeric string for an exact large integer. Float trigonometric arguments remain inexact; use Sin(Pi) for an exact special value.
  • Norm(A, 2) is the matrix spectral norm. Use Norm(A) or Norm(A, "Frobenius") for the Frobenius norm.
  • Compiled JavaScript linear algebra checks whether real inputs contain complex entries. mode: 'complex' selects complex computation; entryChecks: false disables entry checks.
  • Symbolic integration supports a stepBudget option in loadIntegrationRules. Caller deadlines propagate as cancellation errors through compilation, integration and differentiation.
  • timeLimitMs defaults to 30 s (it was 10 s) and guards only against a hang.
  • On a shader, wrap a final color in _gpu_gamut_map_oklch() instead of _gpu_oklch_to_srgb().
  • A value is a pole of Tan, Cot, Sec or Csc when the argument is within about 100 ulps of a pole: Cot(1e-13) is 1e13 (it was ~oo), and cot(1000π) is ~oo. Compiled code gives Infinity (JavaScript) or np.inf (Python) at a pole.
  • On GLSL and WGSL, Mandelbrot and Julia take complex points, passed as vec2.

Improvements​

  • Compile results include freeSymbolTypes. Set strictTypes: true to reject undeclared free symbols.
  • Shader user functions accept complex arguments. Interval compilation supports more point-list coordinate operations.

Issues Resolved​

  • Exact complex arithmetic and matrix entries retain exact values: √2·(1+i) is √2 + √2i, and (√2(1+i))² is 4i.
  • Very small differences and exact values outside float64 range are preserved; values near integers are no longer silently rounded to them.
  • Matrix decompositions retain imaginary components and handle extreme entry magnitudes more reliably. Unsupported complex decompositions stay symbolic.
  • Statistics accept real constants: Median([π, 3, e]) is 3. Mode breaks ties by the smallest value under .N() as well.
  • Degree literals evaluate correctly in every angular mode. Equality proofs, dictionary equality and dependent expression types update consistently.
  • Compiled code preserves global bindings, evaluates random operands once and validates unsupported complex operands. Python complex operations and branch handling, shader complex roots and interval degree literals are corrected.
  • Numeric integration of an everywhere-NaN integrand returns NaN promptly. Color conversion no longer rounds channels prematurely to 8 bits.
  • Timing consistently documents its result as time in microseconds, then value.
  • Compiled Python gave 2 * x + z for 2(x + z).
  • determinant() of the numerics entry point no longer throws a TypeError for a matrix of size 3 or more.

Performance​

  • Faster symbolic sums, expansion, numeric arithmetic over large lists and repeated derivatives. Expand((x+y+z+1)^32) is about 2.7× faster.
  • Monte Carlo integration of 10 million samples takes about 0.4 s instead of 3.6 s.

0.133.0 2026-09-22​

Breaking Changes​

  • StringFrom decodes non-negative integers as Unicode code points. StringFrom(65) is "A", and StringFrom([127467, 127479]) is "🇫🇷". Use StringFrom(65, "default") for decimal text. The empty list gives "".
  • Collection equality propagates absent elements. Equal([1, Missing], [1, Missing]) is Missing, as is NotEqual. A length mismatch remains False; .isEqual() and IdenticallyEqual still recognize structurally identical collections.

Behavior Changes​

  • Undefined behaves as an absent value like Missing: numeric operations return NaN, IsMissing(Undefined) is True, and Coalesce(Undefined, 2) is 2.
  • Scalar callbacks broadcast over collection elements in more collection operations: with f(x) := 2x declared (number) -> number, Map(f, [[1, 2], [3, 4]]) is [[2, 4], [6, 8]]. Predicates requiring a single boolean still reject collection results.
  • Element-wise operations preserve empty lists: Sin([]) is [].
  • RandomChoice on a string returns a string: RandomChoice("abc", 5) has five characters. Use Characters(...) to recover a list of characters.
  • Stored expressions preserve their original bindings inside functions and loops, including in compiled code.
  • At machine precision, element-wise operations over lists of more than a hundred numbers give a List of numbers, not a lazy Map(f, L), so Norm(2·L) is a number. Some values can change in the last digit.

Issues Resolved​

  • Deep expressions parse and evaluate without stack overflow.
  • Round uses ties away from zero at every precision: Round(-0.5) is -1. Exp agrees across evaluation routes at machine precision.
  • Linspace(-1e308, 1e308, 3) gives [-1e308, 0, 1e308], and large float products retain their inexactness.
  • Dot broadcasts over point lists and checks dimensions. Extrema and ordering preserve undecided and absent values correctly.
  • Compiled Missing and Undefined use the target's absent value rather than becoming free variables. Functions with unknown signatures track their bodies.
  • Restricted assignments respect assumptions. Nested function-call notation and repeated ellipses parse correctly.
  • Epsil sum types can implement protocols under the sum type's name. FindFit and FindRoot attribute setup timeouts to the setup phase.

Performance​

  • Faster arithmetic and coordinate access over large lists: Sum(Sin(L)) over 10,000 numbers takes about 2 ms instead of 58 ms.
  • Faster factoring of deeply nested quotients.

0.132.3 2026-09-20​

Improvements​

  • Max and Min of scalars and collections compile on interval-js, including Max(0, Min(1, L)). Empty collections contribute no elements: Min(1, []) is 1.
  • Faster point-coordinate access; PointZ over 10,000 points takes about 79 ms instead of 165 ms.
  • Recompiling an integral after a symbolic search timeout avoids repeating the search across compatible type declarations and scopes.

Issues Resolved​

  • Integrals such as ∫ xⁿeˣ dx with a symbolic exponent return unevaluated promptly instead of hanging. Positive integer exponents are unaffected.

0.132.2 2026-09-19​

Improvements​

  • Faster compiled arithmetic over point lists: a 7,225-point coordinate calculation takes about 0.14 ms instead of 2.55 ms. Shader targets support more point lists with coordinates computed at run time.
  • Repeated compilation of an integral skips a symbolic search that already timed out, then uses numeric integration. Changes to assignments, assumptions, angular units or precision permit a new search.

Issues Resolved​

  • More JavaScript operations compile over lists mixing real and complex values, including Abs, Divide, Power, Real, Exp and Sign.
  • Compiled complex arithmetic agrees with the interpreter at poles: 1/(0·i) is ~oo, its absolute value is +∞, and its reciprocal is 0.
  • Compiled coordinate reads detect incompatible point dimensions and preserve user variable mappings and operator overrides.

0.132.1 2026-09-19​

Resolved Issues​

  • resolveApplication callbacks preserve range precedence, including offsets in range bounds. Returning undefined no longer changes f(x)+1...n into f(x)+(1...n).

0.132.0 2026-09-19​

New Features​

  • resolveApplication(context) lets a parser host choose whether f(x) is a call or a product. Return apply, multiply or undefined; explicit declarations, parameters and resolveSymbol facts take precedence.

Behavior Changes​

  • Explicit declarations, including unknown, take precedence over resolveSymbol. Inferred types can still be overridden. Resolution facts apply to the parsed expression without declaring names in the caller's scope.

Resolved Issues​

  • Lenient parsing reads a letter run before parentheses as one name: foo(x) is ["foo", "x"]. Without parentheses, foo remains f·o·o; Greek constants retain their splitting rules, as in pix(3).

0.131.3 2026-09-18​

Resolved Issues​

  • The static type of PointList includes the width of a matrix component. PointList(M, V) with M a matrix<3x2> and V a vector<3> now has the type list<tuple<vector<2>, number>^3>. The width was missing before.

0.131.2 2026-09-18​

Resolved Issues​

  • The resolveSymbol handler is also used outside ce.parse(), and takes precedence over a function declaration the engine inferred. A raw or structural result canonicalized later now also uses the engine-wide latexOptions.resolveSymbol. Before, b(\cos(NX)-1), with a handler that says b is a value, could be read as an application of b. A declaration made by the host, or one that holds a value, still takes precedence.
  • The static type of PointList over list components includes the list width. PointList([0.1, 0.4, 0.8], [0.2, 0.7, 0.3]) now has the type list<tuple<real, real>^3>, so PointX of it has the type vector<3>. When the components have different widths, the smallest one is used. On the shader targets, 2 · PointX(PointList(-6, v)) over a vector<3> now compiles to 2.0 * vec3(-6.0).
  • ce.appliedNonFunctions() reports a head that is a function only because of an earlier application. After the first parse of g(x), the call returned [] for g. Only a host declaration or an assigned body now makes g a function for this call.

0.131.1 2026-09-18​

Breaking Changes​

  • \overline{z} is the complex conjugate. \overline{…} now parses as Conjugate (it was OverBar), and Conjugate serializes as \overline{z}. It used to serialize as z^\star, which parses as ConjugateTranspose, so a conjugate did not round-trip through LaTeX. OverBar built as MathJSON still serializes as \overline{…}. The repeating decimal 0.\overline{3} and the extended sets \overline\C, \overline\R, \overline\Z and \overline\Q are unchanged. z^\star still parses as ConjugateTranspose.

Resolved Issues​

  • The resolveSymbol handler decides between a product and an application, as a declaration does. With a handler that answers { type: "number" } for s, ce.parse("s(x+1)") was an application of s, which then made s + 1 an error. It is now a product. A function type gives an application, and a head the handler does not resolve uses the scope. ce.parse(latex, { speculative: true }) now reads a head declared unknown as a normal parse does.
  • A head declared unknown by the host is read as a product. In 0.131.0, k(1 - w) was an application whenever k was declared unknown. An explicit unknown declaration says k is a value whose type is not known yet, so k(1 - w) is now k·(1 - w), as for a declared number. Only an undeclared head, or one with a type inferred from use, is applied.
  • ConjugateTranspose over a vector of more than 100 elements evaluates. z^\star over a 200-element vector stayed unevaluated, while Conjugate of the same vector evaluated.

0.131.0 2026-09-18​

Breaking Changes​

  • A symbol with no type information followed by a parenthesized argument is an application. f(x) with f undeclared, or declared with an unknown type, now parses as ["f", "x"], not as the product f·x. The product reading gave silent wrong results: \int f(x) g(x) dx took g out of the integral as a constant. A wrong application stays visible (f(2) evaluates to itself), and a later value for f overrides the inferred declaration.
    • 2f(x) is 2·f(x), and f(3)^2 is f(3)².
    • The product reading remains for a head that is a value: declared with a concrete type (a: real), assigned a value, a constant (\pi(x+1)), a parameter of the enclosing function ((x, N) \mapsto x(N+1)), or a head that its own argument refers to (x(x+1)).
    • A function defined before the head gets a value is read again: with g(t) := 2x(t+1) then x := 5, g(3) is 40.
    • Multiply(s, x+1) now serializes as s\times(x+1), so it round-trips.
    • With strict: false, foo(x) is still f·o·o(x).

New Features​

  • Host capabilities: ce.effects and ce.withEffects(). The application can replace or deny the handlers the engine uses to reach the host, one engine at a time. The console handler is used by Print and Input (Epsil print and input):

    const lines: string[] = [];
    ce.effects = {
    console: { log: (line) => lines.push(line), readLine: () => "Ada" },
    };

    readLine(prompt) returns the line read, null at end of input (Input evaluates to Nothing), or undefined when there is no interactive input (Input stays unevaluated). The defaults are the real console in Node and the prompt() dialog in a browser. ce.effects = {} restores the defaults.

    A handler set to null denies the capability: the operator evaluates to Error("capability-denied", "console"). ce.withEffects(overrides, fn) applies a change for one callback (also an async one). Use it to evaluate an expression that is not trusted:

    const result = ce.withEffects({ console: null }, () => expr.evaluate());

    The entropy handler, { random(): number }, is the unseeded source of randomness (Math.random by default). RandomExpression and the random operators use it outside a WithRandomSeed frame. A constant handler makes them reproducible in tests; entropy: null makes them evaluate to Error("capability-denied", "entropy"), and a compiled function throws CapabilityDeniedError.

    A change made while an evaluation runs does not affect that evaluation. Operator handlers receive the handlers as options.effects; a custom operator may use console or entropy if its signature declares that effect. EvaluateHandlerOptions has a new required member, effects: code that builds its own options object to call a handler directly must add it.

Resolved Issues​

  • \operatorname{conj}(z) parses as Conjugate(z). It was the product conj·z. Over a list, it was an unknown function that did not broadcast. Conjugate still serializes as z^\star.
  • The square of a base that ends with a superscript serializes with braces. Power(Transpose(A), 2) serialized as A^T^2, which is invalid TeX. It is now {A^T}^2, and Power(Conjugate(z), 2) is {z^\star}^2.
  • At with an index that is not an integer returns the missing-element value. L[2.5], L[3/2] and L[5 + √17] stayed unevaluated. They now return NaN for a numeric collection and Missing otherwise, as for an out-of-range index and as compiled code already did. An index whose value cannot be decided, such as a symbol with no value, stays unevaluated.
  • More expressions compile on the interval-js target:
    • Missing values: a restriction over a list or a relation, a Which whose branches are lists, and IsMissing and Coalesce, which gave wrong results. A type that is not numeric, such as a string, still does not compile.
    • PointX, PointY and PointZ over a list of points, and over a value that is either a point or a list of points. A helper function that reads a coordinate of its parameter can be called with one point or a list of points.
    • Point arithmetic: Add, Subtract, Negate, Multiply and Divide with point operands, for example V.x·(cos a, sin a) + V.y·(−sin a, cos a). Operations the interpreter rejects, such as point + number, still do not compile.
    • A Which whose branches return a list in one case and a number in another.
  • sqrt, abs, ln and gamma on the interval-js target keep a missing value missing. They returned an interval such as [0, ∞) for it.

Epsil​

  • The MCP server captures print and input with a console handler. It no longer replaces global functions such as console.log during an evaluation. The output of the evaluate tool is unchanged.
  • A denied print or input is a capability-denied runtime error (see epsil doc capability-denied). The program continues after it.
  • The VS Code extension supports type names and named-argument labels. Go to Definition, Find All References and Rename now work on a type name used in an annotation such as let p: point. Renaming a parameter also renames its labels in calls such as g(a: 2). A rename is refused when not every use can be resolved.
  • epsil check checks calls to a function literal bound with let, const or :=. let k = (n: integer) => n + 1 followed by k(1.5) now reports the same diagnostic as a declared signature. A named call such as k(n: 2) no longer reports a false argument-names-unavailable.
  • A constant bound to a function literal can no longer be redefined. const k = (n) => n + 1 followed by k = (s) => s, or by k(x) = x * 2, replaced k without an error. It is now refused, as const c = 5 then c = 6 is. From the host, ce.assign('Pi', (x) => 2x) is also refused; it replaced Pi on that engine.

0.130.0 2026-09-17​

New Features​

  • New expr.digest property: a 128-bit digest of the expression's structure, usable as an in-memory cache key. Unlike expr.hash (a 32-bit bucket that needs an isSame() check on a hit), two expressions have the same digest exactly when their MathJSON is the same tree. It is much faster than JSON.stringify(expr.json) on expressions that share sub-expressions. It is not an isSame key: 1/2 and 0.5 digest differently. The value is 32 lowercase hexadecimal characters, stable only within one release, and not cryptographic. Do not persist it.

Resolved Issues​

  • expr.hash now agrees with isSame on number literals. The exact rational 1/2 and the float 0.5 hashed differently, so Unique, Tally and set membership could fail to match them.
  • Compiled callbacks compute repeated sub-expressions and loop invariants once. This applies to the callback of a numeric derivative and to the integrand of a compiled Integrate (for example Γ(k/2)·√2^k in a chi-square tail is no longer computed at every sample). One measured case went from 84 µs to 4 µs per call.
  • On the interval-js target, a constant list is built once, and a constant index selects its element at compile time. A sum over a 400-element assigned list no longer builds a new array at each read.

0.129.2 2026-09-17​

Resolved Issues​

  • ce.rebind() now gives the same result as ce.expr(expr.json, …) for an expression that already contains an Error. Type errors below an existing error were not reported again.

0.129.0 2026-09-17​

New Features​

  • New ce.rebind(expr, { form, scope }) rebuilds a boxed expression as if it was boxed from its MathJSON, without producing the MathJSON. ce.expr(expr, { scope }) keeps the existing bindings of a boxed expression. To read it under other declarations you had to serialize it and box it again, which can exhaust memory for an expression with many shared sub-expressions. rebind copies each distinct node once. See "Rebinding a Boxed Expression in Another Scope" in the structural-forms guide.

Resolved Issues​

  • A sum, a product or a literal collection with a few hundred thousand operands no longer overflows the call stack (RangeError: Maximum call stack size exceeded).
  • Compiling an expression with many shared sub-expressions no longer runs out of memory. The analysis of free symbols and unsupported operators visited shared nodes once per path. A custom compile handler is also no longer called twice for each node.
  • Lazy collections and assigned symbols evaluate faster. [l[i] for i = 1…Length(l)] over a lazy list l recomputed from the first element at each step (quadratic time). A symbol assigned an unevaluated expression now keeps its evaluated value until a dependency changes. One measured chain of list helpers went from 16.7 s to 0.24 s.
  • A symbol that holds a lazy Map or Range value compiles on the interval-js target. R[k] and length(R) with R = mod(10⁴ sin(10⁴ · [0...100]), 1) failed with "Map: no lowering".

0.128.13 2026-09-16​

Resolved Issues​

  • Collection values compile on the interval-js target. A list is compiled as an array of intervals. Previously, a list outside a comprehension failed with "List: no lowering". The target now:

    • applies scalar functions element by element to an operand whose type is a list of numbers (sin(L), L + 1, sin(L) · M);
    • passes lists into and out of user functions, and maps a scalar-parameter function over a list argument (f([1, 2, 3]) with f(x) := x²). This call used to return an incorrect result with success: true;
    • supports a Range with literal or symbolic bounds;
    • supports Sum, Product, Max and Min over a list computed at run time;
    • returns a list of numbers, a range or a Map at the root as an array.

    Also fixed: Σ_{k=1}^{x} k with x ∈ [2.5, 3.5] returned [6, 6], which is not an enclosure. A bound that is not constant over the interval now returns entire.

  • A restriction with a list of conditions compiles on the JavaScript target. u\{[1, 2, 3] v < 2\} compiles element by element ([u, NaN, NaN] for v = 1). In the interpreter, a point under a list of conditions is now kept whole at each position: (1, 2)\{[1, 2, 3] < 2\} is [(1, 2), Missing, Missing] (it was [1, Missing]). The shader and interval targets still do not compile a list-valued condition.

0.128.12 2026-09-15​

Resolved Issues​

  • A comprehension or a sum evaluated in a child scope uses that scope's values. With n declared in the root scope and n = 4 in a child scope, [k/n for k in 1..n] and \sum_{k=1}^{n} k stayed unevaluated, while 90n returned 360.
  • Compiled JavaScript: a point with a complex coordinate passed to a user function no longer gives NaN. The call is inlined when possible, or the compilation fails with an error message. 1 + g((x, i x)) with g(P) = P.y returned the string "[object Object]1".
  • A coordinate of a written-out point keeps the type of that component. PointX((1, \sqrt{1 - e^2})) is typed integer | nan instead of complex | nan, so a division by it compiles correctly.
  • A coordinate accessor over a union of numbers, points and lists gets a precise type. PointX(P) typed collection<number>, so arithmetic on it did not compile to JavaScript. It is now number | tuple<number, number> | list<number>.
  • Juxtaposition with a union-typed operand is multiplication. 2\mathrm{PointX}(P) with a number | list<number> coordinate parsed as the tuple (2, PointX(P)). A union with a non-numeric member (string | number) still parses as a Tuple.

0.128.11 2026-09-14​

Resolved Issues​

  • A function that builds a list by recursion compiles to a loop on the JavaScript target. For example F(n) = \{n = D - 1: [P(n)], [P(n)].join(F(n + 1))\} overflowed the stack near 5,000 levels. It now builds a 10,000-point list in about 35 ms. The loop obeys ce.iterationLimit (default 1024), like the interpreter: raise the limit for longer lists. The shader targets still do not compile recursion.

0.128.10 2026-09-14​

Behavior Changes​

  • The cross product of two points is a point. Cross((1, 2, 3), (4, 5, 6)) returned the list [-3, 6, -3], so Cross(A, B) + P failed. It now returns a point typed tuple<number, number, number>. A list operand still gives a list.
  • Map over a tuple returns a list. The result is typed list<R>. It was typed as the tuple or as collection, and could become a Set that removed duplicates: Map(x ↦ 0, (1, 2)) had one element.

Resolved Issues​

Arithmetic and Evaluation​

  • A function that builds a list by recursion evaluates iteratively. It no longer grows the JavaScript stack. It obeys ce.iterationLimit: a missing base case throws a CancellationError with cause iteration-limit-exceeded. Raise the limit for larger builds, such as a 10,000-point list.
  • The numeric derivative of a point-valued function has a value. .N() of the derivative of F_0(t) = f'(t)/|f'(t)| for a space curve f returned an incomplete symbolic result; compiled JavaScript returned NaN. Both now differentiate each component and return a point. ND also supports functions that return points or lists.
  • The norm of a point with an empty list coordinate is the empty list. Norm(([], 3)), Abs(([], 3)) and Hypot(([], 3), 4) now evaluate to []. They returned √10 and √(16 + []²).

LaTeX Parsing​

  • A list method with arguments parses as a function call with the list as first argument. L.\operatorname{join}\left(M\right) parses as Join(L, M), and [1].join(2, 3) as Join([1], 2, 3). Before, it did not parse. Methods without arguments (.x, .total) are unchanged.

Types​

  • A coordinate accessor over a point-or-point-list union has a precise type. PointX over list<tuple<…>> | tuple<…> is typed number | list<number> instead of collection<number>, so arithmetic on it compiles to JavaScript.

Compilation​

  • More point and list expressions compile to JavaScript:
    • a point multiplied by a symbol bound to a list of numbers (for example R × (cos t, sin t) with R built from a Range);
    • a call to a function with a list argument next to a point argument;
    • a value that can be a point or a list of points, added to a list of points. It returned NaN for every element with success: true. When the declared type cannot tell the two apart, the compilation now fails with a message;
    • Norm, Abs and Hypot of a point with a list coordinate return one norm per element: |([1, 2], 3) − (4, 0)| is [4.24, 3.61]. Before, the result was a single wrong number;
    • Which([T, F, F], (1, 2)) returns [(1, 2), NaN, NaN] instead of NaN.
  • A literal index into a list expression computes only the selected element. This applies to element-wise Which/If, relations, Mod, list arithmetic, a literal Range and a PointList built from columns, on every target. For example (0.1·[a, b, c] + 0.4)[2] compiles to 0.1·b + 0.4, and [a, b][1] compiles to a. Likewise PointX of a PointList built from columns compiles to the first column.
  • More list expressions compile on the interval-js target. Lists of any width and helpers that return lists are written out, so F(x, y)[1] and Total(f(x + 1, y) + f(x − 1, y)) compile.
  • More expressions compile on the GLSL and WGSL targets:
    • a comprehension over literal ranges or lists, up to 64 elements, compiles to an array of vectors;
    • a helper function that returns a list;
    • a Which, If or When whose branch contains a loop-form Sum or a block. The loop runs only when its branch is selected;
    • a block with local variables used as a Sum term or an operand.
  • Fixed a shader miscompile of a reduction assigned to a block local. On GLSL and WGSL, a := Σ… in a function or Loop body produced invalid code that was reported as a success.
  • Shader code is smaller. Repeated sub-expressions in a conditional branch are computed once. A deeply nested Max(e, e) no longer expands into megabytes of code.
  • Shader helper names are valid. They no longer contain two consecutive underscores or dollar signs.
  • Color helpers in shaders keep a three-channel return type.

Performance​

  • Compiled code reuses more calculations. A user function called from each term of a Sum with the same argument computes the parts that depend only on that argument once, on all compile targets. The interval-js target also reuses small repeated operations, such as a reciprocal.
  • Nested element-wise operations on a list run in one loop in compiled JavaScript. One measured case went from 21 µs to 3.6 µs per sample.

Benchmarks​

Median time per call at 200-digit precision, in microseconds (lower is better):

ExpressionCE (current)CE 0.128.9SymPymath.jsMathematica
\pi^211131791433.9
\sin 123232214495.1
\ln 218183574,4603.7
\zeta(3)1,5841,588281—50
\Gamma(\tfrac13)841848368—204

On the symbolic cases that both solve (antiderivatives, simplification, limits, definite integrals, equation solving), Compute Engine is a median 3.1× faster than Mathematica.

0.128.9 2026-09-11​

Performance​

  • Compiled JavaScript skips redundant shape checks on constructed vectors and point lists. Helpers with a fixed-size collection return type, and PointList over constructed sources, no longer emit shape checks and broadcast fallbacks that the compiler can prove unnecessary. Runtime inputs keep their validation.

0.128.8 2026-09-11​

Resolved Issues​

  • Compiling nested point helpers to JavaScript no longer repeats the same analysis. This removes a compile-time slowdown in composed point helpers.
  • List-valued point coordinates are kept in compiled JavaScript helpers. Dot broadcasts across a list in any coordinate of a runtime tuple input.
  • Compiled products over a one-element list are correct. The element is emitted directly and evaluated once, and negative zero is preserved. An empty product is still 1.

0.128.7 2026-09-11​

Resolved Issues​

  • Colors in GLSL and WGSL keep their vector representation in locals, helper parameters, return values and shared temporaries. Converted colors are normalized to OKLCh, so the shader types are valid and the colors are kept.
  • A caller parameter with the same name as a global no longer changes how a compiled helper result is classified. List-valued coordinates keep broadcasting when called through such a helper.

Performance​

  • Compiled JavaScript uses direct arithmetic for vectors and points of known width. Scalar lists returned by helpers or held in local variables, and points passed to specialized helpers, skip redundant shape checks, broadcast fallbacks and small .reduce calls. Dot of two points with proven scalar coordinates is a direct multiply-add. Runtime inputs keep their checks.

0.128.6 2026-09-10​

Resolved Issues​

  • Compiling nested shared scalar functions to JavaScript no longer takes excessive time.

Performance​

  • Compiled JavaScript sums and products use direct arithmetic when their terms are proven scalar, even when the declared result of a block permits collections. Collection terms still broadcast.

0.128.5 2026-09-10​

Performance​

  • Faster compiled JavaScript for reductions and helper chains.
    • Sums and products use real square roots and logarithms when a known lower bound proves the argument non-negative.
    • Sum(Map(f, xs)) and Product(Map(f, xs)) run in one pass, without an intermediate array, when the source and callback are pure.
    • Nested helper calls avoid unnecessary broadcasting when local variables and loop counters are proven scalar.

0.128.4 2026-09-10​

Improvements​

  • The interval-js target compiles applications of small closed-form derivatives, such as Apply(Derivative(f, n), x).

Resolved Issues​

  • Compiled derivatives of odd roots stay real at negative inputs. For f(x) := ∛x, compiled f''(-1.2) now returns approximately 0.16399052 instead of a complex value, as the interpreter does. This applies to all compile targets.
  • Compiled closed-form derivatives handle complex arguments. f''(z) for f(x) := x^3 now uses complex arithmetic when z is complex, instead of returning NaN.
  • Compiled Dot broadcasts list-valued point coordinates. For points declared as tuple<broadcastable<number>, broadcastable<number>>, Dot(a, b) with a = [1, [1, 2]] and b = [3, 4] now returns [7, 11] instead of NaN. Complex point coordinates are not compiled.

Performance​

  • Repeated Sum or Product over the same range slice P[a...b] computes the result once in compiled code.

0.128.3 2026-09-10​

Improvements​

  • Dot of points whose coordinates may be lists returns the broadcast inner product. With L := [1, 2], Dot((1, L), (3, 4)) is [7, 11]; it stayed symbolic before. Dot(c, d) for two tuple<real, real> symbols is now typed real instead of number.

Resolved Issues​

Compilation​

  • Scalar calls keep their types through user-function chains and local variables. A function that accepts points or lists can compile a scalar point call to shared JavaScript, GLSL and WGSL helpers without losing its list inputs.
  • A point built from a symbol that holds a number no longer compiles to code that throws. (⌊x⌋, ⌊y⌋, s) with s assigned 2.41 emitted […, ...2.41], and each call raised "2.41 is not iterable".
  • A point plus a scalar is no longer compiled. (x, y) + 3 returned [3.3, 3.7] at x = 0.3, y = 0.7, while the interpreter reports incompatible-type. The expression is now left to the interpreter.
  • A compiled element-wise comparison marks an absent element with NaN instead of false. Equal([1, Missing], 1) compiles to [true, NaN], as the interpreter returns [True, Missing]. A piecewise over such a condition gives an absent result at that position instead of taking a branch. A NaN operand still equals nothing, and the scalar comparison still returns a boolean.

Types​

  • Dot no longer reads the operands of any tuple-typed expression as coordinates. Dot(P + (0, 0), Q) was typed tuple<real, real> and Dot(2P, Q) a union with a tuple, although both values are numbers.

Performance​

  • Point arithmetic and Dot compile to per-component code on the javascript target when the width of the points is known. 2 · (1, 2) compiles to [(2 * 1), (2 * 2)].
  • Fewer runtime broadcasts in compiled user functions. Calls that take points or use a bounded Sum can now be proven scalar. On one noise kernel, the time per sample went from 15.5 to 2.5 microseconds.

0.128.2 2026-09-10​

Behavior Changes​

  • An absent value in a function with an undeclared result is Missing, not NaN. With g(t) := \begin{cases} t & t > 5\end{cases}, g(3) + 1 is now Missing. Declare the function, for example g: (number) -> number, to get NaN.

Resolved Issues​

  • Point accessors read a list whose element type is a union of tuple types as a list of points. For Q = [(a, 1), (2, b)] with a and b declared number, PointY(Q) is typed list<number>. Before, PointY(Q) + 1 had the wrong type and compiled JavaScript returned the string "1,71".
  • A scalar cannot be added to a point that can be absent. For a point with a restriction, such as h(t) := (t, t+1)\{t > 0\}, h(1) + 2 was accepted, and compiled code returned the string "21,2". It is now rejected as for a plain tuple<number, number>. A scalar multiple of such a point is still accepted.
  • An absent point stays absent instead of becoming NaN. With P declared list<tuple<number, number, number>>, 2 \cdot P[0] returned NaN, and 2 \cdot P[0] + (1, 1, 1) then failed with incompatible-type. It is now Missing. Add(Missing, (1, 1, 1)) and Multiply(Missing, (1, 1, 1)) are also Missing. An operation typed as a number still returns NaN: 2 \cdot Missing and \sin(Missing) are unchanged.

0.128.1 2026-09-10​

Breaking Changes​

  • Compiled Equal and NotEqual are exact. A compiled comparison of two numbers uses the IEEE 754 test (=== in javascript, == in python), with no tolerance. The interpreter still compares within engine.tolerance, so 0.1 + 0.2 = 0.3 evaluates to True but compiles to false. This also applies to KroneckerDelta and to comparisons of collections. NotEqual is still true for NaN. For tolerant equality, evaluate with the interpreter.

Resolved Issues​

LaTeX Parsing and Serialization​

  • Nested absolute values round-trip. An Abs whose body contains a vertical bar serialized as \vert\vert x\vert-0.5\vert, which did not parse. It now serializes with \left\vert … \right\vert. The parser also accepts nested bars such as \vert\vert x\vert-0.5\vert, |x-|y||, |\frac{|x|}{2}| and |x^{|y|}|.
  • \operatorname{real}, \operatorname{imag}, \operatorname{erf} and \operatorname{erfc} parse as Real, Imaginary, Erf and Erfc.

Compilation​

  • An integer exponent over a possibly negative base is correct in GLSL and WGSL. (-1)^n with a runtime n returned NaN on most drivers, because pow(x, y) is undefined for a negative x. The sign is now kept for an odd exponent.
  • A compiled point accessor treats a row with non-numeric cells as not a point. PointX(L) over [[1, "a"], [3, "b"]] returned [1, 3]; it now returns [1, "a"], as the interpreter does.

Types and Assignment​

  • A value with a restriction can again be assigned to a declared symbol. In 0.128.0 this threw a TypeCompatibilityError:

    ce.declare("P", "tuple<number, number>");
    ce.assign("P", ce.parse("(1,2)\\left\\{a>0\\right\\}"));

    It works again, with ce.assign, ce.declare, Assign, := and destructuring declarations. P.type stays tuple<number, number>. With a := -1, P evaluates to Missing. missing <: number is still false, so the type of an undeclared symbol or of an expression still includes missing.

Collections​

  • A point accessor over an empty list of points returns the empty list. PointX([]), and PointY, PointZ over any empty collection, returned Missing, and 2 · PointX([]) returned NaN. PointX([]) is now [], typed list<never>. The empty case follows the element type:

    • a point element type (list<tuple<number, number>>), an unknown element type, or the literal [] returns [];
    • a numeric element type (list<number>) is one point, so an empty one still returns Missing (NaN in compiled JavaScript);
    • any other element type (list<string>) returns Missing.

    Compiled JavaScript returns the same values. PointX(s) for s declared string now compiles to its first character instead of NaN.

Performance​

  • Higher-order derivatives of user functions (f''(x), f'''(x)) compile much faster in JavaScript. They use automatic differentiation: the third derivative of a nested radical went from 28.7 s and 392 KB of code to 1 ms and 174 B. This also fixed (x-e)·f'(e), which compiled to NaN when the body has a complex value.
  • Faster compiled complex arithmetic in JavaScript. Complex operations no longer allocate a closure each. i · b compiles to { re: 0, im: b }.
  • More constant folding in compiled code. \sum_{k=1}^{3}(-1)^{k}x compiles to (-x + x + -x) instead of three pow calls. In GLSL and WGSL this also fixed a wrong value: a literal pow(-1.0, 1.0) is undefined in both languages.
  • Better common-subexpression elimination in compiled code. (sin(u)+1)^2 + 1/(sin(u)+1) called Math.sin twice and now calls it once. Values moved out of a loop also share their subexpressions.
  • Element-wise arithmetic over lists of known width compiles to per-component code in JavaScript, instead of a runtime broadcast. A reduction over a real vector no longer uses complex arithmetic.
  • A reduction over a range slice compiles to a loop. Sum, Product, Mean, Max, Min and Length over P[a...b] no longer build the slice. A source that is not an array, or a bound that is not an integer, now returns NaN instead of throwing a TypeError.
  • interval-js kernels no longer rebuild constant intervals on each call. A plotted cases expression is 25–40% faster per sample.

0.128.0 2026-09-09​

Breaking Changes​

  • A false restriction evaluates to Missing, not Undefined. When c is false, When(e, c) (the e\{c\} restriction) now evaluates to Missing, the same value as a Which with no selected clause or an If with no else branch. The element-wise form masks each element: [10,20,30]\{[1,2,3] > 2\} is [Missing, Missing, 30]. Compiled output is unchanged. Code that tested for "Undefined" must test for "Missing" (or use IsMissing, which now returns True for a masked value).

    The type of a restriction now includes the absent case: When(5, x > 0) is integer | missing, and a list with a masked element is list<integer | missing>. Only a restriction whose condition is the literal True keeps the bare type. Code that checks type.matches("number") must remove the missing member first.

  • On the JavaScript target, a compiled color is an object, not an array. compile() now returns a color as { space, c0, c1, c2, alpha }, where space is 'oklch', 'rgb', 'hsv', 'hsl' or 'oklab', the channels are in that space, and alpha is undefined when there is none. Before, a color was a flat [L, C, H] (or [L, C, H, alpha]) OKLCh array, and a conversion such as AsRgb returned a flat array in its own space, with no way to tell them apart. Read raw.space and raw.c0…raw.c2 instead of raw[0]…raw[2]. The color helpers throw a TypeError for a numeric array or an unknown space. A list of colors is an array of these objects; a color string is still a string. ColorToColorspace is not affected: it returns components, and its compiled value is still a plain array.

    Nested conversions are now correct: AsRgb(AsRgb(c)) equals AsRgb(c) and ColorDelta(AsRgb(a), b) equals ColorDelta(a, b). The interpreter fallback returns the same object and accepts one as a vars value. The GLSL and WGSL targets are unchanged: a color is still a vec3 in OKLCh.

  • ColorFromColorspace returns a color. Its signature is now (color | tuple, string) -> color. ColorFromColorspace((0.5, 0.1, 20), "oklch") is Oklch(0.5, 0.1, 20); it was the sRGB components tuple (0.58, 0.29, 0.29), while the compiled code returned a color. To get the old components, use ColorToColorspace(color, "rgb"). Passing the result to another color operator needs no change. Indexing the result (At(result, 1)) is now an incompatible-type error, because a color is not a collection. On the JavaScript target the compiled color keeps the space it was built in; on GLSL and WGSL, a shader whose whole expression is ColorFromColorspace and that expects OKLCh must wrap it in AsOklch.

New Features​

  • Arg compiles on the interval-js target when the complex value is built in the expression (a + ib), for example \arg((x - 0.3127) + i(y - 0.329)). A box that crosses the branch cut on the negative real axis reports the jump. Other complex operands, such as Sin(z), still fall back to the interpreter.

  • More fixed-width list expressions compile to straight-line code. Map(h, [e1, …, eN]) with a user function h becomes [h(e1), …, h(eN)], so Min(Map(h, list)) now compiles on interval-js. Product([a, …]) and a Reduce over Add, Multiply, Min or Max compile to an n-ary operation: the JavaScript target emits _.a * _.b * … instead of a reduce over an array.

  • On the JavaScript target, the loop-invariant part of a Reduce/Scan combiner is computed once. In Reduce(list, (acc, x) ↦ acc + x·Sin(u)·Cos(v), 0), Sin(u)·Cos(v) was computed again for each element.

  • A point compiles on interval-js when it is the whole expression. Tuple(a, b) and an all-scalar PointList(a, b) compile, and run() returns one {lo, hi} interval per coordinate. A point inside a larger expression (2·(a, b)) still does not compile on this target.

Resolved Issues​

Compilation​

  • Compiled Equal and NotEqual are correct for NaN and for two infinities of the same sign (JavaScript and Python). NotEqual(NaN, 3) compiled to false, for example r \ne 3 when r was not given to run(); it is now true, as in the interpreter. Equal(oo, oo) compiled to false; it is now true. An impure operand (the Random family) is evaluated once.

  • An else-less If compiles. \mathrm{If}(x > 0, x + 1) was refused by every target. It now compiles on all targets and returns NaN (for a number) when the condition is false. An If whose branch is a statement is unchanged.

  • A selection with no selected value compiles to the same absent value on a target. A Which with no matching clause, an If with no else branch and a false restriction return NaN when the value is a number or of unknown type, and undefined (JavaScript) or None (Python) only when the value is provably not a number, such as a string. Before, Which and If could return undefined for a number, and Python always returned float('nan'), so a compiled IsMissing over a string selection disagreed with the interpreter.

  • A point passed to a user function whose parameter has no declared type compiles correctly. With r(P) := 2·P, compiled r((a, b)) returned NaN on JavaScript instead of (2a, 2b). Such a call now compiles correctly on every target. When that is not possible (an impure argument, a recursive callee, or a body the interpreter rejects for a point), compilation fails and fallback: true uses the interpreter; before, it could return a wrong list.

  • A user function whose body uses an inner lambda parameter with the same name as a variable of the caller now compiles inline. For example, a body that contains Map(p ↦ …, list) called from a definition that binds p.

  • A Sum or Product over a collection (Sum(2n, Element(n, [3, 5, 7]))) fails to compile with a clear message instead of an internal error about Nothing. It still falls back to the interpreter.

  • An unrolled Sum or Product no longer uses complex arithmetic for a square root whose argument is a non-negative constant in each term. For example \sum_{i=1}^{6} (1 - j(1 - \sqrt{1 - 0.025^2(i - 0.5)^2}))\cdot s compiled to six complex square roots.

  • A Range with a non-integer upper bound has integer elements. Range(1, 2.5) and Range(1, n) with n declared real were typed indexed_collection<real>; they are now indexed_collection<integer>. An equality against such an index now compiles to k === 9543 instead of a tolerance test.

  • An equality with an operand of type integer | nan (such as an element read P[i]) compiles to ===/!==, which gives the interpreter's result for NaN.

  • Shader float literals use the shortest single-precision spelling. 2π was emitted as 6.2831854820251465; it is now 6.2831855.

  • Constant factors inside parentheses are folded. \frac{2\pi s}{P} with P assigned 100 emitted (2 * Math.PI * s) / 100; it now emits (6.283185307179586 * s) / 100.

Interval Arithmetic​

  • Interval sign and heaviside are tighter. sign([0, 0.5]) returned [-1, 1]; it now returns [0, 1]. heaviside (with H(0) = 1/2) returns [0.5, 1] for an input with no negative part and [0, 0.5] for an input with no positive part. Only an input that spans zero keeps the full range.

  • Interval atan2 reports a jump along its x operand. atan2([0, 0], [-1, 1]) returned the bounded interval [0, π]; it is now singular with the same enclosure. A singular result has a new optional atOperand field: the index of the operand that the at location refers to (absent for the first operand). -0 is read as zero: atan2([0, 0], [-1, -1]) is +π.

Colors​

  • A tuple variable is no longer read as a color by compiled code. With v declared tuple<number, number, number>, ColorMix(v, Rgb(0, 0, 1), 0.5) compiled but threw Not a color at run time on JavaScript, and gave a wrong color on the shader targets. Operators that take a color (ColorMix, ColorDelta, ColorContrast, ContrastingColor, ColorToString) now do not compile such an operand and fall back to the interpreter; the message suggests AsRgb((r, g, b)). The As* conversions and ColorToColorspace read it as 0-1 sRGB components, as the interpreter does.

  • Color conversions of a possible list of colors compile again on JavaScript. AsRgb(w) with w declared broadcastable<color> did not compile in 0.127.0, and before that converted one color channel by channel. AsRgb("red") compiles again too.

  • Nested color conversions give the interpreter's color in compiled code. AsRgb(AsRgb(Hsv(0.3, 0.5, 0.5))) compiled to [0.7137, 0, 0.3686] instead of Rgb(0.5, 0.2513, 0.25). This applies to all color operators. On GLSL and WGSL, a Which or If whose branches return colors in different spaces does not compile.

  • Compiled ContrastingColor returns an OKLCh color on JavaScript. Before, AsOklch(ContrastingColor(bg, AsRgb(a), AsRgb(b))) read sRGB channels as OKLCh.

  • Compiled AsHsv and AsHsl of a non-finite color return NaN for all channels. They returned a hue of 0 (red).

Absent Values and Types​

  • Add and Multiply return NaN for an operand that evaluates to Missing. With g(3) evaluating to Missing, g(3) + 1 stayed Add(Missing, 1); it is now NaN. A list operand gives NaN per element (Add(Missing, [3, 4]) is [NaN, NaN]).

  • A juxtaposition with a maybe-absent operand is a product. With g(t) := \begin{cases} 0.5 & t < 1 \end{cases}, 2g(0) parsed as the tuple (2, g(0)); it is now 2 \cdot g(0). The same applies to 2x\{x>0\}, 2\,\mathrm{If}(c, x) and t P\{0 \le t \le 1\}. A non-numeric operand, such as a string, still gives a Tuple.

  • A maybe-absent Sum/Product bound is accepted. \sum_{x=g(0)}^{3} x was an incompatible-type error; an absent value is now handled at evaluation. A non-numeric bound ("lo") is still an error.

  • A bound written as the literal NaN is an error, like one written as Missing: Sum(x, (x, NaN, 3)) now reports incompatible-type.

  • Partition accepts a maybe-absent chunk size, like Take, Drop and Chunk. Partition([1,2,3,4], k(0)) with k(0) typed integer | missing was an incompatible-type error; it now returns [[1,2],[3,4]].

  • A function with a missing | T result keeps its result type when it is assigned to a symbol declared function. After ce.declare('l', 'function'), assigning a piecewise function without a default made l(t) of type unknown, so \sin(l(t)) was typed broadcastable<number> instead of number.

Performance​

  • Faster compiled JavaScript. A repeated Min/Max call is computed once; Mod(a, 1) uses a shorter formula; a factor of 1 is removed from a product; PointX(PointList(a, b)) emits a; and √a³ and a^(3/2) emit a * Math.sqrt(a) instead of Math.pow (also more accurate). Loop indexes and unrolled sums that cannot be NaN skip their run-time NaN tests. A product with one complex factor uses real multiplication for the real factors.

  • Faster compiled interval-js code. Multiplying or dividing by a constant uses a cheaper operation; a constant such as π/50 in 2πs/100 is computed at compile time; and a constant interval in a function or loop body is created once per call instead of once per use.

0.127.0 2026-09-09​

New Features​

  • Fixed-width collections compile to scalar code on every target. A collection whose width is known at compile time (a literal List, possibly exposed by inlining a user function) compiled to runtime array code on the JavaScript target and did not compile on glsl, wgsl and interval-js. The compiler now unrolls Map(f, List(…)), PointX/PointY/PointZ over a list of points, Min/Max/Sum/Product over a literal list, and scalar-list arithmetic. On JavaScript, a loop-invariant part of a Map, Filter, CountIf or Find callback is computed once instead of once per element. A nine-point Voronoi row written as user functions went from 58 to 2 µs per sample on JavaScript and now compiles on glsl, wgsl and interval-js.
  • Texture-backed At on the WGSL target. With the storage compile option ({ storage: { S: 'sampler2D' } }), At(S, k) now compiles on WGSL. On GLSL and WGSL, a literal index and a gather of several indexes over a texture also compile (an out-of-range literal index gives NaN). These cases did not compile before.
  • Rest parameters on function literals. The last parameter can collect all remaining arguments: (a, ...rest) => … in Epsil, ["Function", body, "a", ["Spread", "rest"]] in MathJSON. rest is a Tuple of the trailing arguments (empty when there are none), and the function types as (unknown, any*) -> R. Spreading it back into a call passes the arguments on: (...args) => Conjugate(g(...args)) applied to (1, 2) gives Conjugate(g(1, 2)). The rest parameter cannot have a type annotation. compile() refuses a function with a rest parameter. Named Epsil definitions accept one too: h(a, ...rest) = Length(rest) and function h(a, ...rest) { Length(rest) }.
  • Primes, the set of all prime numbers. A lazy, infinite set like Integers: Element(7, Primes) is True, iteration yields 2, 3, 5, …, and \sum_{p \in \mathbb{P}} p^{-2} stays symbolic with an integer index. \mathbb{P} parses to Primes and serializes back.

Resolved Issues​

Compilation​

  • User functions passed as values compile correctly. A user function used as the callback of Map, Filter, Reduce, CountIf, Find, Tabulate and similar operators:
    • if its definition could not be compiled, the output compiled with success: true and then threw TypeError: _f is not a function. It is now refused at compile time with a message that names the function, and the default fallback: true uses the interpreter.
    • now broadcasts over an element that is itself a collection, as the interpreter does. For f(x) := 2x, the compiled Map(f, [[1, 2], [3, 4]]) gave [NaN, NaN] and now gives [[2, 4], [6, 8]]. A multi-clause function gave strings such as ["3,61", "91"]. A function with a collection parameter still receives the element whole, and a function whose parameters are declared scalar gives NaN for a collection element.
  • Built-in operators and inline function literals used as callbacks broadcast over a collection element. Over [[1, 2], [], [3]], Map(Sin, xs) now gives [[sin 1, sin 2], NaN, [sin 3]] and Map((x) ↦ 2x, xs) gives [[2, 4], [], [6]]; they gave NaN before.
  • A function parameter with the same name as a user function no longer compiles as a call of that user function. With gsh(t) := sin t + t² and psh(gsh, t) := gsh(t) + 1, psh(u, u) compiled to a call of the global gsh and ignored its first argument. It is now refused.
  • angularUnit = 'deg' applies inside inlined user functions. A Sin in a user function body inlined at a call site compiled without the degree conversion.
  • The compile targets respect broadcastExemptions: ['tuples']. A tuple argument written at the call site was mapped element by element even for an operator that takes a tuple whole, so AsRgb((1, 0, 0)) did not compile on JavaScript.
  • Infix operands of identity operations keep their parentheses. Abs of a non-negative value, Floor/Round of an integer, Re of a real, Identity and similar operations inserted their operand without parentheses: 3·Re(x + 1) compiled to 3 * _.x + 1. Fixed on the JavaScript, GLSL, WGSL and Python targets.
  • WGSL emitted a two-argument atan, which WGSL does not have. Arctan2 and the argument of a complex value now emit atan2.
  • A compiled Mod with a non-literal divisor evaluated the divisor three times (2 mod sin(0.2θ), or Random()). It is now evaluated once.
  • Division by an exact rational compiles as a division. x/49 compiled to 0.02040816326530612 * x, which is not correctly rounded: floor(x/49) gave 0 at x = 49. The JavaScript, GLSL, WGSL, Python and interval-js targets now emit x / 49. A power-of-two denominator (x/2) still compiles to 0.5 * x.

Interval Arithmetic​

  • The interval-js target rounds every result outward. Results were computed in round-to-nearest, so a product, a quotient or a transcendental function could give an interval that did not contain the true value. Inexact endpoints are now widened outward; exact results (an integer, 2 · 0.5, 49/49, cos = 1) stay points. Irrational constants (\pi, e), rationals such as 1/3 and radicals such as \sqrt{2} compile to two-ulp enclosures instead of a single point. atan2 across its branch cut now contains π. A 40-term sum went from 32 to 72 µs per call.
  • Rational powers and roots on interval-js are correct enclosures. A rational power did not contain the true value in about 5% of random cases, and Root of a large operand failed in most cases. Exact results stay points: 8^(2/3) is 4, 27^(2/3) is 9 (it was 8.999999999999998), Root(64, 3) is 4 and Root(1000, 3) is 10.
  • Round(x, n) on interval-js with a negative n scales exactly, so an exact multiple of the step stays a point.

Types​

  • Rest parameters type as variadic. f := (a, ...rest) => Length(rest) was typed (unknown, unknown) -> integer; it is now (unknown, any*) -> integer, so f(1) and f(1, 2, 3) are valid. A literal with a rest parameter matches a variadic declaration such as (integer, any*) -> integer. An Epsil definition with a type parameter, function h<T>(x: T, ...rest) -> T { … }, is typed (x: T, any*) -> T where T; h(1) and h(1, 2, 3) were errors.
  • f(L) := Sum(L) treats L as a collection, so f([1, 2, 3]) is 6. It gave [1, 2, 3], because L had type unknown and the call was mapped over the list. A Sum(L) or Product(L) with no index over a symbol with no other type information now types the symbol as a collection.
  • The index of a Sum/Product over an Element clause has the element type of the collection. It was always integer, so Sum(2x, Element(x, [0.5, 1.5])) threw, and a sum over a set of functions gave an expected-function error.
  • Generic signatures narrow their operands. For f: (T) -> T where T: number | function, f(u) + 1 now types u and the sum as number (the sum was function | number). An operator declared (T) -> T where T: number with a canonical handler now types an undeclared operand as number.
  • LinearRegression(xs, ys, x) and PolynomialFit(xs, ys, n, x) are typed number. LinearRegression([1,2,3],[2,4,6],x) was typed tuple<number, number> while it evaluates to 2x.
  • The LaTeX pipe shorthand has a type. [1,2,3] |> \_^2 was typed unknown and is now list<integer<0..>^3>; [1,2,3] |> \mathrm{Length}(\_) + 1 is integer. A type handler's context.derive now also maps a broadcastable operator over a collection operand: derive('Power', [d, 2]) with d typed list<integer^3> gave number.
  • A second pipe on the same engine no longer throws. After xs |> \_^2, xs |> Take(\_, 2) threw "Function body must be a scoped Block expression", and [1,2,3] |> Sum(\_) gave the list itself.

Arithmetic and Numerics​

  • Assumed inequality bounds are exact. A bound was rounded to a JavaScript number: after assume(v > 1 - 10^{-30}), v > 1 was "proven" and assume(v < 1) was rejected as a contradiction. Bounds are now compared exactly, and ce.ask(['Greater', 'x', '_k']) gives the exact bound (1/3, not 0.333…).
  • Covariance and PopulationCovariance no longer overflow on large data. Covariance([1e200, -1e200, 0], [1e200, 1e200, -2e200]) gave NaN; it is 0. A covariance larger than the largest double is +oo.
  • Conjugate accepts a function. Conjugate(f) is (x) ↦ Conjugate(f(x)).

Colors​

  • A bare tuple as a color argument is 0–1 sRGB everywhere. The compiled targets read it as OKLCh: ColorToString((1, 0, 0)) gave #ff0000 in the interpreter and #ffffff when compiled. A list of three numbers is not a color. AsRgb, AsHsv, AsHsl, AsOklab and AsOklch now accept a color string and a tuple (AsRgb("#ff0000") was incompatible-type).
  • Color strings are validated the same way everywhere. A string that names no color is refused by every operator (ColorMix("bogus", "#ffffff") gave a half-transparent grey). A four-digit hex color (#f00f) is read as CSS reads it. Malformed strings such as rgb(255, 0, 0 and #gg0000 are refused. #00000000 and rgba(0, 0, 0, 0) are transparent black instead of an error. A color tuple must have three or four finite numeric components.
  • Color operators give the same results in the interpreter and in compiled code. On GLSL and WGSL, ColorContrast(Rgb(0,0,0), Rgb(1,1,1)) gave about −114 instead of about −1.079, so ContrastingColor chose black for a 0.6 grey. ColorFromColorspace(Rgb(1,0,0), "rgb") was not red when compiled. "hsv" is accepted by ColorToColorspace/ColorFromColorspace everywhere. Colormap samples the same color as ColorMix. ColorToString(c, "oklch") keeps out-of-gamut chroma when compiled.
  • ContrastingColor returns the chosen candidate in its own color space. ContrastingColor(Oklch(0.98, 0.02, 90), Oklch(0.2, 0.1, 29), Oklch(0.95, 0.2, 264)) now gives Oklch(0.2, 0.1, 29) instead of an Rgb value.

Performance​

  • Compiled JavaScript caches the last call of a pure user function that is called from several places inside other user functions. A repeated call with the same arguments (and the same variable values) returns the cached result. A nine-point Voronoi row went from 3.4 to 2.7 µs per sample.
  • Fewer runtime shape checks in compiled user functions. Inside a user function body, a call that passes on a scalar parameter of that function is a direct call. An untyped chain f(x) := g(x) + 1, g(x) := 2h(x), h(x) := x² went from 155 to 77 ns per call.
  • interval-js compiles a subtraction as one sub call instead of an addition of a negation.
  • Better generated code on the JavaScript, GLSL and WGSL targets. Literal arithmetic is folded; loop-invariant parts of a Sum/Product body are computed once before the loop; user function bodies on GPU targets share common subexpressions; the GPU preamble includes only the helpers the shader uses. GLSL uses fract, exp2, dot and inline small integer powers where possible, and rounds half away from zero like the interpreter. JavaScript compiles an equality of integers to ===, and \arg(a + i b) and |a + i b| to Math.atan2 and Math.hypot.

0.126.2 2026-09-08​

New Features​

  • New expr.isMachineNumeric property. It is true when ce.list(expr.array) reproduces the list exactly: every element is a float or an integer that a double holds. It is false when an element is an exact non-integer, such as 1/2 (expr.array reads it as 0.5, but 0.5 / 3 is a float where 1/2 ÷ 3 is 1/6). On a number, the same rule applies to the number. It is false for all other expressions.

Resolved Issues​

Compilation​

  • Complex compile mode: a call to a user function could give a wrong result. With b(x) := If(x > 0, x, 3), b(z) + z on a complex z computed "1[object Object]". It now gives 4 + 2i. Also, a call to a function whose body already returns a complex value is no longer wrapped in an unnecessary conversion.
  • The python target no longer binds a repeated square as a list comprehension. (x^2+1)/(x^2-1) compiled to [(_cse1 + 1) / (_cse1 + -1) for _cse1 in [x ** 2]][0] since 0.126.0. It is again (x ** 2 + 1) / (x ** 2 + -1).
  • interval-js target: a \sum with a fixed number of terms is up to 4× faster. Constant subexpressions are computed at compile time and terms that do not depend on the index are computed once per call. Results do not change.
  • interval-js target: GCD, LCM and Binomial of an integer at or above 2^53 did not return. They now return a wide enclosure.

Collections​

  • expr.array threw a RangeError for an exact rational outside the double range. It now returns undefined, and isMachineNumeric is false.
  • A user function applied to a list-typed symbol that has no value yet stays unevaluated. With xs: list<integer> declared but not assigned, h(n: integer) = n + 1; h(xs) gave an incompatible-type error. It now evaluates to h(xs), and to [2, 3] once xs = [1, 2]. With an untyped parameter, pair(n) = (n, n); pair(xs) no longer becomes (xs, xs).

Other​

  • Chained set relations. A ⊂ B ⊂ C and a one-operand Subset(D) boxed with an error. Subset, SubsetEqual, Superset and SupersetEqual now accept one or more operands. A chain evaluates all adjacent pairs (ℤ ⊂ ℝ ⊂ ℚ is False; before, only the first pair was checked). The LaTeX \mathbb{Z} \subset \mathbb{Q} \subset \mathbb{R} parses to Subset(Integers, RationalNumbers, RealNumbers), as a < b < c parses to Less(a, b, c).
  • An operator with only an evaluateAsync handler is awaited inside Sum, Product and Block. Σ_{i=1}^{3} AsyncOnly(i) and {x = 1; AsyncOnly(x)} stayed unevaluated with evaluateAsync().

0.126.1 2026-09-07​

Resolved Issues​

  • expr.array no longer approximates exact elements at precision: "machine". ["List", 1, ["Rational", 1, 3]] returned [1, 0.3333333333333333]. It now returns undefined, as at other precisions. An exact value is accepted only when a double holds it exactly (for example 2^70 or 3/4).
  • javascript target: faster selection over a list declared as indexed_collection<number> (1.73 ms to 0.4 ms for a 200×200 Game of Life step).
  • A function literal with wildcard parameters round-trips through LaTeX. () \mapsto 2 parsed as a function of one parameter named Nothing, and () \mapsto \_ + 1 applied to 3 returned _ + 1. Both now parse as ["Function", body]. Also, x_1 \mapsto x_1 + 1 serialized as ()\mapsto x_1+1, which lost the parameter.

0.126.0 2026-09-07​

New Features​

  • ce.list(values) builds a List of numbers quickly. The input can be a number[], a Float64Array or any array-like of numbers. A 40 000-element list is created in a fraction of a millisecond instead of about 5 ms. The result is the same as ce.box(['List', ...]). A non-number element throws a TypeError.
  • expr.array reads a List as plain machine numbers. It is undefined when an element is not a machine number (an exact rational such as 1/3, a radical, a complex number, a symbol, a nested list): values are never approximated. The array can be passed directly to a compiled function.

Improvements​

  • Generated code shares repeated squares and specializes more numeric operations on the JavaScript, GLSL and WGSL targets.
  • javascript target: numeric selections over arrays run in one loop, with no intermediate arrays.
  • javascript target: a list-declared input accepts a typed array. A Float64Array passed for a list<number> or vector<3> input was read as a scalar and gave NaN. It is now copied into a plain array.

Behavior Changes​

  • javascript target: a symbol declared with a scalar type no longer broadcasts over a list. With u declared number and f(x) = 2x + 1, run({ u: [1, 2, 3] }) for f(u) returned [3, 5, 7]; a list is now outside the declared contract. A symbol whose type was inferred from use still broadcasts.

0.125.1 2026-09-07​

Improvements​

  • Faster generated code on the JavaScript, GLSL and WGSL targets. Repeated expressions are computed once, including in sums and products; small powers use multiplication; loops use constant bounds when known; array reads skip checks when the index is proven in range; and fewer function wrappers are emitted.
  • javascript target: list pipelines are about 10× faster. A Game of Life step over a 40 000-cell board went from 13.3 ms to about 1.1 ms. Results do not change.
  • javascript target: a symbol with an assigned value is computed once per compiled function, not on every call. A plot that read a 22 500-element list per pixel went from about 180 ms to 1.5 µs per sample.

Resolved Issues​

  • Interval compilation: a piecewise expression with no matching branch and no default returns empty instead of entire.
  • simplify() treats \log_e as \ln. \log_e(x) simplifies to \ln(x), \ln(x) + \log_e(y) gives \ln(xy), and \log_e(x^3) - 3\ln(x) gives 0. Contributed by @yelliver (#332).

0.125.0 2026-09-06​

New Features​

  • GLSL target: read a fixed-length list from a texture. The new storage compile option stores a list symbol in a sampler instead of a uniform array:

    glsl.compile(ce.box(["At", "S", "k"]), { storage: { S: "sampler2D" } });

    The host declares uniform sampler2D S; and uploads a single-channel float texture with the list in row-major order from texel (0, 0). Indices are 1-based, a negative index counts from the end, and an invalid index reads as NaN. The symbol can be read only with At and a runtime index. The option is ignored on the other targets, and WGSL does not support it.

0.124.3 2026-09-06​

Breaking Changes​

  • Operator type handlers now return BoxedType | undefined. Return context.engine.type('real') for a fixed result, or context.engine.type(computedType) for a computed result. undefined continues to use the declared signature fallback.
  • BoxedType.type is now readonly. Use engine.type(newType) to construct a boxed type with a different value.

Improvements​

  • Faster symbolic operations: simplification is 14–16% faster, solving 7%, integration 9%, and definite integrals 25%, compared with 0.124.2.

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE 0.124.3CE 0.100.0SymPymath.jsMathematica
\pi^29.17.91791063.9
\sin 122212274965.1
\cos 121212215367.1
\ln 216143474,4283.7
e^{\pi}14132124,8244.6
\zeta(3)1,5851,602268—49
\Gamma(\tfrac13)863829362—213
\psi(\tfrac13)7267122,835—171

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (higher is better). — means the engine can't do the case. The CE + R/F column loads the optional Rubi integration rules and Fungrim identities (loadIntegrationRules / loadIdentities).

OperationCE 0.124.3CE + R/FCE 0.100.0SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx2.9×1.8×3.0×0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx6.4×1.2×6.0×0.09×—1×
\int\frac{1}{x^3+1}\,dx3.5×0.6×3.8×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—1.4×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—0.8×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—0.8×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.03×0.03×0.02×0.001×0.004×1×
Simplification
\sqrt{3+2\sqrt2}36×27×30×——1×
\sqrt6\,x+\sqrt2\,x64×55×49×3.2×17×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}27×11×32×2.9×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x3.8×3.1×3.6×2.1×—1×
\int_1^2\tfrac1x\,dx1693×2285×3777×83×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx186×86×230×2.5×—1×
Solving
x^4+x^2-1=00.2×0.2×0.2×0.06×—1×
x^3-x-1=01.5×1.6×1.4×0.04×—1×
Measured 2026-09-06 on Node v22.13.1 with SymPy 1.14.0, math.js 15.2.0 and Mathematica 14.3.0. Correctness is verified against an independent mpmath reference.

0.124.2 2026-09-06​

Performance​

  • Symbolic evaluation is 5–12 % faster on simplification and solving, and about 4–10 % faster on indefinite integrals. For example, solving x⁴+x²−1=0 went from 3.17 ms to 2.86 ms, and ∫1/(x³+1)dx from 2.66 ms to 2.39 ms. Results are unchanged.

0.124.1 2026-09-06​

Resolved Issues​

  • A coordinate accessor over a value that may be one point or a list of points now canonicalizes. With P declared list<tuple<number, number>> | tuple<number, number>, PointX(P[2]) reported an incompatible-type error. An operand with no compatible type is still refused: PointX(5) is still an error.
  • A chained slice no longer makes its base a collection of collections. With Z undeclared, (Z[1..p-1])[W] = Z[p] was typed list<boolean | missing>, so a Filter with that comparison as predicate was refused. It is now typed broadcastable<boolean> and the predicate is accepted.
  • glsl target: Dot over a point list whose component is itself a Dot compiles again. It reported that dot "has no array overload". Lists of complex values, booleans, strings or lists still do not compile.
  • interval-js target: a power whose base reaches zero is no longer reported as clipped. A base of [0, 2] with an exponent interval such as [1.9, 2.1] returned a partial result, so a smooth field looked discontinuous. The base is now clipped only when it has a negative part or the exponent can be negative. The upper bound at such a pole is now +∞, and an exponent that spans zero no longer excludes reachable values ([0, 2]^[-1, 3] contains 0.5³).

Performance​

  • Symbolic evaluation is 25–45 % faster than 0.124.0 on simplification, integration, solving and construction of expressions with exact literals such as √6. For example, simplifying √6x + √2x went from 1.53 ms to 0.96 ms, and ∫1/(x³+1)dx from 4.7 ms to 3.1 ms. Results are unchanged.

0.124.0 2026-09-05​

Breaking Changes​

  • Epsil: a bare lowercase library name refers to the library definition. Because of the new lowercase spellings (see New Features), mean, sum, count, pi and the other spellings resolve to the library when nothing in the program or the engine binds them: mean + 1 is now a type error, where it was a sum with an unknown mean. Declare the variable (let mean = 5) to shadow the library name. MathJSON is not affected (["Add", "mean", 1] still names an unknown symbol), but the engine aliases print and input are removed: use Print and Input in MathJSON.
  • Epsil: a let/const cannot re-declare a name of its own scope. This includes an earlier let of the same block, a parameter of the enclosing function, and the index of the enclosing loop: function f(x) { let x = x + 1; x } is now a variable-redeclaration error, reported by epsil check. Before, the interpreter and compiled JavaScript gave different results. Shadowing a name of an outer scope in a nested block is still allowed, and the shadowing let now reads the outer name on each loop turn: let t = 1; for k in 1..3 { let t = t * 2; xs = Append(xs, t) } collects [2, 2, 2], where it collected [2, 4, 8]. To update a binding, assign to it. A top-level let in a re-run notebook cell still re-declares.

New Features​

Epsil​

  • Lowercase spellings for the standard library. Every library function and constant can be written with an initial lowercase letter: sin(x), map(f, xs), isPrime(7), gcd(12, 18), pi, print("hi"). The MathJSON names (Sin, Map) still work. Names with a dedicated syntax (Add is +, If is if) have no lowercase spelling. A user binding shadows the spelling. Hosts that call parseEpsil and box the tree themselves must call the new resolveLibraryNames(tree, source, ce) first.
  • Common mathematical Unicode notations. Double-struck letters are types (c: ℝ is c: real; also ℤ, ℚ, ℂ, ℕ, and in ce.declare('x', 'ℝ')). √3, ∛8 and ∜16 are radicals (√2x is Sqrt(2)·x). Superscripts are exponents (x², x⁻¹, xⁿ⁺¹). A subscript of letters and digits joins the name (xₙ is the symbol x_n); other subscripts give a Subscript (xₖ₊₁). ∫, ∑ and ∏ are Integrate, Sum and Product. The highlighter and the VS Code grammar support these notations.
  • Unicode output. The serializer option fancySymbols (command line: epsil --epsil --fancy-symbols; MCP serialize and evaluate: fancySymbols: true) writes √x, ∛x, ∜x and x², with parentheses where needed (√(x + 1), (−x)²). The default output stays ASCII. The --epsil output now writes a square as x ^ 2 instead of Square(x).
  • Dot call for protocol functions. c.area() is area(c) and c.scale(2) is scale(c, 2), so calls chain (c.scale(2).area()). Named arguments follow the receiver (c.scale(k: 2)), and c.(Shape.area)() names the protocol when two protocols declare the same member. Other functions are the new error dot-call-not-a-protocol-function (xs.Sort()). c.area without parentheses is still a field read, and a declared field still wins over a member of the same name.
  • epsil check --effects reports the inferred effects of each top-level function, for example f (line 1): console or g (line 2): pure (declared). With --json the report is the effects array; the MCP check tool accepts "effects": true. The VS Code hover shows the same line.

LaTeX Parsing and Serialization​

  • Typed lambda parameters. (i: integer) \mapsto 2i and (f: (real) -> real) \mapsto f(1) parse to Function literals with typed parameters and serialize back as (i\colon integer)\mapsto 2i. Other uses of a colon (set-builder, piecewise, f: A \to B) are unchanged.
  • Inline lambda application. (x \mapsto 2x)(3) and ((x, y) \mapsto x + y)(2, 3) evaluate to 6 and 5; they parsed as products. A power on the argument list applies to the application: (x \mapsto 2x)(3)^2 is 36, and (f)(3)^2 reads as f(3)^2.

Types​

  • A use of an element refines the collection's element type. xs[1] + 1 makes an undeclared xs an indexed_collection<number>, xs["a"] + 1 a dictionary<number>, and m[1][2] + 1 makes m an indexed_collection<indexed_collection<number>>. A lambda parameter shows the refinement: (v) => v[1] + 1 is typed (v: indexed_collection<number>) -> broadcastable<number>. A later assignment replaces the inferred type, and a declared type is never changed. This applies to At, First, Second, Third and Last; an operator definition can declare its own rule with the new inferOperandTypes handler.

Resolved Issues​

LaTeX Parsing and Serialization​

  • A set-builder with several conditions now parses as a comprehension. \{ k : k \in \{1, 2, 3\}, k > 1 \} parsed as a two-element set; it now evaluates to {2, 3}. The domain can also be on the left of the colon (\{ k \in S : k > 1, k < 3 \}). A trailing comma in a set literal or a piecewise no longer adds a stray element.
  • An inferred parameter type is no longer serialized. With C a list of integers, \operatorname{Filter}(C, Z\mapsto 0<|Z|) printed (Z\colon integer)\mapsto…. A written annotation still round-trips. To see inferred annotations, use toMathJson({ inferredAnnotations: true }).

Epsil​

  • A multi-clause function with a typed scalar parameter maps over a collection. With fib(n: integer) = … defined by clauses, 5..10 |> fib |> Sum failed with incompatible-type; it now returns 136.
  • A number literal keeps all its digits. 12345678901234567890.5 parsed as 12345678901234570000, 1e400 as Infinity, and hexadecimal or binary integers above 2^53 were rounded.
  • Serialized Epsil re-parses to the same expression. Power(-2, 2), Power(Power(x, 2), 3), Subtract(a, Subtract(b, c)) and Factorial(-2) now serialize as (-2) ^ 2, (x ^ 2) ^ 3, a - (b - c) and (-2)!.
  • A match or if let pattern that starts with an unknown glyph reports an error. match x { ⊕ => 1 } silently dropped the case.
  • A destructuring comprehension binder gets its type from the collection. In [p + q for (p, q) in pairs] with pairs: list<tuple<number, number>>, p and q are number. A pattern of the wrong arity, or a name bound twice, is now an error.
  • An empty block evaluates to Nothing. if 1 < 2 { } returned the empty block {} with type unknown; it now has type nothing.
  • A return-type annotation no longer gives a function the effects of the lambda it returns. function make() -> ((number) random -> number) { x => Random() * x } was typed () random -> …; it is now pure.

Types​

  • q + 1 with q: boolean | missing is now refused, as Sin(q) already was. unknown | missing and list<number> | missing are still accepted.
  • A collection of non-numbers at a numeric element-wise operator is refused when boxed. Ln(["a", "b"]) and Add(["a"], 1) are now incompatible-type errors, as Sin(["a", "b"]) already was. A collection that could hold numbers (such as list<integer | string>) is still checked per element at evaluation.

Compilation​

  • A compiled function no longer returns a string when given a matrix where it expects a list of numbers. With h := (v) => v[1] + 1, the compiled h on [[1, 2], [3, 4]] returned "1,21". It now throws an error that suggests declaring the parameter as list<list<number>> or matrix.
  • javascript target: a Sum with index i gives correct results. \sum_{i=1}^{3}\cos(i(|x|-\sqrt{9.81/k[i]}\,t)) returned "[object Object]…" because i was read as the imaginary unit. With mode: "complex" it also returned a wrong value.
  • javascript target: Abs of a parameter that may be a point or a point list is decided at run time. With g(P) := |P - (4, 0)|, g((5, 1)) returned [1, 1] instead of √2. A point subtracted from a point list is now subtracted from each point.
  • interval-js target: Arctan2 over a box that crosses its branch cut reports a jump. It returned a bounded interval; it now returns singular with the enclosure [-π, π].
  • interval-js target: a domain clip is kept by the operations above it. x + \sqrt{x^2 - 0.01} over x ∈ [-0.3, 0.3] returned a bounded interval, although the function is undefined on |x| < 0.1; it now returns partial.
  • python target: a for loop over a Range with a non-integer bound matches the interpreter. A symbolic bound of 2.5 raised TypeError, and a literal bound of 5.5 walked 5, 4, … instead of 5.5, 4.5, ….

Arithmetic and Numerics​

  • A subscript with a computed index evaluates. With k := 3, x_{k+1} evaluates to the symbol x_4, and to its value when x_4 is assigned.
  • Recursive functions in a comprehension are no longer recomputed for each element. [P(i) for i in 0..n], with P(i) defined from P(i-1), called the step function n(n+1)/2 times; it now calls it n times.
  • An asynchronous-only operator inside a comparison, If or Which is awaited. Less(15, AsyncOnly(2)).evaluateAsync() returned 15 < AsyncOnly(2). Inside the body of a big operator or a block, such an operator still stays unevaluated.
  • GammaRegularized(a, z) evaluates for a negative non-integer a. GammaRegularized(-1/2, 2) is now −0.00849… and GammaRegularized(-1/2, 0) is −∞. A negative z still stays symbolic.
  • Variance, Covariance and Correlation are accurate for data far from zero. Variance([10⁸+1, 10⁸+2, 10⁸+3]) returned 0 instead of 1, and Correlation([1.5e200, 2.5e200, 3.5e200], [1, 2, 3]) returned NaN; it is now 1. Correlation is typed real<-1..1>.
  • Operations whose work grows with an argument value are bounded. Eulerian, Stirling, NPartition, StirlingS1, BernoulliB, MatrixPower, Chunk and others could run without limit or exhaust memory (Stirling(4000, 2000)). Past a limit they now stay symbolic or report iteration-limit-exceeded. Eulerian(60, 30) now returns immediately.

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE 0.124.0CE 0.123.2SymPymath.jsMathematica
\pi^210121751033.9
\sin 122232194385.1
\cos 121232165227.1
\ln 215173424,3643.7
e^{\pi}14162114,6594.6
\zeta(3)1,5381,587270—49
\Gamma(\tfrac13)855839339—213
\psi(\tfrac13)7297232,795—171

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is (higher is better). — means the engine can't do the case. CE + R/F is the current build with the optional integration rules and identities loaded (loadIntegrationRules / loadIdentities).

OperationCE 0.124.0CE + R/FCE 0.123.2SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx2.2×1.4×1.7×0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx5.0×0.9×3.6×0.09×—1×
\int\frac{1}{x^3+1}\,dx2.1×0.6×1.7×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—0.7×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—0.5×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—0.5×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.02×0.02×0.01×0.001×0.004×1×
Simplification
\sqrt{3+2\sqrt2}16×15×14×——1×
\sqrt6\,x+\sqrt2\,x23×20×16×3.2×20×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}23×11×19×3.0×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x3.1×3.0×3.0×2.1×—1×
\int_1^2\tfrac1x\,dx1441×2430×1187×85×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx140×57×118×2.5×—1×
Solving
x^4+x^2-1=00.08×0.08×0.08×0.06×—1×
x^3-x-1=01.0×1.1×1.0×0.04×—1×
Measured 2026-09-05 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 (carried over from the 2026-08-19 run) · Node v22.13.1. Correctness is verified against an independent mpmath reference.

0.123.2 2026-09-04​

Improvements​

  • Typing a large list of tuples is about three times faster. Typing a list of 5,000 pairs of radicals is about ten times faster.
  • Growing a list one element per loop turn is much faster. xs = Join(xs, [k]), xs = Append(xs, k) and xs = [...xs, k] in a loop took 2.9 s for 400 turns and about 30 s for 1000; they now take 89 ms and 379 ms. A Join of list literals, and an Append to a list literal, now become one list literal at canonicalization: Join([1, 2], [3]) is [1, 2, 3]. Such a result is no longer a lazy collection, and its type carries the shape: Join([1, 2, 3], 10) types vector<integer^4> (was list<integer>).

Resolved Issues​

  • Real-only functions over a list that may hold complex values now compile in the auto and complex modes. Min, Max, Floor, Mod, the statistics functions and the ordering comparisons over such a list (√L for L: list<real>, a list<complex> symbol) were refused on the JavaScript and Python targets. Each real element now gets the real result and every other element is NaN: ⌊√L⌋ at L = [4, -1] is [2, NaN]. This also fixes two wrong results: √L < 1 at L = [0.5, 2] returned [false, false] (now [true, false]), and min(Z) for a list<complex> symbol returned NaN. strict mode is unchanged.
  • List and set literals now await asynchronous elements. [1, AsyncOnly()].evaluateAsync() returned [1, AsyncOnly()] for an operator with only an evaluateAsync handler. Elements are now awaited in order.
  • evaluateAsync() now honors numericApproximation on numbers and symbols. [1/3].evaluateAsync({ numericApproximation: true }) kept the exact value. A set comprehension now also evaluates the values of a literal domain: {k : k ∈ {x, 2}} with x := 5 returned Set(x, 2).
  • A tuple, set or list of inexact literals no longer carries a numeric range in its type. (√2, 1) typed tuple<real<1.4..1.5>, integer> and now types tuple<real, integer>. A list of 5,000 such points typed list<tuple<any, any>> and now types list<tuple<real, real>^5000>. A record holding ~oo now types record{x: infinity}.
  • A point with a list component now compiles under Norm, Abs and Hypot. Norm(([1, 2], 3)) is [√10, √13] and Hypot(([1, 2], 3), 4) is [√26, √29]. The JavaScript target used to fall back to the interpreter.
  • Hypot with a point and a list paired the point's components with the list's elements. Hypot((3, 4), [1, 2]) returned [√10, √20]; it is now [√26, √29] (the point's norm is one leg of each hypotenuse). Abs follows the same rule. Power((1, 2), [3, 4]) is still [1, 16].
  • Destructured block locals compile. After (xs, n) := ([1, 2, 3], 2) in a block, Length(xs) and At(xs, n) now compile instead of failing.
  • A list times a point now types as a list of points. [1, 2] · (3, 4) evaluates to [(3, 4), (6, 8)] but typed tuple<integer, integer>; it now types list<tuple<integer, integer>^2>.

0.123.1 2026-09-04​

Resolved Issues​

  • A single-letter builtin name in an optional or variadic argument is read as a variable. Range(1, N), Max(1, N) and Min(2, N, M) returned an incompatible-type error on N, while N + 1 and Range(N) read N as a variable. For example, [1, \ldots, N] with an undeclared N failed in 0.123.0.
  • Join and Append no longer drop an operand that has no value yet. With v declared list<number> and unassigned, Join([1], v) evaluated to [1] and Append(v, 2) to [2]. They now stay unevaluated until v has a value. First(Join([1], v)) still returns 1.
  • A symbol declared with the bare tuple type is treated as a tuple in arithmetic. With ce.declare('w', 'tuple'), Sin(w), Sqrt(w) and w^2 typed number; they now type tuple. Abs(w) is the norm, and w · w is the no-product-between-points error.
  • A list scaled by a symbolic tuple kept a unit factor. [1, 2, 3] · p evaluated to [1p, 2p, 3p]; it is now [p, 2p, 3p].

0.123.0 2026-09-04​

New Features​

  • Epsil: match exhaustiveness warning. A match whose subject has a closed type (a sum such as type light = red | green | yellow, or boolean) now reports match-not-exhaustive when a value reaches no case, and names the missing pattern (yellow(), false). A guarded case does not count as covering. See epsil doc match-not-exhaustive.
  • Typed match bindings and is tests compile. On the JavaScript target, MatchesType(v, T) (used by n: integer => …, while let v: T = … and x is T) now compiles for numbers, strings, booleans, literal types, numeric ranges, unions of these, and tagged non-generic sums. Other types, and other targets, still fail to compile.
  • Destructuring loops compile. for (p, q) in pairs { … }, and the same pattern in a Comprehension, now compile on the JavaScript and Python targets, when the element type is known to be tuples of the right arity (a Zip, a literal list of tuples, a list<tuple<…>> annotation).
  • The Python target compiles more loop forms. A Loop over several Element clauses, a Range with integer literal bounds (Range(10, 1, -3) → range(10, 0, -3)), and a Comprehension (as a list comprehension).
  • Zip types its elements. Zip(xs, ys) now types list<tuple<X, Y>> from the element types of its sources.
  • Epsil: a standard library index (published at /epsil/library/) lists every function and constant by category, with its signature, description and a tested example.
  • Epsil: a style guide (published at /epsil/style/) describes idiomatic Epsil with an example for each idiom. It recommends Map/Fold, or ListFrom, instead of growing a list with Join in a loop.

Resolved Issues​

Types and Validation​

  • Operators with custom canonical forms now validate their arguments against their signature. PoissonDistribution(s) with s := -3 was accepted, while a literal -3 was refused. It is now refused too. Related changes:
    • Sin(+oo) and a range used as a range bound are now refused when the expression is created, instead of when it is evaluated.
    • Some signatures are now wider: Apply takes any function (Apply(3, 5)), a relation takes a single operand (Less(3) is True), At takes a base of any type, and the set operators take any operand.
  • Function signatures print unions correctly. (complex | infinity, (complex | infinity)+) -> number printed without the inner parentheses, and (real<0..>) | nan printed unnecessary ones. A union may now print its members in a different order (nan | real<0..>).

LaTeX Parsing and Serialization​

  • Norm(v, p) round-trips through LaTeX. The order was dropped, so a 1-norm re-parsed as a 2-norm. It now serializes as a subscript (\|v\|_1, \|v\|_\infty, \|v\|_F for Frobenius) and parses back from every norm notation.

Statistics​

  • A discrete PDF or CDF at a symbolic point now matches the numeric result. PDF(PoissonDistribution(2), x) with x := 0.5 returned 0.216, while PDF(PoissonDistribution(2), 0.5) returns 0. The symbolic forms now include the support condition, for example Which(⌊x⌋ = x ∧ x ≥ 0, …, True, 0). A point declared integer<0..> keeps the plain closed form.

Compilation​

  • A value that may be a scalar or a list compiles correctly. On the javascript target, an operand typed integer | vector<integer^2> made Sum(x) fail, and 2·x, x + 1 and x < 1 returned wrong values (NaN, or the string "1,21"). They now handle either shape at run time. Such a union now types 2·u as number | vector<2> (was number).
  • A compiled condition with a random operand handles NaN. If(Random() > 0.5 ∧ x > 0, a, b) took the else branch at x = NaN; it now returns NaN, and Random() is called at most once.
  • The Python target emitted JavaScript for a Comprehension.
  • A Range whose bound is not a number compiled to an empty range. Sum(1..10..2) compiled to 0. It now fails to compile.
  • Compiled Sum, Product and Comprehension loops no longer recompute loop-invariant list values on each iteration. A 10,000-element histogram took 25 s; it now takes under 0.1 s at 16,000 elements. cse: false turns this off.

Arithmetic and Numerics​

  • A NaN in a branch condition selects no branch. If(NaN > 0, 1, -1).evaluate() returned -1, while compiled code returned NaN. It now evaluates to Missing. Greater(NaN, 0) is still False, and If(NaN > 0 ∧ False, 1, -1) is still -1. A NaN element in an element-wise condition still takes the else value.
  • Exact numbers are compared exactly. Less(10^400, 10^500) and Greater(10^500, 10^400) were both False, and 1 + 10^(-20) compared equal to 1.
  • Factorial of a very large integer no longer hangs. Factorial(10^400) now stays symbolic, and its .N() is +oo. The same applies to Factorial2.
  • Dividing a very small or very precise exact rational no longer rounds it. Divide(1/10^400, 3) was 0 and Divide(99999999999999999999/10^20, 1) was 1. CDF(UniformDistribution(0, 1), 10^-400) is now 10^-400.

0.122.0 2026-09-03​

Breaking Changes​

  • A type handler in an operator definition now receives operand descriptors, not expressions. The signature is (operands: OperandDescriptor[], context: TypeHandlerContext) => Type. A descriptor has the operand's type, facts such as facts.finite, facts.sgn and facts.elementType, and a structural view from structureOf(). Use context.derive(operator, operands) to type a sub-expression. A handler can no longer declare, canonicalize or evaluate. The typeHandlerKind flag, the OperatorTypeHandlerOnExpressions type and the operandTypes option are removed. To migrate: ops[i].type.type → operands[i].type; .ops, .operator and .string → structureOf().

    Some built-in types changed: {n² : n ∈ ℤ} now has elements integer<0..> (was unknown); an Interval with a symbolic endpoint has real elements (was never); √2 ∈ [√2, 1] now types boolean.

New Features​

  • RuntimeError(code) creates an error value when evaluated. A function whose body contained Error("neg") was never defined. With function g(x) { if x > 0 { x } else { RuntimeError("neg") } }, g(-1) is an error value that if let v: !error = … and match can inspect. The javascript target does not compile it.
  • Epsil if let. if let pattern = subject { … } else { … } binds the pattern when the subject matches, and runs else otherwise: if let v: !error = f(x) { v } else { 0 }. Without else, a failed match evaluates to Missing. New warnings: if-let-irrefutable, if-let-equal-expected.
  • Epsil while let. while let pattern = subject { … } loops until the pattern fails to match: while let [h, ...t] = xs { s = s + h; xs = [t] }. New warnings: while-let-irrefutable, while-let-equal-expected. It compiles on the JavaScript target but not on the Python target.

Resolved Issues​

Errors and Evaluation​

  • An error from evaluating an operand is now the result. Sin(Length(5)) evaluated to sin(Error(…)) and 1 + Length(5) to 1 + Error(…); both now return the error. A collection still keeps a failed element in place.
  • A user function whose body evaluates to an error returns the error. len := x ↦ Length(x) then len(5) returned len(5). An Epsil function can now return an error value, such as match-no-case.
  • Deeply nested tuples or lists with shared parts are fast. A value built as Tuple(t, t) twenty levels deep took seconds and printed a type with a million entries. Types are now limited in size.
  • Nested list literals with shared sub-lists no longer overflow the stack. Length or Element over List(t, t) 18 or more levels deep overflowed.
  • toLatex({ materialization: false }) no longer evaluates anything. Printing Join(p) with p bound to a long unevaluated chain could take minutes.

Pattern Matching​

  • match sees lazy lists. match Rest([1, 2, 3]) { [h, ...] => h; _ => "no" } took the wildcard case, and a while let [h, ...] = xs loop ended after one turn. A lazy list now matches a list pattern.
  • A match case can break or continue in compiled code. It caused an Unexpected token 'break' syntax error.
  • A compiled ...rest capture now splices into a list. [h, ...t] => [t] compiled to a list that held the tail as one element, so a while let [h, ...t] = xs { xs = [t] } loop never ended.
  • A binding with a guard compiles. n if n > 3 => … failed on the JavaScript target.

Types​

  • The stage of a pipe keeps its element type after evaluation. xs |> p ↦ p[1] ∧ p[2] over a list of boolean pairs types list<boolean^3> before and after evaluation.
  • A scalar function over a list of points keeps the point shape. With P: list<tuple<number, number>>, Sqrt(P) and Power(P, 2) typed list<number>; they now type list<tuple<number, number>>.
  • A comprehension variable's type comes from the collection. In [q.x + 1 for q in L] with L: list<number>, the body changed the type of q and the expression failed only when evaluated. It is now a type error when the expression is created.
  • Reshape with a non-literal size no longer throws. Reshape([1,2,3,4], (n, 2)) threw Failed to parse type; it now types list<number>.
  • point + (list, list) types as a tuple of lists. G + (L, L₂) now types tuple<list<number>, list<number>>, not a union.

Compilation​

  • The javascript target refuses invalid point arithmetic. P·P for a point list, P + 2, P − L, and a scalar divided by a point list compiled to plausible values where evaluate() returns an error. They now fail to compile, and so does a comprehension whose variable appears in its own collection ([P.x + 1 for P in P]).
  • List arithmetic with radicals and logarithms compiles. L + √L, 2·√L and L + ln L failed to compile on the javascript target.
  • List arithmetic over a mix of real and complex elements compiles. Add, Subtract, Multiply and Negate over 2·[1+i, 2] or a list<complex> parameter now compile.

LaTeX Parsing and Serialization​

  • The serializer overflowed the stack on a product with a never-typed factor, such as f(f(b)) - f(f(a)) = (f'(c))^2 (b - a).
  • An ellipsis over an indexed family is no longer read as an arithmetic progression. \{\frac{x_1}{1+x_1}, \frac{x_2}{1+x_1+x_2}, \dots, \frac{x_n}{1+\dots+x_n}\} parsed to a Range; it now stays a set, like \{x_1, x_2, \dots, x_n\}.
  • A two-element Tuple in a set position serializes unambiguously. It was (a, b), which reads back as an open interval; it is now \operatorname{Tuple}(a, b).

0.121.1 2026-09-03​

New Features​

  • Join accepts scalar operands. A scalar is appended as one element: Join([1, 2], 3) and Join(1, 2, 3) are [1, 2, 3]. This was an incompatible-type error. The javascript and python targets now also append a tuple operand as one element instead of spreading its components.
  • Sign accepts complex numbers. Sign(z) is z/|z|: Sign(i) is i and Sign(3 + 4i) is 3/5 + 4i/5. This was an incompatible-type error. For real values the result is still −1, 0 or 1; Sign(~oo) is still an error and Sign(NaN) is NaN. The javascript target compiles the complex case.

Resolved Issues​

  • Fixed compiled PointX, PointY and PointZ reading a list of points as a single point when the operand shape was not known at compile time. For f(v) := PointX(v) + 1, f(P) over a list of points compiled to [2, 3, 4] instead of [2, 5], or threw inside a PointList.
  • Fixed compiled base-10 and base-2 logarithms losing precision: log(x) / log(2) at x = 4 gave 1.9999999999999996 instead of 2.
  • Fixed ["Function", ["Add", ["Negate", "q"], ["Tuple", 1, 2]], "q"] being an incompatible-type error while q + (1, 2) was accepted.
  • Fixed the glsl and wgsl targets missing parentheses before a swizzle: PointX((x, y) + (1, 2)) compiled to vec2(x, y) + vec2(1.0, 2.0).x. The same applied to First, Second and Third.
  • Fixed compiled color constructors (Rgb, Hsv, Hsl, Oklab, Oklch) with a non-finite channel: they now return a NaN color. Hsv(90, 1, ~oo) compiled to the same color as Hsv(90, 1, 1).
  • Fixed compile() taking exponential time and memory, and eventually throwing Invalid array length, on expressions with deeply shared sub-expressions.

0.121.0 2026-09-03​

Breaking Changes​

  • Numeric types now distinguish finite values, infinities, and NaN. integer, rational, real, and complex contain only finite values; number is now complex | infinity | nan. The new infinity type contains +oo, -oo, and ~oo; signed_infinity contains only +oo | -oo. The old name non_finite_number is a temporary alias for signed_infinity, and the finite_* names are temporary aliases for the bare types (finite_number maps to complex).

    Use BoxedType.complex, BoxedType.integer, BoxedType.real, and BoxedType.setInteger instead of their finite_* counterparts. Use isExtendedReal instead of the removed isReal property.

  • Infinity and NaN have precise types and matching runtime behavior. Arithmetic, functions, assignments, collection accessors, and compiled code report or propagate infinity, signed_infinity, and nan. Finite numeric types no longer accept infinite arguments. Infinite endpoints of intervals and ranges are not members of them.

  • Assumptions refine values only while they are in scope. They no longer permanently change declarations or inferred signatures. forget() and leaving a scope remove the refinement, and an assignment that contradicts an active assumption is rejected.

    The assumption API now exposes assumedValues, and ce.context.assumptions contains immutable fact arrays instead of booleans. assumptionBindings, ContextAssumptions.bindings, TypeProvenanceEntry.previousType, and the assumed provenance kind are removed.

  • Numeric predicates are three-valued. isNumber, isInteger, isRational, and isExtendedReal return true when membership is proven, false when it is impossible, and undefined when it is not known.

  • Functions enforce their mathematical domains. Operations that need an ordered value (Heaviside, Sign, rounding, Clamp, ElementMin, ElementMax) accept only finite reals and signed infinities; complex values and ~oo are rejected. NaN propagates through numeric evaluation.

  • Elementary and special functions handle infinities consistently. They return their limits at infinities, reject arguments outside their domain, and stay symbolic when no numeric value is available. evaluate(), N(), and simplification agree more closely at exceptional points. Examples: Sqrt(~oo) = Ln(~oo) = ~oo, (+oo)^(+oo) = +oo, IEEE values for Arctan2 at infinite corners, and NaN for indeterminate forms.

  • Complex accessors propagate NaN. This applies to Real, Imaginary, Argument, AbsArg, and Conjugate. The real part, imaginary part, and argument of ~oo are NaN.

  • Power and Root use the same rules for infinities. ~oo is not a valid exponent. Root(x, n) follows Power(x, 1/n), and a zero root index is an error.

  • Arithmetic and number-theory operators validate their arguments. Mod, Fract, Rationalize, ContinuedFraction, the parity and prime predicates, and the numerator/denominator accessors reject invalid arguments. The continued fraction of an inexact value uses its best rational approximation at the current precision. IsEven is now kept as its own operator.

  • Linear-algebra operations validate numeric inputs. Norm, Trace, MatrixPower, and Determinant reject incompatible operands and have more precise result types. Frobenius norms work for vectors and higher-rank tensors, the norm of an empty collection is 0, and numeric half-integer powers of exact 2×2 positive-semidefinite matrices are supported.

  • Combinatorial and regularized functions have corrected domains. Binomial, Choose, Pochhammer, GammaRegularized, and BetaRegularized handle infinite limits consistently, and several results for negative arguments and gamma poles are corrected.

  • Probability distributions validate their parameters and arguments. Normal, uniform, Poisson, exponential, and binomial distributions reject invalid parameters. Uniform and exponential PDF/CDF are piecewise, discrete distributions return correct values outside their support, and N() computes binomial and Poisson quantiles.

  • Statistics functions reject invalid samples. Non-numeric or nested values are an incompatible-type error. NaN and Missing propagate, quartiles of a single value are supported, and quartile tuple fields are ordered lower, mid, upper.

  • Selections are lazy, and undecided conditions stay unevaluated. If, Which, And, and Or evaluate only the operands needed, so an error in a branch that is not selected does not affect the result. A condition that is provably not boolean is an incompatible-type error. A selection with no matching branch and no default returns Missing. Custom lazy operators can get the same behavior with selectsOperands.

  • Compiled conditions use three-valued logic. Compiled JavaScript and Python no longer select a branch for a NaN, absent, or undecided condition. And, Or, and Not still short-circuit.

  • Compiled float-only targets represent ~oo as Infinity. These targets cannot distinguish signed and complex infinity.

  • Result types include possible exceptional results. Partial operations can have nan or infinity in their type, and domain failures of non-numeric operations return Missing. Division and negative powers include non-finite results when the divisor can be zero. An expression with an empty-range operand has the type never.

New Features​

  • Operator definitions can declare their error behavior. The new nanBehavior, partiality, definedWhen, and requires fields describe how NaN is handled, whether the operation is partial, its domain, and its preconditions. Result types include nan or missing only when needed.
  • Hypot accepts infinite arguments. An infinite argument gives +oo even when another is NaN; otherwise NaN propagates. Euclidean norms and distances follow the same rule.
  • Factorial, Factorial2, Gamma, and GammaLn accept signed infinities. +oo gives +oo; -oo gives NaN.
  • D differentiates piecewise expressions. Each branch of If and Which is differentiated and its condition is kept.
  • Componentwise functions broadcast over tuples. Sin((1, 2)) evaluates each component. Functions that treat a tuple as a point, such as Abs, are unchanged.
  • The javascript target compiles more user functions: bounded generic functions, functions that return tuples, array arguments broadcast at run time, complex-valued callbacks, and element-wise selections with complex branches.
  • The python target compiles statement-form conditionals. If with an else branch compiles inside blocks and loops.
  • The interval-js target supports more expressions: Choose, direct application of function literals, bounded random draws, and sums over collections. A finite jump discontinuity gives a singular result with a correct value enclosure, instead of being treated as a pole.
  • Predicates can have the type true or false when the operand types decide them; otherwise the type is boolean. Compiled code can remove unreachable branches.
  • Numeric ranges support open endpoints. real<0<..> means x > 0, real<..<3> means x < 3, and real<0<..<3> means 0 < x < 3. An empty range is never.
  • Numeric result types include computed bounds for Add, Multiply, Abs, Divide, and integer powers. Inexact literals get a small enclosing range type.
  • New infinity and nan primitive types, usable in type strings, declarations, signatures, matches(), and isSubtype(). ~oo and ~∞ are the type of complex infinity.
  • BigDecimal.toPrecisionToward(n, direction) rounds to n significant digits toward negative or positive infinity.

Resolved Issues​

Types​

  • Fixed the TypeScript signature of ce.declare() rejecting an existing operator definition, for example ce.declare('Sqrt', { ...ce.expr('Sqrt').operatorDefinition!, evaluate }). The accepted type is exported as SymbolDefinitionInput.
  • Fixed type inference and broadcasting through type aliases, including recursive aliases that could overflow the stack.
  • Fixed assumptions leaving declarations behind after they were removed, and types that depended on temporary assumptions.
  • Fixed a function parameter named i or e not shadowing the library constant.
  • Fixed assigning a function signature through .type corrupting later type reads.
  • Fixed unions that contain broadcastable<T> not broadcasting.
  • Fixed exact literal types changing with the precision setting.

Arithmetic and Numerics​

  • Unexpected failures during evaluation now use the internal-error code instead of evaluation-error.
  • Fixed arithmetic with infinities in scalars and collections, including zero times infinity, division of a collection by zero or infinity, and the reciprocal of infinity.
  • Fixed Euclidean norms and distances with infinities and NaN, including matrix norms that ignored a NaN row or column.
  • Fixed numeric integration and special functions discarding the imaginary part of complex values. Unsupported complex evaluations now stay symbolic.
  • Fixed very large prime, Lucas, and Catalan computations running indefinitely.
  • Fixed one-sided limits at jump discontinuities.

Collections​

  • Fixed Length and Count staying unevaluated for values known not to be collections.

Compilation​

  • Compilation output is now deterministic.
  • Fixed compiled Block locals losing their declared or assigned types.
  • Fixed compiled Heaviside(NaN) and Sign(NaN) not returning NaN.
  • Fixed compiled JavaScript for a point added to a list of points, and for collection arguments to compiled functions.
  • Faster JavaScript compilation of expressions that reference assigned values many times; self-referential assignments no longer overflow the stack.

Colors​

  • Fixed OKLCh conversion producing negative hue angles; hues are now in [0, 360).

0.120.0 2026-08-27​

Breaking Changes​

  • The bivariate statistics reject complex data instead of silently using its real part. Covariance, PopulationCovariance, Correlation, LinearRegression and PolynomialFit previously projected every data point through its real part, so Covariance([1, 1+2i], [2, 3]) returned 0 — the answer for the data [1, 1]. A non-real data point now returns a structured incompatible-type error naming the operator and the offending datum. A complex literal with a zero imaginary part (Complex(2, 0)) still canonicalizes to a real and participates normally, and the complex infinity ~oo is not complex data in this sense: it is the single point at infinity rather than a sample point off the real line, so it propagates NaN. Additionally, Correlation now propagates NaN for any data it has no real value for — NaN, ±∞ and ~oo — matching Covariance, instead of misreporting it as "zero variance". That error is now raised only when a column is genuinely constant, so at ce.precision = 'machine' it also no longer appears for finite data whose sums of squares overflow the double range (values around 1e200), which now yields NaN. (At the default precision the bignum kernel handles such data without overflowing.) (Complex covariance — E[(X−μX)·conj(Y−μY)] — is not implemented; if you need it, compute it from Mean directly.)

  • The one-sample statistics no longer silently use the real part of complex data: they either compute the complex answer or reject it. Every one of them used to project each datum through its real part, so Mean([1, 1+2i, 5]) answered 2.333…, the mean of [1, 1, 5]. Now:

    • Mean returns the COMPLEX mean — Mean([1, 1+2i]) is 1 + i — with no convention involved, since the mean is linear. Exact data still gives an exact answer: Mean([1, i]) is (1 + i)/2, not 0.5 + 0.5i.
    • Variance, PopulationVariance, StandardDeviation and PopulationStandardDeviation compute E[|X − μ|²] — the mean squared MAGNITUDE of the deviations, with the same sample (n − 1) or population (n) divisor as before. The result is a real, non-negative number: Variance([1+i, 1−i]) is 2 and PopulationVariance([1+i, 1−i]) is 1.
    • Median, Mode, Quartiles, InterquartileRange, Skewness, Kurtosis, Histogram, BinCounts and the empirical data form of Quantile return the same incompatible-type error the bivariate statistics use. Order statistics need a total order, and the complex plane has no canonical one; the standardized moments have only convention-laden, branch-dependent complex extensions. Histogram/BinCounts reject a complex bin EDGE for the same reason. (Quantile of a DISTRIBUTION is unaffected.)

    Real data is unaffected, including the exact results (Mean([1, 1, 5]) is still 7/3), and NaN and a real ±∞ keep the readings they have always had in these heads (Mean([1, +∞, 5]) is +∞, Median([1, +∞, 5]) is 5).

  • The complex infinity ~oo now reads as NaN in the one-sample statistics, under both of its spellings. The real part ~oo reports is an artifact of how it was written — ComplexInfinity reports Infinity, Complex(1, Infinity) reports 1 — so the same value produced different statistics depending on the spelling: Mean([1, ~oo, 5]) answered +∞ one way and 2.333… the other, and Median answered 5 one way and 1 the other. Every one-sample head now projects it to NaN, which is what the bivariate heads already did, so both spellings agree: Mean, Median, Mode, the variance family, Skewness, Kurtosis and InterquartileRange return NaN, Quartiles returns (NaN, NaN, NaN), and the empirical Quantile returns NaN. A real ±∞ is unaffected.

  • Mean and the variance family return NaN for complex data mixed with a non-finite value. Variance([1+2i, +∞]) returned +∞, an accident of boxed arithmetic that contradicted the real-only path (Variance([1, +∞]) is NaN). A complex sample point together with a point at infinity has no reading — +∞ is a limit along the real axis, and no direction in the plane makes it a value a complex number can be averaged with — so NaN is what these now answer. The real-only paths are unchanged.

  • Histogram and BinCounts stay inert on data (or bin edges) that is not a number literal, instead of silently dropping it. A Sqrt(-2) datum has no real value, and it used to be filtered out of the sample: BinCounts([1, Sqrt(-2), 5], 2) reported the counts of [1, 5]. It now leaves the expression unevaluated, the same way the empirical Quantile does; under .N() the datum numericizes to a complex literal and is rejected with the incompatible-type error.

  • A PolynomialFit degree that is not an integer is reported as a bad degree. The degree was read with a helper that takes a number's real part and rounds it, so PolynomialFit(xs, ys, Complex(1, 2)) silently fitted a degree-1 polynomial and PolynomialFit(xs, ys, 2.5) a degree-3 one, while a NaN or ±∞ degree was blamed on the argument list as invalid arguments. Every number in the degree position is now answered by the degree must be an integer in [0, 12] error unless it is exactly an integer.

  • Histogram and BinCounts reject non-finite data and non-finite bin edges instead of silently dropping them. A data point with no finite real reading — NaN, a real ±∞, or the complex infinity ~oo under either spelling — used to be filtered out of the sample, so BinCounts([1, +∞, 5], 2) reported the counts of the two-point dataset [1, 5] with no hint that a value had been discarded. A non-finite explicit bin EDGE was worse: every interval comparison against it is false, so the head fabricated a row of zero counts (BinCounts([1, 2, 3], [0, NaN, 10]) returned [0, 0]). Both now return the same structured incompatible-type error the complex rejection uses, naming the finite_real constraint, the operator, and the offending value. These two heads cannot absorb a non-finite value the way Mean([1, NaN, 5]) returns NaN does: their result is a vector of COUNTS, and no count means "there was no reading". Finite real data and explicit finite edge lists are unaffected.

    A finite real too large for a machine float — 10^400, an exact integer — is refused too, but as an out-of-range error naming the machine floating-point range, because the limit belongs to the binning arithmetic and not to the value: the bin width and every interval comparison are computed in doubles, where such a datum reads as infinity. The statistics that sum their data exactly (Mean, Covariance, the least-squares fits) have no such limit and accept it.

  • LinearRegression and PolynomialFit propagate NaN for non-finite data instead of misdiagnosing it. A NaN, ±∞ or ~oo value in the X column made the least-squares pivot search fail, and the heads reported unexpected-argument: "degenerate data" — a claim about the geometry of the sample that the data does not support — or, when the elimination happened to pivot on a later row, returned the half-NaN tuple (NaN, 0) whose 0 slope is not a fit of anything. Both now answer with every coefficient NaN, in the shape each head declares: (NaN, NaN) for LinearRegression, a NaN-filled list of degree + 1 coefficients for PolynomialFit, and the fitted expression with NaN coefficients when a trailing variable symbol is given. This is what both already answered when the non-finite value sat in the Y column, and it matches Covariance/Correlation. The "degenerate data" error remains for rank-deficient finite real data — LinearRegression([2, 2, 2], [1, 2, 3]) still reports it.

  • Two collections of different lengths are a dimension error in the fits. LinearRegression([1, 2], [1, 2, 3]) and the corresponding PolynomialFit call fell through to the least-squares rank guard and were misdiagnosed as "degenerate data"; they now report incompatible-dimensions 2 vs 3, the error Covariance and every other pairwise head already used for a length disagreement.

  • LinearRegression reports a sample with fewer than two points as such. One point determines no line whatever its value is, but the head answered (NaN, NaN) for LinearRegression([NaN], [2]) and "degenerate data" for LinearRegression([1], [2]), while PolynomialFit([NaN], [2], 1) said there were not enough data points. LinearRegression now reports not enough data points for any sample shorter than two, ahead of anything the values could say.

  • Re, Im and Arg reject the argument lists their targets reject. These aliases rewrote to Real/Imaginary/Argument through a construction path that skips signature validation, so Arg(1, 2) silently dropped the second operand and answered 0 where Argument(1, 2) reported the unexpected argument. They also declared a narrower type than their targets, so Re(NaN) claimed the type real, which does not admit NaN, wherever the expression was left uncanonicalized. Each alias now validates and types exactly as the name it stands for.

New Features​

  • Re and Im are now defined, as aliases of Real and Imaginary. ["Re", z] and ["Im", z] used to stay inert as unknown operators; they now canonicalize to Real/Imaginary (the preferred names) and evaluate, exactness included: Re(1/3 + 2/5i) is 1/3. The \Re and \Im LaTeX commands already parsed to Real/Imaginary and are what the serializer emits, and \operatorname{Re}(z) now resolves too.

  • A type handler can now be declared as a function of operand DESCRIPTORS instead of operand expressions. An operator definition that sets typeHandlerKind: 'types' receives, in place of each operand, an OperandDescriptor: the operand's handler-visible type, a deliberately minimal set of three-valued facts carrying only what the type cannot (finiteness — for the NaN literal, whose type is number; sign from pure value sources such as a held numeric value; closedness; the collection capability facets; a static shape) and an on-demand structural view — never the operand expression itself, so deriving a type cannot declare, canonicalize, or evaluate anything. Validity has no fact: an error operand's type is 'error'. The legacy expressions shape remains the default and is unchanged; the flag — never the handler's parameter count — selects the shape. Under test (and with CE_TYPE_PURITY_GUARD elsewhere) a runtime guard turns any engine-state write from a 'types' handler into an immediate error. The built-in Coalesce, Hold and ReleaseHold definitions were the first to use the new shape, with byte-identical derived types; DigitCount, Block and When have since converted the same way, each proven equivalent by a differential shadow that runs both shapes side by side across the test suite. 34 further operators (the number-theory and combinatorics constants) went one better: their constant type handler was retired outright and its result moved into the declared signature — NthPrime now declares (integer) -> finite_integer instead of pairing a wide signature with a narrowing handler, with byte-identical derived types.

  • GammaRegularized and BetaRegularized no longer claim a finite real result unconditionally. Their old constant claim was unsound off the proven domain (GammaRegularized(-1, 2) evaluates to NaN). They now claim finite_real only when the domain is proven — a > 0 and z ≥ 0 for the gamma; x ∈ [0, 1], a > 0, b > 0 for the beta — and answer number otherwise, per the non-finite typing convention.

Resolved Issues​

  • Square roots of large exact perfect squares are exact at every precision. Sqrt(10^402) returned +oo at machine precision (the radicand was narrowed to the numeric format before rooting) and Sqrt(10^12/9) stayed unevaluated where 10^6/3 exists. Exact integer and rational radicands now reduce exactly — perfect squares fold, negative radicands give exact imaginary results — regardless of the precision setting. Sqrt(1000000/49) now evaluates to 1000/7.

  • Conjugate, Real and Imaginary preserve exactness. Conjugate(1/3 + 2/5i) returned machine floats (so z·Conjugate(z) answered 0.2711… where 61/225 is available); Real(1/3 + 2/5i) returned a 21-digit approximation. All three now read the exact components: Real(1/3 + 2/5i) is 1/3, Imaginary(√2 i) is √2, and z·Conjugate(z) is 61/225.

  • A fence-less Delimiter around a bracketed or braced collection serializes to valid, round-trippable LaTeX. Delimiter(List(x, y)) — the parse of ([x,y]), and the operand shape a call f([x,y]) carries — serialized as \lbrackx,y\rbrack: the [ fence maps to the \lbrack command, plain concatenation glued it onto the first operand (an unknown command, so the output did not re-parse), and the Delimiter's recorded parentheses were dropped. Fences are now joined with joinLatex, which separates a command from a following letter (this also fixes the same glue for \lbrace and custom bracket delimiters), and a fence-less Delimiter keeps its parentheses around an operand that carries its own fences: Delimiter(List(x, y)) emits (\bigl\lbrack x, y\bigr\rbrack), which re-parses to the same structure, and f([x,y]) round-trips to the call. The same rule covers the tuple family — Delimiter(Tuple(x, y)) used to emit f(x,y) under a call, which re-parsed as TWO scalar arguments where the source had one tuple argument; it now emits f((x,y)). A Sequence operand still fuses with the parentheses, since a sequence IS a bare argument list. (Tycho item 230; the Desmos importer's persisted rows were corrupted by the glued form.)

  • Negate distributes over a tuple whose components are collections, and a tuple divides by a scalar under the same rule that multiplies it. A "zipped" point list — a Tuple of coordinate lists, ([1,2], [3,4]) — negated component-wise only when every component TYPE was a scalar number, so -P stayed an inert Negate, and P − P or the interpolation (1−t)·P − t·P (with t substituted later) never reduced, while 2·P and P + P folded fine. The negate arm now uses the same structural tuple test as the Add/Multiply dispatch. The tuple / scalar arm had the sibling gap plus one more: it now admits the same tuples as the Divide type handler (transparent type aliases included), and each component quotient is evaluated the way the multiply and add arms already evaluate theirs — so P / s folds once s has a value, and P / 0 answers (~oo, ~oo) with the point shape kept. Dividing BY a tuple still errors (no-division-by-point), and a non-numeric tuple still rejects with the same error the multiply path gives. (Tycho item 229 — the interpreted fallback of the declined rows painted nothing because the difference of two evaluated tuples stayed symbolic.)

  • The JavaScript compile target no longer withdraws a value-correct kernel because a tuple holds an exact constant with a complex-hedged type. √(5−√5) — an exact value of cos/sin(π·rational), ordinary in Desmos documents — types the finite_complex hedge (the engine does not prove 5−√5 ≥ 0 at type time) though its value is the plain real 1.6625…. The broadcast lowering's per-element shape analysis read that element complex, the element verdicts of (√(5−√5), 0) disagreed, and every Add/Multiply over such tuples failed closed with a misleading "list-valued operand" diagnostic. The complexness oracle (isComplexValued) now answers fold-first inside a JavaScript compilation: a closed pure scalar whose memoized constant fold is a real number reports real, and the constant folder's complex-shape gate — reading the same oracle — then inlines that folded literal, so every analysis and the emitted code describe the same plain number (an indexed read of such a broadcast result computes correctly rather than reading .re off a plain number). The fold-first verdict stands down where the emission cannot fold: under constantFold: false, in a symbolDeps capture, on the shader targets, and outside a compilation — there the previous fail-closed decline is unchanged, as it is for genuinely complex or symbolic elements. (Tycho item 229; 80 of the witness document's 201 line members were declined this way, all value-correct.)

  • Interpreted broadcast evaluation no longer pays the literal-type cost per element. Since a number literal's public .type became its literal value (ce.box(21).type is 21), every intermediate result of an interpreted broadcast carried a structured type, and each per-element type query walked that structure instead of comparing interned tier strings — about 2,000 subtype queries per element on the witness workload, roughly doubling the cost of evaluating and draining a large computed collection (a 7,225-element Desmos color chain went from ~5.5 s to ~12.7 s and blew through the consumer's materialization budget). Ruled: a broadcast CELL is a storage-like position and may widen. A re-entrant widening window now makes a literal report its bare tier for the duration of the interpreter's own per-cell computation — opened around each element step of a broadcast map and in the shape classifier that provably discards the precision anyway. Everything user-visible keeps the literal type: the public .type of any expression, tuple components read at classification time, and the types named in per-element error messages (a diagnostic minted inside a cell re-reads the precise type outside the window). The widening recovers the drain cost to within ~5% of the pre-literal-types baseline and costs nothing on ordinary small expressions. (Tycho item 228.)

  • The shader targets lower Power over a vector base, so the min-distance-field family compiles on the GPU lane. min over squared distances to a literal point list — (x−P.x)² + (y−P.y)² after vectorization — stopped at Power: the sign-preserving integer-power helper _gpu_powi is declared over scalar floats, and pow(vecN, scalar) is invalid in both shader languages (no scalar promotion). The targets now emit a per-width helper family _gpu_powi2/3/4 (WGSL has no overloading, so the width is in the name; the sign is restored per component), select it from the operands' vector width, widen the scalar side of pow explicitly, and answer vecN(1.0) for a vector base raised to the zero power — which previously emitted a shape-wrong scalar 1.0. Found and fixed alongside, on WGSL only: the emitted-call argument counter treated the comma inside a type template (array<f32, 1>) as an argument separator, so every one-for-one operand-shape check stepped aside and an invalid emission passed as success: true. Component reduction for Min/Max over a vector already existed and is unchanged. (Tycho item 231, face (a); the symbolic-length face (b) still fails closed by design — a host-side per-values substitution makes the count static, which is the supported route.)

0.119.0 2026_08_23​

Breaking Changes​

  • A number literal's .type is now its literal type. ce.box(42).type is 42 — a value type, a subtype of finite_integer — and ce.box(0.5).type is 0.5. An exact rational carries its tier through a singleton range (ce.parse('\frac12').type is finite_rational<0.5..0.5>), and an exact value no machine number represents carries its sign on its tier (ce.parse('\sqrt2').type is (finite_real<0..>) & !0). NaN, ±∞ and complex literals keep their tier types, as do string and boolean literals. Subtype tests are unaffected — ce.box(42).type.matches('integer') is still true — but code that compares .type.toString() against a tier name must switch to .matches(). Stored types still widen to tiers: k := 42 infers k: integer, a generic call identity(5) types finite_integer (the solver never binds a type variable to a literal type), [[1, 2]] types matrix<finite_integer^(1x2)>, and {x -> 1} types record{x: finite_integer}. Type errors now name the offending literal: "expected integer, got 2.5" instead of "got finite_real".

  • Declared signatures admit by overlap; conformance is checked at evaluation. An operator with a declared signature now follows the same admission model as arithmetic: an argument is refused at boxing only when it is provably incompatible — a concrete value that fails membership (FactorInteger("abc") is still an error at boxing), or a symbolic argument whose type shares no inhabitant with the parameter (string at an integer slot). A symbolic argument whose type merely OVERLAPS the parameter is admitted provisionally: FactorInteger(n) with n: number now boxes, works when n turns out to be 12, and errors at evaluation when it does not. On a strict engine, a generic runtime conformance check at non-lazy dispatch enforces the declared parameter on each evaluated concrete operand, producing the same incompatible-type error value the static gate mints for a literal — so the literal and symbol routes now agree. Consequences to note: a wrong-kind concrete value that a handler previously absorbed silently is now an error (see Resolved Issues); a diagnostic for a call like k(x) with x merely typed incompatibly (no value yet) moves from box time to run time, and the Epsil static pre-pass no longer flags it (ROADMAP: "Epsil static evidence diagnostics lost to overlap admission"). Collection-kind and function-kind parameters, and operators with a custom canonical handler, keep their existing handler-owned admission unchanged. On a non-strict engine (strict: false) nothing changes. (R1/R8 — §4.4 of docs/plans/2026-08-22-type-handlers-on-types.md; pinned by runtime-conformance-fuzz.test.ts and runtime-conformance.test.ts.)

  • Sqrt no longer evaluates a closed radicand to decide its type. √(1 − 0.2²) — a machine-float radicand, which canonicalization deliberately does not fold — now types finite_complex, the same hedge as any other real radicand of statically unknown sign, instead of finite_real: the type handler used to numericize such radicands (closedRealSign, now deleted) for the sole benefit of the compile targets, and a type derivation must not evaluate. Values are unchanged (evaluate(), .N(), solve() fold the float as before) and compiled output is byte-identical: the compile targets fold constant subtrees themselves before any lowering decision reads a type (the item-137 GLSL band is pinned byte-for-byte in type-handler-audit.test.ts). A literal radicand still types precisely — √0.96 stays finite_real, its sign being statically known. Measured blast radius: 3 pinned type assertions, all in type-handler-audit.test.ts; zero snapshot changes in the full suite. (§5.4 Sqrt row and §5.8 A5 of docs/plans/2026-08-22-type-handlers-on-types.md.)

New Features​

  • Ranged result types: Abs, even powers and Exp carry their sign in the type. |x| types real<0..> (each real tier keeps its non-negative range), x² types finite_real<0..>, and e^x — every Power with a provably positive real base — types (finite_real<0..>) & !0, so a consumer that reads the TYPE sees the sign the sgn handlers always knew: √(x²), √|x| and ln(2^x) now type finite_real instead of hedging complex. Negate reflects a range instead of echoing it (−|x| is real<..0>). The scope stops at these heads: Add/Multiply deliberately still join bare tiers (see the Bug Fixes note below); general interval arithmetic is tracked separately in ROADMAP.md.

  • Number literals carry their value in their handler-visible type. A type handler now sees 21, 0.5 (value types), an exact rational as a singleton range (finite_rational<0.5..0.5>), and a value no machine number holds exactly as a sign-carrying range ((finite_real<0..>) & !0 for √2) — so the sign and value questions handlers used to answer from the value channel (Power's integer-vs-rational claim for 10^21, arcsin(0.5)'s domain classification, Range(2, 3)'s index-span test, an even root of a negative constant) are now answered by the type alone. The PUBLIC .type of a literal is unchanged (ce.box(21).type is still finite_integer), and a handler result is widened back to ordinary types before it is stored, so literal types never leak into an expression's type. The constants e and π now declare value-bracket ranged types (finite_real<2.718281828459045..2.718281828459046>), so their positivity is a type fact as well. (Ruling O9 first half, docs/plans/2026-08-22-type-handlers-on-types.md §4.3/§6.)

  • The interval-js target sizes — or declines — dynamically nested integrals at compile time. An inner integral runs once per enclosing piece, so Integrate nodes nested inside each other's integrands or bounds multiply the enclosure's work by their piece count per level — a class the per-node sizing (which only sees one node's limits) could not bound. The outermost integral of a nest now measures its whole subtree (integrateStats: how many integral runs, how deeply they nest — a pure function of the tree, memoized per node, no clock anywhere) and picks one uniform per-level count keeping runs·count^depth within the 65 536 evaluation budget; inner lowerings inherit that count through the compile target. Below 4 pieces per level the enclosure would be as uninformative as entire at full cost, so such an integral fails closed instead and the caller falls back (in a two-lane consumer, to its scalar estimate) — restoring, for the macro-expanded Tycho item-226 witness, exactly the pre-0.118.2 decline behavior at ~zero probe cost: the witness document's member sweep runs in ~25 s against 44 s on 0.118.1 and 90 s with the runtime budget alone. Visible chains that fit are now genuinely cheaper AND honest: a three-deep chain that formerly ran 256³ evaluations into the runtime budget's entire now answers a real (coarse) enclosure at ~28 pieces per level in milliseconds. Composition through by-reference function calls shows no Integrate node in the tree and is still bounded at run time by INTERVAL_NESTED_QUADRATURE_BUDGET. A Sum/Product with literal bounds multiplies its body's integral runs by its iteration count in the sizing; with symbolic bounds the body is counted once and that shape, too, is runtime-backstopped. Both mechanisms are pinned in compile-interval-integrate.test.ts. (Tycho item 226)

Resolved Issues​

  • Rational(n, d) is an integer-pair constructor again: a proven non-integer argument is rejected instead of silently dividing. The two-argument form canonicalizes to Divide, which erased the declared integer contract, so Rational(3, 2.5) — and Rational(3, x) with x := 2.5 — evaluated to 1.2. The signature is now the overload ((real) -> rational) | ((integer, integer) -> rational) and a non-integer literal, or a symbol holding one, errors with incompatible-type on every construction route. A valueless symbol is still admitted and rewrites to Divide as before, and the Divide spelling itself is unchanged (Divide(3, 2.5) is still 1.2).

  • Divides no longer answers a rounded question. Divides(2.5, 3) returned True — the handler converted operands with the rounding toBigint, so it actually tested 3 | 3 — and NotDivides(2.5, 3) returned False (the truth is True). Non-integer operands now stay symbolic, as the operator's documentation always stated. The same integrality gate protects FromContinuedFraction, which used to reconstruct a value from a rounded non-integer term, and NotDivides' declared signature now matches Divides' ((number, number)).

  • The unary Multiply/Subtract fold no longer lets a held non-numeric value through unexamined. Multiply(s) with s := "str" folded to the lone operand — the operator vanished before any check could run — and evaluated to "str", while the literal Multiply("str") refused. A lone operand holding a concrete string, boolean, or character now errors with incompatible-type; a lone collection still folds (the broadcast identity) and a valueless symbol still folds.

  • Intersection of a single collection is that collection as a set, not the empty set. Intersection([1, 2, 2]) evaluates to Set(1, 2) (symmetric with Union), and a set-shaped operand returns itself without enumeration — Intersection(Integers) is Integers. Nullary Intersection() is still EmptySet.

  • Vector declares the content leniency it always had. Its signature is now (any+) -> vector, matching the tensor family (Matrix accepts non-numeric entries on every route), and its type handler claims the numeric vector<n> only when every element is provably a number.

  • The Epsil linter is now deliberately stricter than engine admission for assignment evidence. let x; x = g(); k(x) with g: () -> number and k: (integer) -> integer flags a static-type-error again: the pre-pass refuses a call whose recorded evidence type does not FIT the parameter, even when it merely overlaps — the way TypeScript flags code that would run. Lint-only by construction (the evidence exists only while a pre-pass runs); the executed program is unchanged, overload verdicts stay per-arm, and an exact-rational initializer now flags too. One adjacent lint gap is recorded in ROADMAP.md: a LAMBDA-valued callee's argument errors are deferred to run time by design, so the pre-pass does not surface them yet.

  • Folding an assigned value that would explode the emitted source now fails closed. A DAG-shared value tower unfolds once per reference path in generated text, so compiling a member could grind through megabytes of source; tryFoldKnownSymbol now refuses above 20,000 expanded nodes (a DAG-linear identity-memoized size sum — the compile corpus's largest legitimate fold is 4 nodes) with a message that says why. The default fallback: true route degrades to interpreted evaluation with correct values; the direct registered-target route throws. The CSE initiative remains the general fix.

  • Nested quadrature is bounded through Monte-Carlo composition, and compiled multiple integrals cost what the interpreter's do. _SYS.integrateMC joins the nested-evaluation budget (a runaway by-reference composition under Monte-Carlo, previously unbounded, now answers NaN in seconds; a nested Monte-Carlo integral that previously never returned now answers NaN too), and the Integrate emitter seeds starting panels by the statically visible nesting depth using the interpreter's own sizing — a compiled triple integral dropped from 1.38·10⁷ integrand evaluations (~0.9 s) to 9.1·10⁴ (~12 ms) with accuracy equal or better on every probed closed form.

  • A runaway dynamically-composed scalar integral now answers NaN instead of hanging. The scalar javascript target's _SYS.integrate carries the same per-outermost-integration evaluation budget the interval target already had (2²⁵ nested integrand evaluations — ~70× headroom over the hardest measured double integral, 2.4× over a smooth genuine triple): by-reference composition no tree walk can see is cut after a few seconds, an exhausted run cannot fall through to the Monte-Carlo fallback, and a fresh outermost integral re-arms. Choosing NaN as the exhausted result follows the ROADMAP entry's own analysis (the scalar result is an estimate, not an enclosure).

  • structural of an object-holding shared tree is no longer exponential. The per-node memo cannot persist a payload containing a mutable object (cache rulings B12/B22), so such trees rebuilt once per path; a transient map scoped to the outermost read — retaining nothing, validating nothing — now preserves sharing within one read (depth 18 cost 8.5 s before; depth 30 is now depth + 1 rebuilds).

  • The Epsil pre-pass flags a concrete literal initializer that cannot inhabit a call's parameter again. Overlap admission had silenced let x = 1.5; k(x) for k: (integer) -> integer (the widened number overlaps integer); the pre-pass now records the initializer's exact literal type as its assignment evidence, which provably refutes the parameter, restoring the static-type-error — while let x = 2 stays clean and a symbolic x = g() stays admitted (that half is an open product decision recorded in ROADMAP.md).

  • Every irrational standard-library constant declares a value-bracket ranged type (e/ExponentialE, π, γ, Catalan's G, and φ), so sign and magnitude are type facts — √φ and ln π type finite_real off the declarations alone. GoldenRatio's value is an unevaluated expression whose static type cannot witness a bracket; such standard-library declarations are now TRUSTED (the throwing value-vs-type check is user-declare-only) and validated empirically under a development-build console.assert plus the suite pin constant-declared-brackets.test.ts. The remaining literal-value type handlers (ErfInv, PolyLog, Subscript's numeral base) read the literal's handler-visible type first.

  • A compilation that exhausts the shared antiderivative pool no longer starves later compilations of closed forms. The per-compilation pool bounding symbolic antiderivative-first attempts (added with the item-226 fix) was reset only in compileRoot and the public compile() entry — but registered targets invoked directly (ce._getCompilationTarget('javascript').compile(...)) enter through compileCseRoot, which reset nothing. Once any compilation drained the pool, every later direct-target compilation skipped the symbolic attempt permanently and emitted runtime quadrature (_SYS.integrate(…)) where a closed form exists (∫₀ᵗ 2x dx compiled to a quadrature call instead of t²). The reset now lives at the one choke point every route crosses — the depth-0 boundary of BaseCompiler.compile — which also gives each auto-mode escalation attempt its own pool. Nested compilations now consume the outer pool instead of re-granting themselves a fresh one, so the aggregate stays bounded by one pool per depth-0 compilation entry (a multi-statement shader body, which compiles each statement as its own root, gets one pool per statement). Pinned in compile-antiderivative-budget.test.ts. (Surfaced by the dual review of the constant-fold-before-type-read change.)

  • Histogram/BinCounts no longer round a non-integer scalar bin spec. The scalar bin spec is a bin COUNT, but it was read with a rounding conversion, so BinCounts(data, 2.5) silently answered the 3-bin question. A non-integer scalar now stays INERT — the documented contract, which lets Desmos-style bin-WIDTH spellings (histogram(L, .05)) parse and remain for the importer to translate. A string bin spec, reachable through a permissively-typed symbol, is now an incompatible-type error instead of being iterated character-by-character into NaN bin edges.

  • Integer-domain operators no longer silently round a non-integer operand delivered through a permissively-typed symbol. With u: any and u := 2.5, FactorInteger(u) answered [(3, 1)], NextPrime(u) 5, IsTriangular(u) True, and Fibonacci(1 + 2i) 1 — the ~45 operators of the number-theory and combinatorics families trusted the boxing gate to never hand them a non-integer, and toBigint rounds by contract. All of them now produce an incompatible-type error value at evaluation, from the generic runtime conformance check (no handler was changed).

  • An unknown rule-condition name no longer crashes. Condition(x, "nonsense") threw a raw TypeError out of evaluate() (an unguarded CONDITIONS[name] lookup); checkConditions now fails closed on an unknown condition name. More generally, a native-fault crash (TypeError/RangeError/ReferenceError) escaping a built-in non-lazy evaluate handler is converted into an evaluation-error value on the expression instead of crashing the caller; deliberate throws — cancellation, redefinition discipline, predicate contracts, user-function arity — still propagate unchanged.

  • A promoted radical arm in a branch is no longer double-wrapped on the JavaScript target. Under the complex discipline (the default mode: 'auto'), If(c, √−4, √(1 − 0.2²)) promotes the unknown-sign radical arm to _SYS.csqrt(…) — already a {re, im} object — while the branch coercion classified that arm by its static type and wrapped it a second time ({re: {re, im}, im: 0}), NaN-poisoning every slot read. The JavaScript target's static complex-wrap sites (BaseCompiler.isProvablyRealValued: branch-arm coercion, the Typed ascription lift, the complex-argument delivery) now consult the shape analysis (isComplexValued) before the static type, and — where the type is imprecise — the constant fold's value (constantFoldValue, split out of tryConstantFold behind every gate except the constantFold emission opt-out). A closed constant arm beside a complex arm therefore keeps the complex convention under every mode, including mode: 'strict' with constantFold: false, where only the fold-informed analysis can prove the structurally-emitted arm real. Pinned in compile-complex.test.ts ("coercion reads the value SHAPE" block).

  • The runtime effects projection honors its memo for pure applications, and an expression's structural form is memoized per node. Two independent defeats of per-node caching made every walk over a DAG-shared tree — a document function applied to its own previous result embeds that result once per parameter mention, so a few levels unfold to millions of paths — exponential, which surfaced as a consumer document evaluating at 4 GB and aborting on the heap limit (Tycho item 225). First, effectsOf's memo consultation was spelled expr._effectsOf?.() ?? applicationEffects(expr), and undefined is the memo's legitimate answer for a PURE application — so every read of every pure node fell through and recomputed its entire subtree: 24 million recomputes in one document evaluation, 1.5 million once the memo is honored. Second, BoxedFunction.structural rebuilt through every operand's structural with no per-node memo, so a shared tree was rebuilt once per path — exponential time and fresh allocation for every copy; it now caches per node (generation-guarded, like type), and the rebuilt form preserves the sharing. Both pinned on the depth-30 shared tower in dag-shared-walks.test.ts. The witness document no longer OOMs; its remaining cost is a separate compile-time issue (baking a DAG-shared symbol value into compiled source is exponential in the OUTPUT without CSE) tracked in ROADMAP.md. Found in review and fixed with it: once any mutable object exists, two more walks re-scanned a shared tree once per path — the cache commit points' payload-containment scan (containsObject), and the cross-engine ingress guard that runs on EVERY function boxing (containsForeignEngineObject — constructing a depth-n shared tree cost 2^n scans). Both now carry a per-question memo and are linear in distinct nodes; pinned by the armed-scan test in dag-shared-walks.test.ts. A shared tree that CONTAINS an object still rebuilds its structural form per read (the cache commit rule refuses object-holding payloads by ruling); tracked in ROADMAP.md. (Tycho item 225, partial)

  • A dynamically nested interval integral can no longer run away. The compiler sizes an integral's piece count only for the nesting it can see — the limits of one Integrate node (256 pieces for a single or double integral, fewer per level beyond that, so one node never exceeds its 65 536 evaluation budget). But integrals also nest DYNAMICALLY: a distinct Integrate node inside another's integrand, or a compiled user function that computes an integral, runs once per enclosing piece, multiplying the work by 256 per level with no syntactic trace. The Tycho item-226 witness — a six-level Newton iteration w − k·E(w)/d_E(w) whose macro-expansion substituted each level into E's integrand — emitted 728 _IA.integrate calls nested inside each other's integrands, ~256⁶ integrand evaluations, and never returned. The interval runtime now enforces its own budget (INTERVAL_NESTED_QUADRATURE_BUDGET, 4× the per-node compile-time budget): each outermost integral entry resets it, every integrand evaluation performed by a nested integral consumes it, and a nested integral that finds it exhausted answers entire — sound, since the entire line contains the true value — so the enclosure widens instead of spinning and the caller degrades to its non-interval fallback. Integrals the compiler did size are untouched (a double consumes 65 536 of the 262 144 budget, a triple about the same), pinned alongside the regression in compile-interval-integrate.test.ts. The consumer's document that hit this completes its member sweep in ~90 s where it previously never returned. With it, the antiderivative-first symbolic attempts of one compilation now share a single 4 s wall-clock pool instead of arming a fresh 2 s span per Integrate node, so a many-hundred-node emission degrades to its numeric emitters at bounded compile-time cost. The pool resets at every compilation entry — the public compile(), compileRoot (raw custom targets), and compileCseRoot (every registered target's root) — so no compilation inherits an earlier one's depleted pool; pinned by draining the pool and compiling on each route. (Tycho item 226)

  • An assume() range no longer leaks through Add and Negate. After assume(x > -1); assume(y > -1), x + y typed real<-1..> — but the sum can be as small as −2 — and -x echoed real<-1..> verbatim. Join-based result computations now strip range decorations from their inputs (a join is a set union, and a sum does not lie in the union of its terms' ranges), and Negate reflects a range about zero (-x under that assumption is real<..1>).

0.118.2 2026-08-22​

New Features​

  • The interval-js target now lowers Integrate, At, Length and PointX/PointY/PointZ. Each compiled on the javascript target and declined on interval-js with "the operator is known to the engine but target 'interval-javascript' has no lowering for it", so every plotted band over one of them fell from the interval lane to the scalar fallback. Four of the six had a common cause: the interval target had no collection value model at all — a List/Tuple literal had no lowering, and an array handed to a compiled function was flattened into an object keyed "0", "1", …, losing its length. A collection is now a JavaScript array of intervals in the OPERAND position of an accessor, and the accessor projects it back to one interval: Length answers the count as a point interval; At applies the interpreter's 1-based, negative-from-the-end convention, and since the index is itself an interval it stands for a set of indices — the result is the hull of every element the integers in it select, partial when some of them fall outside the collection, and the numeric absence marker ({ lo: NaN, hi: NaN }) when none selects anything; PointX/PointY/ PointZ read one coordinate of a single point. A literal collection — or a symbol assigned one — folds at compile time. The gates mirror the javascript target's: a string base, a gather or mask index, a list of points under a coordinate accessor, and a collection-valued RESULT all still decline, each with a message saying why (the target's value is one interval). Integrate is a different algorithm rather than a table entry: the antiderivative-first step is now shared with the javascript target (BaseCompiler.closedFormIntegral), so ∫₀ˣ sin t dt compiles to the closed form 1 − cos x on both — except that on the interval target a DEFINITE integral's closed form is guarded at run time (_IA.integrateClosed): the symbolic step differences an antiderivative at the bounds without checking that the integrand is bounded between them (∫₋₁¹ dt/t² closes to −2), and on this target that would be a zero-width "enclosure" of a divergent integral, so a coarse interval scan of the integrand over the range withholds the closed form — the scan cannot miss a pole, since interval arithmetic encloses — and hands such an integral to the enclosure, which answers singular. An integral that does not close compiles to _IA.integrate, an enclosure — a Riemann bracket over a uniform partition, with interval-valued bounds handled by the mean-value correction [0, b₂−b₁]·f([b₁, b₂]) on each side, and the accumulation's own round-to-nearest error added back by the standard dot-product bound — rather than the scalar target's Gauss–Kronrod estimate. Its width is first order in the piece size (≈ 2.5·10⁻³ on ∫₀¹ e^{−t²x} dt at 256 pieces); nested limits share one evaluation budget of 65 536 integrand calls (256 pieces per level for a single or double integral, 40 for a triple, 16 for a quadruple), so a deeper integral gets a coarser enclosure instead of a runaway cost. A pole inside the range answers singular, an undefined integrand empty, and an infinite bound entire (no finite partition exists; the scalar target's variable transform is not interval-tight). An indefinite integral with no closed form still fails closed, as on the javascript target. Found and fixed alongside, on BOTH targets: a Function integrand's parameters were paired to the limits by position, where the interpreter pairs them by name — Function(a·x·y², y, x) under (x, 0, 1), (y, 0, 2) compiled to ∫∫ a·y·x² (2a/3) instead of ∫∫ a·x·y² (4a/3), and a spare parameter (Function(x + q, x, q) under one limit) compiled as a read of an input the integral never supplies; both now fail closed unless the parameters match the integration variables one to one. And the interval target never populated varsKeys, so a symbol the caller pinned through vars was not protected from the closed-form fold (∫₀ᵏ t dt with k mapped baked k²/2 instead of reading k at run time). Two contract points were settled with it: the interval target's result is one interval per quantity, so a collection-valued expression (a comprehension) returns an ARRAY of interval values (IntervalValue) — which comprehension roots already did, now typed and honored by the interpreter fallback too, which used to collapse any list to entire; and every scalar kernel now fails closed on a provably collection-valued operand, where Add(L, 1) used to answer { lo: NaN, hi: NaN } and Less(xs, 3) 'maybe' behind success: true. The runner's variable type is IntervalInput — a number, an interval, or an array of these for a collection-valued variable. (Tycho item 220)

Resolved Issues​

  • Evaluating a user function over its own previous result no longer takes exponential time. A document function applied to its own result — Tycho's detail(smooth(upSample(…))) heightmap chain, evaluated while its sliders are still valueless — produces an inert value that EMBEDS the previous level once per mention of the parameter: four levels hold about 16 000 distinct nodes that unfold to over a million. Every walk that descended each operand independently paid for the unfolded tree — the Add ordering key (revlex, a string of every symbol in the term, so the key itself grew to gigabytes), the free-variable scan (unknowns, freeVariables, symbols), the binder rewrite behind parameter substitution, symbol dereference and Sum/Comprehension canonicalization (rewriteWithBinders), closure capture, the element memo's dependency snapshot, has() and the ordering tie-breaker's leaf count — and none of them reached a deadline check, so a consumer's per-evaluation time budget never fired: one document ran for more than twenty minutes at 100 % CPU and 3 GB (Tycho item 220's sweep stall). 0.118.1 finished the same document in about 12 s only because its Add broadcast over the operand; the item-221 fix keeps that Add inert, which is right, and exposed the walks. Each walk now memoizes per node within a call, so it is linear in the number of distinct nodes, and the ordering key is bounded to the trailing 1 024 characters of the symbol sequence. The bounded key is a function of the unbounded one, so every sum whose terms' keys fit orders exactly as before; a sum whose terms carry more than 1 024 characters of symbol names AND share that whole tail now breaks the tie by the structural order rather than by the longer key — deterministic, and only reachable from expressions of that size. The heightmap document evaluates in 3.7 s (78–199 s before); the engine-only replica at three levels takes 0.2 s (more than two minutes before). Pinned in dag-shared-walks.test.ts on a depth-30 shared tower (31 distinct nodes, 2³⁰ unfolded): the free-variable and symbol scans, the bounded ordering key, the degree walks on a shared polynomial spine, the leaf-count tie-breaker, has(), a Sum binder over the tower, the application of a literal holding it, and a lazy collection's dependency snapshot over it. Two walks on the way to ASSIGNING such a literal remain exponential and are tracked in ROADMAP.md ("Assigning a function literal whose body shares operands").

  • A compound factor of a vector Multiply keeps its parentheses on the glsl and wgsl targets. Multiply(Add(t, 1), Tuple(x, 0)) compiled to t + 1.0 * vec2(x, 0.0), which the shader reads as t + (1.0 * vec2(x, 0.0)) — the float broadcast into the vector rather than scaling it, so a Desmos-style lerp t·P₁ + (1−t)·P₀ rendered as t·P₁ − t + P₀. A Multiply with a collection operand is lowered by the shader target's function handler, and the compile callback such a handler receives carries no precedence context, so the factor arrived unparenthesized and was joined with a bare *. Each factor is now compiled at the binding power of *. Two more instances of the same dropped grouping are fixed with it: a function handler's emission spliced into an enclosing infix operator now obeys the same op[1] < prec rule the infix path applies to its own operands (s·(P + Q) over two points emitted s * vec2(a, b) + vec2(c, d)), and a single-statement Block used as a sub-expression is compiled at the enclosing precedence on every target (Multiply(Block(Add(t, 1)), x) emitted x * t + 1 on javascript, python, glsl and wgsl). The scalar multiply, Divide, and the javascript/python lowerings of the vector shape were already correct and are unchanged. (Tycho item 224)

  • A bare Nothing fails closed on every compile target. Nothing is the erasure marker, not a value; it can still reach a compiler as a bare symbol (a malformed Which with a dangling clause canonicalizes to it). Only the javascript target refused it — glsl and wgsl emitted the undefined identifier Nothing behind success: true, python the undefined name, and interval-js a _.Nothing read that is undefined at run time. All five targets now decline with the same diagnostic ("the erasure marker is not a value … Fail closed (D6)"). A Nothing arithmetic operand is still erased at canonicalization (Add(Nothing, x) compiles to x), as before.

  • An element-wise broadcast no longer captures a collection-typed but still valueless operand as a per-element scalar. With A an unknown-length view (Range(0, n)/n, n unassigned) and s declared list<number> but not yet assigned, Multiply(A, s) evaluated to Map(_ ↦ _·s, A); once s := [10,20,30] and n := 2 bound, that stored form materialized as the outer product [[0,0,0],[5,10,15],[10,20,30]] while a fresh Multiply(A, s) zipped to [0,10,30]. The known-length form [1,2,3]·s → [s, 2s, 3s] was the same splice. A collection-typed operand that is not yet a collection value now leaves the operator inert — s·[1,2,3], typed list<number>, the zip's element type — and re-evaluating it after the assignment zips. The decline reaches every route: Add/Multiply/ Divide/Mod, user lambdas and declared broadcastable<…> parameters (the application is held rather than inlined — g(a,b) = (a,b) no longer stores ([1,2], s)), the relational operators ([1,2,3] < s), When's mask, PointList components, tuple scaling ((1,2)·s no longer stores (s, 2s)), and operands typed number | list<number> or broadcastable<number>. A definite length mismatch among the resolved operands still errors ([1,2] + [3,4,5] + s → incompatible-dimensions 2 vs 3), and a symbol declared bare tuple keeps scaling a list ([1,2,3]·r → [r, 2r, 3r]). (Tycho item 221)

  • PointList over unknown-length lazy views is a point VIEW, with a type that carries the point arity. PointList(−√(1−A²), A) with A = Range(0,n)/n and n unassigned evaluated to an inert PointList head — isCollection false, no count, typed list<tuple> with no component arity — while the sibling A·(1,0) already produced a lazy point view. An unknown-length indexed component (a symbolic-length Range, a lazy Map over one, a Filter) now transposes lazily, exactly as the arithmetic route does: the result is a collection, resolves to the eager list of points as soon as the length does, and the stored lazy form re-evaluates to the same points a fresh evaluation gives. A provably infinite component or a non-indexed one (a Set) still fails closed; a string component is an atomic coordinate rather than a fail-closed trigger, so PointList("ab", 3) now evaluates to the point ("ab", 3) its type already promised. The static type is list<tuple<T₁, …, Tₖ>> (each list component contributes its element type, any other component its own type) instead of the arity-less list<tuple>. Consumer-visible consequence: because a two-component point list now statically has no third coordinate, PointZ(PointList(-6, n)) is a typed incompatible-dimensions error at box time on every route, where the javascript target used to emit a kernel of NaN absence markers and the GLSL target its own arity decline. (Tycho item 222)

  • A zipped Divide(list<tuple>, list<number>) folds each element. [T, U] / √(PointX([T,U])² + PointY([T,U])²) evaluated to [0.999999999999999835·(0.943…, −0.331…), (0.6, 0.8)]: the value-level div() had no tuple arm, and the broadcast zip builds each element with ce._fn('Divide', …), which bypasses the canonicalDivide fold the single-tuple form T / √(…) takes. An inexact divisor left an inert number × Tuple; an exact integer divisor ([T, U] / [2, 5]) left a tuple of unevaluated Divide components. A numeric tuple divided by a numeric scalar now scales component-wise on the value route too, and tuple.div(0) on that route answers (~oo, ~oo), as the box route always has. (Tycho item 223)

  • Arithmetic admits a symbol declared through a transparent type alias of a tuple, list or vector. ce.declareType('pt', 'tuple<number, number>', { alias: true }) then ce.declare('p', 'pt'): 2·p, [1,2,3]·p, Divide(p, 2) and Negate(p) all errored incompatible-type "number" vs "pt", as did an alias of list<number> or vector<2>, while the direct spelling was accepted — the shape gates read the type's kind directly and saw an opaque reference, where isSubtype already unfolded it (so an alias of a scalar worked). Transparent aliases are now unfolded at every shape gate and in the quotient's type. A NOMINAL declaration (declareType's default) stays opaque by design and is still refused.

  • A lone scalar | list<…>-typed operand keeps its union in the result type, and a big operator over a body that may be a collection stays inert. With u declared number | list<number> and not yet assigned, 2u, u + 2, −u, sin(u) and a user lambda f(u) all typed a definite list<…> — type.matches('collection') answered true — yet u := 5 evaluated 2u to the scalar 10. The result now carries the union through, finite_number | list<finite_number>, each branch wrapped back in its own collection kind (number | range → finite_number | indexed_collection<finite_number>), and a definite collection sibling ([1,2] + u) still gives list<number> — the union's list branch zips and its scalar branch lifts. Two folds that committed such a body to a scalar are closed by the same rule: Sum(2u) evaluated to 2u (and Sum(u) to u) where u := [1,2] makes the sum 6; and Sum(2 + h(x)) with h not yet defined evaluated to h(x) + 2, which re-evaluated to [5, 8] once h := x ↦ [x, 2x], where the sum is 13. Sum/Product with no index now stay inert over a body typed as a scalar-or-collection union (any branch a list, vector, set, tuple, string or broadcastable<…>), a broadcastable<…> body, or the application of an undeclared head; a bare undeclared symbol still folds (Sum(y) → y).

  • A definite integral with a pole strictly inside its bounds no longer evaluates to a finite number. ∫₋₁¹ dt/t evaluated to 0 and ∫₋₁¹ dt/t² to −2: the antiderivative was differenced at the bounds with no check that the integrand is bounded between them, which the fundamental theorem of calculus requires — both integrals diverge. The Integrate evaluate handler now answers what the integral actually is: +∞ or −∞ when the integrand keeps one sign across every interior pole (∫₋₁¹ dt/t² → +∞, as ∫₀¹ dt/t → +∞ already did), and inert (unevaluated, as it already was when no antiderivative was found) when the integrand changes sign across a pole and the integral has no value at all (∫₋₁¹ dt/t, whose principal value is 0 but whose integral is undefined). .N() follows suit — ±∞, or NaN for the sign-changing case, on either bound order and in the iterated form for a dimension with constant bounds — where it used to hand back a confident quadrature Measurement (∫₀² sec t dt → 8.316585 ± 0.000016, ∫₋₁¹ dt/t → −1.4 ± 3.6): the adaptive Gauss–Kronrod error estimate is blind to a singularity it straddles. A pole is reported only when it is PROVEN: located exactly — as a real root of a polynomial denominator (1/t, 1/(t² − 1), t⁻³), a pole of tan/cot/sec/csc or csch/coth of a linear argument, or a zero of a sin/cos/tan/cot/ sinh/tanh divisor (a reciprocal such as 1/tan t is not canonicalized to cot t, so it is read as written) — and then confirmed by sampling the integrand on both sides of it to grow at least as fast as 1/|t − r|. A root is told apart from a bound by the denominator's residual there, not by a distance, so ∫₀¹ (t − 10⁻¹⁰)⁻² dt is recognized as divergent while a double root exactly at a bound stays an endpoint case. That confirmation is what keeps a cancelling denominator (∫ (t² − 1)/(t − 1)), an integrable singularity (∫₀¹ dt/√t = 2), a pole AT a bound (∫₀¹ dt/t = +∞), and every integral with a symbolic or infinite bound exactly as they were. The compile targets' antiderivative- first step inherits the fix: ∫₋₁¹ dt/t² no longer compiles to the constant −2.

  • The sign of a circular function of an exact argument was off by one quadrant. sgn of cos 1, sec 1, tan 1 (first quadrant) and sin 2 (second quadrant) answered negative: the quadrant helper numbers the quadrants 1..4 and the sign table was indexed 0..3 with that number. Every consumer of the sign was affected — |sec 1| evaluated to -sec(1), so ∫₀¹ tan t dt (= ln|sec 1|) came out as ln(−sec 1), a complex number for a real integral. All six functions now report the sign of their value in every quadrant (verified against the numeric value at 90 sample points).

  • ∫ cot(ax + b) dx had the wrong sign. The pattern rule answered −ln|sin(ax + b)|/a; the antiderivative of cot is +ln|sin x| (its derivative is cos x / sin x). ∫₀² cot t dt, divergent at the lower bound, evaluated to −∞ instead of +∞. The power-reduction route (∫cot³x dx) already used the correct base case and is unchanged.

  • Eight more rules of the built-in antiderivative table were wrong (the table the Integrate evaluate handler uses when no integration provider is loaded; the Rubi provider was unaffected). ∫ sinh and ∫ cosh answered ln|cosh| and ln|sinh| (the antiderivatives of tanh and coth); ∫ tanh answered ln|sech|, the negative of the correct ln(cosh); ∫ sech answered ln|tanh| where it is arctan(sinh); ∫ csch kept the full argument in −ln|coth| where it must be halved; and all six inverse hyperbolic rules (arsinh, arcosh, artanh, arcoth, arsech, arcsch) answered the function's own logarithmic definition — ∫ arsinh t dt evaluated to arsinh t. Each is now the by-parts form (∫ arsinh t dt = t·arsinh t − √(t² + 1), …), and every rule of the table is pinned by differentiating its answer back to the integrand at several points of its domain.

0.118.1 2026-08-22​

Resolved Issues​

  • The javascript and python targets now compile the multi-collection (zipWith) form of Map over symbolic sources. Map((_1,_2) ↦ _1+_2, 1..N, 2..N) with N a free input declined with "Map: multi-collection form is not compiled" — which a plotting document met through Desmos' element-wise list difference c = (1..N) − Join([0], 1..(N−1)), whose bound value is exactly that zip Map, so every compiled expression reading c declined with it. The same shape with literal bounds const-folded away before reaching the lowering, which is why it looked as if every minimal zip compiled. The form now lowers like the single-collection one: each source is materialized once, the callback receives one element from each source per position, and the result is as long as the SHORTEST source — the interpreter's count for the form, and what Zip does. A parameter annotation on the callback is checked against ITS source, position by position, with the same fail-closed rule the unary form has (an integer annotation over a number-typed source declines, naming the parameter). Three shapes stay with the interpreter, by a compile-time decline rather than a wrong value: a source whose elements are not provably real numbers (list<complex>, list<list<number>>, strings, a bare list) — the callback's parameters are compiled untyped and its body treats them as real numbers; a source with observable effects (one that draws Random) — the compiled code materializes every source in full where the interpreter stops at the shortest; and a bare Add/Subtract/Multiply/Divide symbol as the mapping over anything but exactly two sources — it compiles to a two-argument function. The effects decline now guards Zip on both targets too, which had the same gap. The constant fold's cost estimate prices a zip by its shortest resolvable source instead of its first. (Tycho item 218)

  • Reading the type of a nested lazy collection view is no longer exponential in its nesting depth. A view built over a collection whose length is not statically known — L := Range(0,n)/n with n a free input, then Y_0 := Y + 0·(2L−1) — nests one Map per arithmetic step, and asking such an expression for its type cost 2^depth type-handler invocations. Sqrt(1 − Y_0²) took 3.6 s, and PointList(−√(1 − Y_0²), Y_0) did not return at all. A bound list was affected too, superlinearly in its length: parsing an rgb(…) row over a 301-element list took 12.9 s. All are now single-digit milliseconds.

    Two causes compounded, both in Map's type handler. It read each source's type twice per level, which doubles per level when the source is itself a Map. And the element-type derivation added in 0.118.0 declares stand-in symbols in a scratch scope, which advanced the engine's any cache generation — the generation BoxedFunction.type keys its memo on — so every read retired the type cache of every expression in the engine, including the sources the same walk was mid-way through reading. One PointList evaluation made 998K handler calls and 2.0M cache invalidations.

    A declaration whose target scope is one the computation itself pushed and pops now advances no cache generation, since the binding cannot outlive that computation and nothing outside can depend on it. The test is on the declaration's resolved target scope, not on whether a derivation is running, so a declaration aimed at a longer-lived scope — a function literal's block scope, a protocol member's scope — still invalidates as before. (Tycho item 219)

  • A compiled Sum/Product over a COLLECTION-valued body no longer skips iterations of an effectful body. 0.118.0 made the NaN early exit depend on effects rather than on who supplied the code, but only for scalar bodies; the element-wise fold kept an unconditional exit, so Sum(Random()·[1,1], n=1..31) stopped drawing at the first NaN while its scalar twin Sum(Random()+n, n=1..31) ran every term. The exit did two jobs at once, and only one of them may skip work: projecting a length mismatch to a scalar NaN is what makes the result independent of which term came last, and is now unconditional, while STOPPING the loop is an optimization and is now taken only when the skipped terms have no observable effect. An effectful body runs every iteration and answers the same value.

0.118.0 2026-08-21​

Breaking Changes​

  • .N() now leaves a product of sums FACTORED, like evaluate(). Since 0.117.0 2(x+1) evaluated to 2(x + 1) but .N() of the same expression still came back 2x + 2: the numeric route of the Multiply handler kept distributing. .N() is evaluate() with floats, so the two now agree on shape and differ only in the numbers — √2(x+1).N() is 1.414… * (x + 1), ((a+b)/c)·d keeps its quotient shape (d(a + b))/c on both routes, and a closed constant such as 2.5(√2+1) still folds to a single float. The same rule now reaches the tuple and tensor arms of the handler on BOTH routes: (1,2)·(x+1) is (x + 1, 2(x + 1)) and [1,2]·(x+1) is [x + 1, 2(x + 1)] where the components used to be distributed even under evaluate(). A component product also now honors the closed-inexact-constant rule a scalar product has: 0.5 · [π, 1], 0.5 · (π, 1) and the element-wise [0.5, 1] · [π, 1] all evaluate their π cell to 1.57…, as 0.5 · π does, instead of leaving 0.5π. Expand reproduces the previous output; simplify() and the internal normalization paths still expand. The values are unchanged.

  • A compiled Sum/Product now decides its NaN early exit on EFFECTS rather than on who supplied the code. The exit — if (acc !== acc) return NaN; between terms, valid because NaN absorbs + and * — used to be suppressed whenever the body contained caller-supplied source, on the reasoning that such code might count its own calls or mutate shared state. That test asked the wrong question, and got two answers wrong in opposite directions:

    • An impure operator of the engine's own no longer keeps the exit. A sum containing Random() was skippable, because Random is not caller-supplied. Skipping terms draws from the generator fewer times, and a later draw observes that, so Sum(Random() + n, n=1..31) now emits no exit. This is the user-visible half of the change: such a sum evaluates every term again, as it did before the exit existed.
    • A caller compile handler no longer costs the exit by existing. Whether the handler supplies source or declines for the target at hand, the suppression fired on the handler being present. The operator definition's pure / effects now governs: a definition declaring no effects keeps the exit, pure: false refuses it. A consumer measured ~33x on a 31-term sum from a handler that had explicitly declined.

    The rule holds for a piece written directly in the body. It does NOT yet reach one used through a user-defined function: wrap(t) := s(t) + 1 summed over still refuses, because the user-function admission gate reads the body's own purity and a signature-only declaration — which is what a name implemented through functions has — reports impure. That refusal is conservative, never unsound.

    The rule is now one sentence — an emission may be skipped when nothing in it has observable effects — with one oracle per spelling: a functions entry through its declared or inferred purity, an operator with a caller compile handler through its definition, everything else through the effects model. A spelling with no oracle is refused, which is where an operators entry ([op, prec], no body and no declaration slot) and a string-valued vars symbol land.

    Note the two oracles have deliberately opposite defaults. A functions entry must EARN purity — it is inferred from the source, and anything the analysis cannot model is refused — while an operator definition is GRANTED it, since pure defaults to true. A declaration is an assertion its author made; a bare source string is not. An operator whose handler emits effectful code while its definition declares none is the one shape this cannot catch, and it is the same mis-declaration that already misleads common-subexpression elimination.

  • A function type must now cover every call its target type permits. Signature subtyping asked only whether a function had enough parameters; it never asked whether it could handle every call the declared type allows. So a name declared (integer, string+) -> string accepted a (integer, string) -> string function, and the mismatch surfaced later, at a call the declaration explicitly permits:

    declare join: (integer, string+) -> string
    join := narrow // narrow: (integer, string) -> string — was ACCEPTED
    join(1, "a", "b") // permitted by the declaration → unexpected-argument

    The error is now raised where the mistake is, at the assignment. A declaration is a contract in both directions — it tells callers which calls are legal, and it constrains what may be stored under that name — and assigning checks against that contract rather than rewriting it. Concretely, a function is a subtype only when it accepts the target's shortest AND longest permitted call, so no fixed-arity function satisfies a * or + tail (which has no longest call): only a variadic function can.

    This affects storing and substituting a function, not calling one. + and * still mean one-or-more and zero-or-more at every call site, and passing a fixed-arity function to a variadic callback slot is unchanged — that admits when the two arities overlap, which is a different question. The engine already enforced this rule for function LITERALS ("takes 1 parameter(s), but the declared signature accepts 0 or more"); only the named-function path slipped through.

  • A compiled runner's declared return type now covers everything it can return. run() was typed number | ComplexResult, but a compiled predicate returns a boolean (Greater(x, 0) runs to true, never 1), a string-valued expression returns a string, and a collection-valued one returns a (possibly nested) array. TypeScript therefore accepted result.run({ x }) * 2 on an expression that could never be a number. The default is now the new exported CompiledValue union — which also covers a function-valued expression, since Derivative(Sin) runs to the callable (x) => Math.cos(x) — so such a call is a build-time error. The interval-js target keeps its own result type (an IntervalResult, or a bare Interval for a constant) rather than being folded into that union.

    Migration: a caller doing arithmetic on the result declares the narrow type it expects — compile<'javascript', number>(expr) — and gets a number back. This is the type-level replacement for the realOnly: true option removed in the previous release, which was the only remaining way to obtain a narrow numeric result; unlike realOnly it is purely a type assertion and projects nothing at run time.

    The same call's VARIABLES widened too, and that direction only ever accepts more: run() now takes number | ComplexResult on a JavaScript target, so a complex-mode call such as run({ z: { re: 2, im: 3 } }) type-checks instead of needing a cast. It was previously typed number alone, which refused the one shape complex mode exists to accept — while the _getCompilationTarget('javascript') route had always typed it the wider way. interval-js widened by the same step, from number alone to number | Interval; there too the _getCompilationTarget('interval-js') route had always used the wider type. Neither widening requires any change from a caller — both only ever accept more than before. A complex value handed to a REAL-mode runner is still rejected at run time, with an error naming the variable, unless the call passes entryChecks: false, which disables that guard along with the others and lets the value reach real arithmetic, yielding NaN.

  • ~oo (ComplexInfinity) now types number, not complex. The non-finite typing convention admits an undirected infinity at the top type only — which is how every derived pole already typed (Gamma(-2), Zeta(1), (-1)!, sqrt(-oo)) — but the constant itself was the exception, so two expressions with the same ~oo value could type differently depending on whether the constant survived canonicalization (Divide(~oo, 5) answered complex, Add(1, ~oo) answered number). expr.type.matches('complex') is now false for ~oo, exactly as it is for NaN. A value carrying an infinite IMAGINARY part is ~oo for this purpose — the set the engine renders as ~oo — while an infinite real part with a finite imaginary part (∞ + i) is unchanged.

  • A pole compiles to NaN on a real-valued lane. (-1)!, 1 + (-1)! and 1 + \tilde\infty compiled to the object {re: Infinity, im: Infinity}; they now run to NaN. A pole has no real value, and NaN is how the real lane spells that — the same projection _SYS.factorial already applied at a negative integer. Previously the constant fold emitted the value's own complex shape while the surrounding code was lowered from the node's number type, so a parent added an object to a number (1 + {…} → "1[object Object]") and the folded and structural paths disagreed with each other. A pole and a pole under a parent now answer NaN on both paths, and on the shader targets too, where the vec2 this produced was also a shape mismatch wherever a float was expected. A real-valued FUNCTION of a pole still differs between the two: Re(~oo) folds to Infinity (the fold evaluates symbolically, and the interpreter answers +oo) but lowers structurally to NaN, because on the real lane the pole is already NaN by the time the function sees it.

Resolved Issues​

  • A call of a user function whose definition a compile target cannot emit is compiled inlined. On the glsl and interval-js targets f((x,y)) with f(P) := a·P.x² + b·P.y² declined — a shader function needs a static type for every parameter and a point-typed one has none (parameter "P" has no static GLSL type); the interval target has no PointX/PointY lowering over an opaque parameter — while the same body written out compiled. The call now compiles with the body substituted at the call site (the coordinate accessors of the literal point folded), so f((x,y)), d((x,y)) with d(P) := √(P.x²+P.y²), a two-point Q((x,y),(1,2)) and a chained e(P) := d(P) + 1 all compile on both targets; the javascript target keeps compiling such calls by reference. The body is substituted, never evaluated — an impure body, a generic or recursive callee, a symbolic point or a list argument keep the definition's own decline. (Tycho item 216.)

  • Applications of a pure user function to number-literal arguments are memoized within an evaluation. A recursive definition that applies itself twice per level, R(i,x,y) = R(i-1,x,y) + 0.5·S(x,y,R(i-1,x,y)), cost the interpreter 2^i body evaluations at depth i — minutes at depth 20 where the compiled artifact, whose CSE pass binds the repeated self-call, took microseconds. The same application — same literal, same number-literal arguments, same exact/numeric route — is now answered from a memo, so depth 20 evaluates in milliseconds. An entry is valid only while the engine's state version — the axis every assignment, declaration, assumption, configuration change, non-clean scope pop and checkpoint restore advances — and a mutable-object store epoch are both unchanged, so a pure body that reads an assigned free symbol or an object field is never answered from a stale result; an impure body or a symbolic argument is never memoized. (Tycho item 217.)

  • The javascript compile target lowers a point multiplied by a list. [1,2,3]·(cos a, sin a) and (3((-N)..N)+cos t)·(cos a, sin a) declined with "no list-arithmetic support" while x+[1,2,3] and 2(cos a, sin a) compiled. The interpreter broadcasts over the LIST and scales the point whole at each element — a list of points — which a flat element-wise broadcast over the two arrays would have zipped instead; the product now emits a nested broadcast (list outside, point components inside) and agrees with evaluate() on every shape probed, a list-typed or tuple-typed symbol included. Shapes the interpreter does not broadcast keep failing closed (tuple·tuple, a matrix, two lists of provably different lengths). Add and Divide of a point against a list, which the interpreter answers with a per-element incompatible-type error or leaves inert, used to compile to a plausible zip ((1,2)+[3,4] → [4, 6]); they now fail closed. (Tycho item 214.)

  • [1,0] = P with P declared tuple<number, number> compiles. It declined as "a tuple participant" while [1,0]=[x,y] and (1,0)=P compiled. A list and a point are never equal in the interpreter (a point binds atomically), so the pair is a constant whatever the coordinates — the literal [1,0]=(1,0) already folded to False before compilation. The list-vs-point-symbol pair now compiles to that constant (true for NotEqual) instead of declining. (Tycho item 215.)

  • A zip of two unknown-length point views keeps its element tuple-ness in the TYPE. With n unassigned, A = (Range(0,n)/n)·(1,0) is a lazy view of points, but A - B evaluated to Map((_1, _2) ↦ _1 + _2, A, B) typed indexed_collection<number> — the mapping's parameters are bare, so its body was typed with both unknown — and PointY over the view folded to a scalar absence marker on the strength of the type alone, while the pulled values were correct tuples. Map now derives a bare-parameter mapping's element type from its sources' element types (no annotation is written onto the literal, so no runtime check is added and the lambda's shape is unchanged), and the same view now reads indexed_collection<tuple<finite_number, finite_number>>. Two component tier lies on the way were fixed with it: a scalar-scaled point now widens its components by the declared scalar's tier (x·(1,0) with x: number is tuple<number, number>, and (1/n)·(…, 1) no longer claims an integer second coordinate), and a collection-times-point product scales the components by the collection's element type. PointY over a point view whose length is not yet decidable answers the lazy projection instead of an absence marker. (Tycho item 212.)

  • PointX/PointY/PointZ over a list of SYMBOLIC points stay symbolic. With P declared tuple<number, number> and unassigned, PointY([P]) evaluated to [NaN] and PointY([(1,2), P]) to [2, NaN] while PointY(P) stayed symbolic: the broadcast arm read a symbolic element's missing components as an absent coordinate. A symbolic element now keeps the accessor applied to it — [PointY(P)], [2, PointY(P)], [PointY(P), PointY(2P)] — and substitutes to the numbers once the point is assigned; a coordinate a point provably lacks still takes the absence marker. (Tycho item 213.)

  • .N() of a point view is a view of points, not a tuple of coordinate views. N((Range(0,n)/n)·(1,0)) came back as (Map(…), Map(…)) — and, with n assigned, as ([0, 1/3, …], [0, 0, …]) — because the numeric routes of Multiply and Add ran their tuple branch on the raw operand Range(0,n)/n, which is collection-typed but not yet a collection, before the .N() step could reveal the view. Such a co-factor now defers to the post-evaluation re-dispatch, where the collection wins exactly as on the exact route. The literal-list shape N([0, 1/3]·(1,0)) used to crash (mulN had no single-operand short-circuit, so mulTensors' tuple "scalar" recursed into a zero-operand product); it is [(0, 0), (0.333…, 0)].

  • A primed name with a subscript that does not fold into the symbol is a primed variable, like the bare name. \alpha_1' parses as the primed variable Prime(alpha_1), but \alpha_{i+1}', A_{i,j}' and x_{n+1}'' parsed as Derivative(Subscript(…)) — the prime parselet read every compound base as an expression to differentiate — and canonicalization then lifted the subscript into a lambda over its own base ((alpha) ↦ alpha_{i+1}). Such a base now asks the same variable-or-function question as the bare symbol, decided by its base: Prime(Subscript(alpha, i+1)) for an unknown base, Derivative(Subscript(f, n+1)) for f declared a function. The prime-first spelling \alpha'_{i+1} agrees, the applied \alpha_{i+1}'(t) is unchanged, and a parenthesized expression (x^2)' is still differentiated.

  • A symbolic derivative order is accepted and stays symbolic. f^{(n)} is documented as Derivative(f, n), but with n unassigned it canonicalized to an incompatible-type error (the order was checked against number without the inference a free symbol gets elsewhere), and had it got through, evaluation read the non-numeric order as 1 and returned the FIRST derivative. The order is now inferred number and the expression stays inert until it is assigned (n := 2 then gives f''). Also fixed on the way: Derivative(g, 0) with g a function symbol evaluated to (x) ↦ g, the constant function returning the symbol, instead of g (likewise the all-zero multi-index Derivative(g, 0, 0)), and a multi-index derivative of a symbol bound to a lambda (Derivative(g, 1, 0) with g(x, y) := x^2 y) stayed inert because that arm only recognized an inline function literal.

  • A compiled Sum/Product keeps its NaN exit when a caller-supplied function is applied beneath another operator or inside a user-defined callee. A function declared by signature only and implemented through the functions compile option projects unknown effects onto every application of it, so the skippability gate — which lets a Sum stop evaluating terms once its accumulator is NaN when nothing in the body has observable effects — kept all 30 exits for \sum sq(n x) but none for \sum (sq(n x) + 1), \sum \sin(sq(n x)), \sum 2 sq(n x), or \sum wrap(n x) with wrap(t) := sq(t) + 1: the functions entry's purity oracle was consulted only when the vouched head was the whole body. The gate now re-reads the effect projection per node with each oracle's answer standing in for the head it vouches for, so a pure entry keeps the exit wherever the head sits, and an impure entry, an impure built-in beside it, or a second head with no oracle still refuse.

  • A product of two infinities could come out with the wrong sign at machine precision. With ce.precision = 'machine', x · (-2) · 3.1 · (-∞) · (-∞) · (y + 1) evaluated to +oo · x · (y + 1) instead of -oo · x · (y + 1). The machine-precision numeric value answered "no sign" for ANY infinity, and the product accumulator read that as positive, so once the running coefficient was -∞ the next infinity flipped it. The big-number path already returned ±1 for ±∞; the two now agree. The pairwise fold that products without a sum go through masked the bug, which is why it surfaced only alongside a factored sum.

  • Multiply gave ~oo a sign. 2·~oo evaluated to +oo and -2·~oo to -oo, which contradicted Negate(~oo) — that correctly stays ~oo, so -2·~oo and -(2·~oo) disagreed. An undirected infinity takes no sign from its factors: all three are now ~oo. One neighbouring case went with it: i·~oo answered NaN, because the general complex product computed ∞·0 − ∞·1. The indeterminate form 0·~oo is still NaN, and the signed infinities are untouched (-2·∞ = -oo).

  • A real infinity turned in a non-real direction is ~oo, not NaN. ∞·i evaluated to NaN, as did ∞·(2+3i) and every other product of a real ±∞ with a factor that has a non-zero imaginary part: the general complex product computes ∞·0 for the real part and lands on the indeterminate form. Such a product is infinite with no real direction left, which is precisely what the single point at infinity represents, so it is now ~oo. The rule reads from either side: a non-real COEFFICIENT does the same to an evaluated real infinity, so i·ln(0) is ~oo rather than -oo. 0·∞ remains the genuine indeterminate form at NaN, and a real factor still keeps the signed rule (-2·∞ = -oo, 2·ln(0) = -oo).

  • A sum holding ~oo alongside a real infinity kept the wrong one. ∞ + ~oo evaluated to +oo and -∞ + ~oo to -oo, discarding the ~oo term; both are ~oo, since the undirected point at infinity absorbs the sum. Add selected ~oo by asking whether a term typed complex, which stopped selecting anything once ~oo moved to number, leaving the signed-infinity counters — which track only real ±∞ — to decide. It now tests the value. Two real infinities are unaffected (∞ + -∞ is still NaN).

  • Gamma at a non-positive integer returned a large finite number when compiled. Gamma(-2) compiled without constant folding ran to 6413262697001887: the reflection formula π / (sin(πz)·Γ(1−z)) cannot see the pole, because sin(-2π) computes as ≈2.4e-16 rather than 0. The kernel now answers NaN at every pole. The interpreter was unaffected — it returns ~oo before reaching the kernel — and so are non-integer negative arguments (Gamma(-0.5) = -3.5449…).

  • Malformed dictionary input is reported instead of thrown — or silently accepted. Building a dictionary from a malformed expression raised a raw JavaScript exception, which ce.box() promises never to do for untrusted input, and two shapes were worse than that: a tuple with more than two elements (["Dictionary", ["Tuple", key, value, extra]]) and a non-string key both produced an EMPTY dictionary that reported itself as valid. All of these now box to an incompatible-type error naming the offending entry, the way the sibling DictionaryFrom/RecordFrom handlers already did. A malformed dictionary NESTED inside another is reported too, rather than becoming a silently empty entry.

  • The two dictionary construction routes agreed on empty keys. The ["Dictionary", …] form accepted an empty-string key while the plain-data {dict: …} form rejected it, so a dictionary built the first way serialized to {dict: {"": …}} and then failed to box back — a valid expression that did not survive its own round trip. Both routes now reject it.

  • Dictionary keys and record-type fields named after Object.prototype members work. A __proto__ entry could not be stored at all (the dictionary rendered as {->} and listed no keys, while At still returned the value by reading the prototype), and reading a MISSING toString or valueOf key returned the inherited JavaScript function as if it were a math value, where any other missing key yields NaN. The same flaw dropped a __proto__ field when a record{…} type was parsed back from its own spelling, so such a dictionary did not round-trip through its type.

  • An argument that violates a type variable's declared bound is now reported. Calling a generic function with an operand its bound refuses — f("abc") where f: (T) -> list<T> where T: number — was accepted and left to run time. The check that decides whether an operand REFUTES a parameter reads the declared signature, and for a generic arm it read the parameter with the variable still free, which no type predicate can answer; the arm is now read with each variable standing for its declared bound, so T: number refuses a string outright. An UNBOUNDED variable is unaffected: it ranges over every value type, so nothing refutes it and such a call still defers.

  • A symbol whose name matches an Object.prototype member no longer reads that member. A MathJSON symbol name is an arbitrary string, but several lookups keyed plain JavaScript objects by it, so toString, constructor, valueOf or __proto__ found an inherited value instead of missing. Three consequences, all fixed:

    • ce.parse('\\mathrm{toString} + 1') threw a TypeError. The LaTeX parser's scope chain answered "declared" for a name it had never seen and handed back the inherited function as the symbol's type.
    • A compiled expression read a MISSING symbol of such a name as the inherited member: toString + 1 returned the string "function toString() { [native code] }1", where every other missing symbol yields NaN. A caller-supplied vars map was also consulted with in rather than an own-property test, on all four targets.
    • \\mathrm{__proto__} resolved as a known unit, because the unit tables answered Object.prototype for that key and the lookup tests its result for truthiness. \\sum_{\\mathrm{__proto__}=1}^{3} \\mathrm{__proto__} silently lost its index and evaluated to 3·__proto__ instead of 6.

    Ordinary symbol names are unaffected, and the emitted code is unchanged for them — the own-property guard is emitted only for a colliding name.

Breaking Changes​

  • The deprecated realOnly compile option has been REMOVED. It was an OUTPUT projection applied to a compiled unit's result after the kernel had run — a {re, im} collapsed to re when the imaginary part was at roundoff scale and to NaN otherwise, a top-level boolean became NaN, and an array result was projected component-wise. It never selected a lowering, so nothing about which code is emitted changes; only what the runner hands back does.

    The result convention already carries what most callers wanted: a compiled value whose imaginary part is exactly zero comes back as a plain number, and a returned ComplexResult always has im !== 0. Test typeof v === 'number' per sample. Where the projection's specific choices mattered, reproduce them at your own value boundary — a {re, im} slot, a boolean result, and ~oo ({re: Infinity, im: Infinity}, what a compiled (-1)! now returns) all reach the caller unflattened instead of as NaN. To forbid promotion and hold the real lane — which realOnly never did — pass mode: 'strict'.

    realOnly is gone from the typed option surface, so a TypeScript caller passing it gets a compile error. An untyped JavaScript caller still gets a one-time console warning naming the removal on both compile routes rather than silently losing the projection.

Improvements​

  • A functions entry can now be declared pure, and a compiled Sum over it stops at the first NaN again. A compiled Sum/Product exits as soon as its accumulator becomes NaN, since NaN absorbs both + and * and no remaining term can change the answer. That exit was suppressed for any body splicing caller-supplied source — a functions/operators entry, a string-valued vars symbol, an operator with a caller compile handler — because such code may count its own calls, log, or mutate shared state. The suppression was all-or-nothing: ONE caller-supplied factor anywhere in the body dropped the exit for every term, and for the scalar loop arm too.

    Note what the skipped terms could still change. Once the accumulator is NaN the sum's VALUE is settled whatever the remaining terms do, so the only thing running them preserved was the supplied function's SIDE EFFECTS. A caller who knows there are none can now say so:

    compile(expr, { functions: { s: { source: mySpline, pure: true } } });

    The functions option accepts a { source, pure? } descriptor beside the bare source-or-function spellings it always took. A declared pure is an assertion and is believed rather than re-derived, so asserting it for a helper that draws or counts will drop calls to it; pure: false pins the conservative behavior.

    An entry that declares nothing is analysed instead, so the common arithmetic helper needs no annotation: a source that is an arrow or function expression whose body uses only its parameters, numeric literals and an allowlist of Math members is taken as pure. It calls nothing else — not a parameter ((f) => f(x) is rejected, since f is whatever the caller passed at run time), and not a Math member outside the allowlist (Math is an ordinary mutable object, so Math.audit may be anything). Also rejected: Math.random, whose two calls disagree; a closure over an outer binding ((t) => t * scale mentions a name that is not a parameter); and a bare name with no body to read, which can only be declared. The grammar is small and explicitly enumerated rather than a JavaScript semantics check, which is what keeps the rejections safe; a missed helper costs the early exit and nothing else.

    Purity governs only whether an emission may be SKIPPED. A caller-supplied implementation stays opaque to every pass that would rewrite what is inside it — common-subexpression elimination included — because the emitter receives its operands as text and may drop, repeat or defer them. Values are unchanged in every arm.

  • A callback with too MANY parameters is now rejected at a user-declared arrow slot, the way one with too few already was. With myOp: ((number) -> number) -> number, the call myOp((a, b) |-> a + b) reported nothing and failed at application; it now carries the same callback-arity diagnostic the opposite mismatch gets — "myOp calls its callback with 1 argument (per the declared parameter list); (a, b) => a + b declares 2 parameters". This covers a callback written inline and one supplied through a symbol alike, whether the symbol was declared with a signature or inferred from an assigned lambda.

    The check reads the slot arm's admissible range — required, then optional, then a variadic tail's mandatory occurrences — so a slot spelled ((number, number?) -> number) still accepts both a unary and a binary callback, while a ((number+) -> number) slot now rejects a nullary one it can never satisfy. At an OVERLOAD set the verdict participates in choosing the arm, so a callback that fits one arm resolves to that arm instead of being reported against a more specific sibling it cannot satisfy. The library's own collection operators are unaffected: their canonical handlers mint the richer per-operator wording before validation runs.

  • Signature subtyping now checks arity in both directions. A signature requiring MORE arguments than a fixed-arity signature supplies is no longer reported as its subtype: (number, number) -> number is not a (number) -> number. Only the too-few direction was refused before, which is what let a wrong-arity callback reach an arrow slot unnoticed. A signature whose tail is optional or variadic keeps its existing leniency.

  • Sort and Ordering declare the comparator result they actually accept. The comparator arm read ((any, any) any -> number), but the evaluator has always also accepted an Elixir-style BOOLEAN comparator (True means the first argument sorts first), so the arm is now ((any, any) any -> number | boolean). The previous spelling was masked by the arity hole above — a boolean comparator was admitted through the unary sort-KEY arm instead, which also meant Sort(xs, Less) type-checked for the wrong reason. Behavior is unchanged; the declared type now matches it.

0.117.0 2026-08-20​

Breaking Changes​

  • evaluate() now leaves a product of sums FACTORED. (a+b)(c+d) evaluates to itself rather than to ac+bc+ad+bd, and 2(x+1) stays 2(x+1). evaluate()'s contract is the most EXACT form, and a factored product is exactly as exact as the polynomial it expands to while being smaller — often dramatically so, since expanding multiplies the term count at every factor. Expand reproduces the previous output verbatim, so code that needs the expanded form should ask for it. This is what made a Product of linear factors superlinear: ∏_{k=1}^{8}(kn-1) returned a nine-term polynomial with large coefficients instead of its eight compact factors, and a plotting consumer paid that cost on every sample because a plot axis variable can never be bound.

    simplify() and .N() still expand, as does every internal normalization path — the change is confined to the Multiply and Product evaluate handlers (mulFactored() and productAccumulate() respectively; the ∏(kn-1) example above is the Product half). Several results are now reported factored where they were not: ∫x²/(2(1+x²))dx is ½(x - arctan x), d/dx LambertW(x) is W(x)/(x(W(x)+1)), and a quadratic with a symbolic coefficient solves to ½(a ± √(a²-4)). The values are unchanged.

Improvements​

  • Raw-form subscript folding no longer depends on the base's spelling. An undeclared subscripted name parsed with form: 'raw' folded to a joined symbol for a Latin base (a_{0} → "a_0") but stayed structural for a command-spelled Greek base (\eta_{w} → ["Subscript","eta","w"]) and for a prefixed base (\operatorname{speed}_{0}), and the prime-first spelling (\alpha'_1) diverged from the subscript-first one (\alpha_1') the same way. All spellings now fold to the joined symbol ("eta_w", "alpha_1", "speed_0"), matching what canonical form always produced, and the two prime orders agree on every spelling. Unchanged: an indexed-collection base keeps the element-access reading unless the joined name is declared, a subscriptEvaluate base (such as \gamma for the EulerGamma family) keeps its subscripts, and dictionary-claimed constants such as unbraced \mu_0 are untouched. If your code reads form: 'raw' output and pattern-matches ["Subscript", base, sub] nodes for undeclared names, re-measure it — those nodes are now plain symbols; code that folded them itself now receives the already-folded name.

Resolved Issues​

  • Dividing a list of points by a scalar keeps the points. A collection whose elements are numeric tuples — list<tuple<number, number>>, the shape a set of points carries — typed its quotient list<number>, dropping the element tuple-ness, while the equivalent product p·(1/q) kept it. The values were always correct; only the type was wrong, and it was wrong in a way that changed downstream readings: PointX/PointY over a list<number> take the element-INDEX reading rather than the elementwise one, so a normalized point list read as its own first coordinate. Every denominator spelling is repaired (a scalar, a broadcastable<number>, a sibling collection of scalars that divides elementwise), and a divisor that can present a tuple — a point, a point list, or a broadcastable<tuple<…>> — still has no defined quotient. The same repair applies to the product of a point list with a sibling list of scalars, which used to widen to list<number | tuple<…>>.

  • Scaling a list of points widens the point's components. A scalar factor folded into a collection's cells but stopped at a tuple cell, so list<tuple<integer, integer>> scaled by a real still claimed integer components even though [(3,4)]·0.5 is [(1.5, 2)]. Each component is now widened with the scalar factors, as a non-tuple cell already was.

  • The derivative of a vector-valued function no longer claims a scalar result. A head declared as a bare function and only then assigned a tuple-valued lambda typed f'(t) as number even though evaluating it returned a 3-tuple — so the declared type contradicted the value, and type-strict consumers such as Cross and Dot rejected the call with incompatible-type. Derivative now reads the codomain of an assigned function literal when the declaration itself is uninformative, and D of a tuple- or list-valued body reports the shape it actually evaluates to (D((cos t, sin 2t, t), t) is a tuple, not a number). A head that is declared function and never assigned still reports the long-standing scalar compromise.

  • A literal 0 operand is no longer dropped when serializing to LaTeX. Several serializers tested an operand for truthiness where they meant to test for absence, and the MathJSON of the literal 0 is the number 0 — so a zero operand read as a missing one and was silently deleted. A domain restriction on a zero lost BOTH operands (When(0, x=x) serialized to the empty string), Return(0) printed as a bare return, a big operator lost its body AND its indexing sets (Sum(0, i=1..10) became \sum), an integral lost its integrand (Integrate(0, x) became \int), and Delimiter(0) became an empty (). Each now serializes its zero, so an expression carrying one round-trips through LaTeX again — which matters wherever serialized LaTeX is a persistence boundary, since parsing was never at fault and the loss was silent. Log(x, 0) also now renders its zero base as a base (\log_{0}(x)) rather than falling back to the argument-list form.

    The round trip is an equality of CANONICAL expressions, not of bytes or of raw parse trees. Re-serializing is free to choose a different spelling of the same value (\left[.1,.3\ ...\ 1\right] comes back as (0.1..0.3..1), \operatorname{abs} as \vert…\vert, \sqrt{m} as m^{1/2}), so a byte-stability check reports damage on a bundle where nothing is wrong. Compare with .isSame() on the canonical parse. Also beware an assertion that merely looks for a 0 in the output: Sum(0, i=1..10) now serializes \sum_{i=1}^{10}0, but the upper limit contains a 0 of its own, so "contains a zero" is satisfied by output that dropped the body — the exact defect being tested for. Assert on the reparsed expression, not on the string.

  • A negative subject of a When restriction keeps its precedence. When(-1, cond) serialized as -1\left\{cond\right\}, which reads back as Negate(When(1, cond)) — the negation applied to the restriction instead of to its subject. The subject is now parenthesized whenever its own precedence is below that of the restriction. The repaired form is (-1)\left\{cond\right\}, which parses back to When(-1, cond) at canonical form; a RAW parse of it keeps the parentheses and the unfolded negation (["When", ["Delimiter", ["Negate", 1]], cond]), as it does for a parenthesized negative literal anywhere else. The invariant restored here is that the negation sits inside the restriction rather than outside it — check it against the canonical parse, not the raw one.

  • A symbol named after a JavaScript object member no longer misbehaves. Names such as toString, constructor and valueOf collide with members every JavaScript object inherits, and the engine looked symbol names up in tables that carried those members. The consequences were visible three ways: ce.box("toString") returned the JavaScript function of that name instead of an expression, ce.function("toString", [1]).toString() rendered a JavaScript error message as the expression's text, and toString used as a variable was refused by compile() as though it were a built-in operator. All three now treat such a name as the ordinary symbol it is.

  • Rewriting a nested Sum/Product no longer breaks its index binding. .subs(), .replace() and .map() each rebuilt a scoped node onto a FRESH scope, parented at the rewriting site rather than at the rebuilt outer node. The chain from the inner body then never reached the outer binder's index, so an outer index whose name collides with a library constant resolved to the CONSTANT: Σ_{i=1}^{2} Σ_{j=1}^{2} x·At(K, i+j) substituted at x := 1 answered 60 where the same expression built directly answers 120, and Σ_{i=1}^{2} Σ_{j=1}^{2} x·i under x → 2 answered 8i — the imaginary unit — instead of 12. Nothing looked wrong: the rewritten expression reported isCanonical and printed identically to the direct one. A scoped node is now rebuilt onto its own scope, so the chain is preserved at any nesting depth. Only the OUTER index's name mattered, so sums indexed p/q were correct all along and sums indexed i/j — what most people write first — were not.

  • A partial CanonicalForm[] request no longer rewrites a binder's own index. Boxing with { form: ['Order'] } (or any other partial form) runs before a binder's scope exists, and its symbol pass resolved EVERY symbol against the ambient scope — including the bound variable at its binding site. Sum(2i, Limits(i, 1, 3)) came back as Sum(2·Complex(0,1), Limits(Complex(0,1), 1, 3)) and then failed to evaluate with incompatible-type; the Number form rewrote it a second time, through its imaginary-unit normalization. A name the node's operator declares as a binding site is now left alone, at the site and throughout the binder's body. A FREE imaginary unit still folds as before.

  • An unrolled Sum/Product now stops at the first NaN, and a constant collection inside one is built once instead of once per term. When both bounds of a Sum or Product are constant and the range is small, the JavaScript target unrolls it into explicit terms rather than emitting a loop. That unrolled form evaluated every term even after the running total had gone NaN — where the loop form exits on the first one — and it re-emitted every subexpression per term, including subexpressions whose value cannot depend on the index. A 31-term sum sampling a constant 101-element list built that list 31 times, in the source and again on every call: 11 KB of emitted JavaScript with 31 Array.from constructions, all evaluated even when the first term already answered NaN. Such a sum now emits 4 KB with a single construction bound ahead of the accumulation, and returns as soon as the total is NaN. This restores the cost a plot or sampling loop over such an expression had before the typing improvement that began steering these bodies into the unrolled form (they previously took the element-wise fold loop, which already had both properties) — a plot that went blank because every sample evaluated all 31 terms paints again. Values are unchanged: NaN absorbs both + and *, so stopping early returns the same answer. From four terms on, the emitted source for such a sum is a statement sequence in an IIFE rather than a flat a + b + c chain; two- and three-term sums keep the flat chain. The SCALAR loop form — a range past the unroll limit, or a bound that is not a compile-time constant — gained the same exit, which it had only on the element-wise fold path: a symbolic-bound or 100,000-term sum no longer runs every remaining iteration after the total has gone NaN. Its shape is otherwise unchanged (one emission of the body, no per-term statements). Complex-valued sums and products keep every term in both forms, deliberately: a complex accumulator with a finite imaginary part does not absorb, so an early exit there could change the answer. In both forms the exit is omitted when a term splices caller-supplied source — a functions/operators entry, a string-valued vars symbol, an operator with a caller compile handler — since such code may count its own calls or mutate shared state, so it must run as many times as it did before. Note this when checking the change yourself: a vars entry whose value is a STRING is spliced as source, which is exactly the carve-out above, so a sum compiled with vars: { x: 'number' } emits no early exit at all and looks like the old behavior. Measure with a numeric vars value, or with no vars map — the free symbol is read from the arguments object either way.

  • A protocol function member read as a field now says so. b.span for a function span(self: Self) -> number requirement reported unknown-field "span" (w, h) — true of the object's LAYOUT and useless to the author, since the name they wrote does exist, on a protocol the value conforms to. It now reports protocol-function-not-a-field, naming the protocol and the call spelling: a function member is called (span(b)), only a readonly/readwrite property is read with a dot (b.area). This is the mirror of the protocol-property-not-callable warning below. Object, record and named-tuple receivers all report it, and so does an ASSIGNMENT to such a name (b.span = 5), whose message says the member cannot be written rather than recommending a call. When several conformances answer the same name, the message names them all and asks for a qualified call, since a bare one would be protocol-call-ambiguous. A name no protocol claims still reports the layout, and so does a name whose conformance is not settled — a PENDING edge carries no implementation, so there is no call to recommend yet.

  • Epsil: a protocol property called as a function is now reported. A protocol's two member kinds are spelled differently — a function member is invoked in call position (span(b)), a readonly/readwrite property is read with a dot (b.area) — and using the call form for a property resolved to no operator, so it stayed a silently inert application: a program ending in print(c, area(c)) printed area(Circle(…)) with nothing to say why. It now raises a protocol-property-not-callable warning naming the property, the protocol that declares it, and the dot spelling. The check runs over the raw statement, so it fires even when the inert call is consumed by another operator and never reaches the statement's value — the shape this was reported on. Function members in call position are correct and untouched, and a name that has a real operator definition (a user function that happens to share a property's name) is never reported.

  • Epsil: the protocol-implementation-pending warning no longer covers the whole program. It was emitted with a hardcoded whole-source range, because the check walks the protocol registry — which outlives the batch — rather than the source, so an editor squiggled the entire file. Each pending edge is now anchored to the type X is P statement that declared it when that statement is in this batch; one statement naming several protocols anchors every one of its edges. An edge declared in an EARLIER batch (the declare-in-one-cell, implement-in-the-next pattern the warning exists to support) still falls back to the whole program, since this source contains no statement to point at.

  • A declared type now admits a value whose type is unknown. Assigning an expression the engine cannot type statically — a call to a function with no signature, say — to a symbol with a declared type was refused: let xs: list followed by xs = f(0) raised incompatible-type list unknown, and so did the let xs: list = f(0) spelling, for every declared type (number, string, list<number>, …). A type of unknown states nothing about the value ("some value, not stated which"), so there is nothing for the declaration to refute; it is admitted and the verdict is left to run time, which is what the argument-position boundary already did — the identical value passed to a list parameter was accepted. Values the engine CAN type are still held to the contract (xs = 42 against list is still an error), and a bare-constructor declaration still refines its element slot from real evidence: an admitted unknown value leaves xs reporting list, and a later xs = [1, 2, 3] refines it to list<finite_integer>. any is unaffected — it is a stated contract, not a placeholder, and is still checked, and so is a declared function SIGNATURE ((number) pure -> number), which keeps refusing an unknown value rather than installing a definition that would be callable under a contract nothing proved. Note the admission is unchecked: nothing re-examines the value later, exactly as at a typed parameter.

  • A compilation reached through a target now escalates to complex mode like the standalone compile() does. Under the default auto mode a lane mismatch — a complex-shaped value, typically a promoted unknown-sign radical, reaching a binding the compilation shaped real — is answered by recompiling under mode: 'complex'. That retry existed only in the standalone compile() export, so a caller using the target route (ce._getCompilationTarget('javascript').compile(expr, options), the route an integration takes once it needs a specific target) got a thrown LaneMismatchError instead — or, with fallback: true, a success: false result with the lane-mismatch diagnostic and an interpreter-backed run — where the standalone route returned working compiled code. The escalation now lives in each target's own compile(), so both routes behave identically: such a call returns success: true with mode: 'complex', promoted: true and the failed strict attempt's diagnostic in escalation. Callers of the target route under the default mode are affected; fallback: true callers will see success: true where they previously saw success: false with lane-mismatch. mode: 'strict' is unchanged on both routes, and the shader and interval targets, which never promote to complex, are unaffected. One consequence for a THIRD-PARTY registered target: escalation is now each target's own responsibility, so a custom target that declares auto support no longer inherits the retry from the standalone wrapper — apply the exported compileWithAutoEscalation helper inside its compile() to keep the behavior.

  • The Python target's interpreter fallback no longer drops the realOnly projection. With fallback: true on the Python target route, a declined compilation handed back an interpreter-backed run that ignored realOnly: true (the standalone route forwarded it; the target route did not) — a complex result then reached a caller that had asked for the real-only projection. Same route-asymmetry family as the escalation fix above, found while moving it.

0.116.1 2026-08-19​

Improvements​

  • Cross accepts numeric tuples, like Dot always did. Cross((1,2,3), (4,5,6)) computed nothing — the signature demanded vector, so a point spelled as a tuple was rejected with incompatible-type while the same point passed Dot — and the two classic vector products disagreed about what a vector is. A provable numeric 3-tuple (a point in ℝ³, including a PointList row) is now lowered to its component vector exactly as Dot lowers it; the result is a List, consistent with collection operators not preserving the tuple kind. A tuple that is not provably numeric stays a symbolic Cross, and a wrong-length tuple gets the incompatible-dimensions report, not a type error. Affects code that builds Cross over Tuple/point operands — previously an error, now a value.

Resolved Issues​

  • compile() no longer reports the boolean literals as required inputs. CompilationResult.freeSymbols lists the identifiers a caller must supply, and True/False were included even though every target either bakes them into the emitted code or drops them entirely. Since consumers derive a compiled function's variable list from freeSymbols, and True is the idiomatic fallback condition of a Which, an ordinary piecewise such as Which(x > 0, 1, True, 2) acquired a phantom variable named True.

  • A boolean literal compiled to invalid code on the shader and interval targets. The glsl and wgsl targets emitted the undeclared identifier True — a shader that fails to compile, reported behind a successful result — and the interval target emitted a lookup for a caller-supplied variable named True that threw at run time. Each target now emits its own spelling of the literal.

  • Trigonometric operators now report a non-numeric operand instead of staying inert. The trigonometric and hyperbolic functions, Degrees and DMS validated only the NUMBER of arguments, not their types, so an operand that turned out not to be a number was absorbed rather than reported: Sin(At(["a", 2], 1)) stayed as sin("a") and Degrees(At(["a", 2], 1)) answered a bare NaN. All of them now answer incompatible-type, matching the arithmetic operators. This completes the numeric-operand checking introduced in 0.116.0, which had covered arithmetic but not these operators.

  • DMS no longer mis-folds a component that is not a plain real number. Its degrees, minutes and seconds components were read in a way that silently discarded anything but a real number literal, so DMS(1, At([30, 2], 1)) answered NaN even though the minutes component resolves to 30, and DMS(1, i) answered exactly what DMS(1, 0) does. Such a call now evaluates its components first and, when one of them cannot be folded, is left unevaluated instead of producing a wrong angle.

  • The root compile() export regained its precise result types. Its declared return type collapsed the target generic, so run was typed optional even for executable targets such as javascript, and realOnly: true no longer narrowed run's values to plain number — a type-precision regression against the internal target route (ce._getCompilationTarget(...).compile(...)) that 0.116.0 told consumers to migrate away from. The export's overloads now mirror the underlying compile function's, so the target name flows through: compile(expr).run is non-optional, compile(expr, { realOnly: true }).run(...) is a number, and calling run unguarded on a source-only target (python) is a type error. Runtime behavior was never affected. Reported by a consumer at 0.116.0 adoption.

0.116.0 2026-08-19​

Breaking Changes​

  • Type now returns a type value instead of text. Use StringFrom(Type(x)) if you need the type name as a string, or use type-aware checks such as x is integer and Subtype(Type(x), TypeFrom("number")). Code such as Type(3) == "integer" now evaluates to False.
  • Callback type declarations now use regular arrow types. The old callback<...> spelling no longer parses. Declare the callback slot directly, for example (T) any -> boolean for a predicate. Calls that could only fail, such as filtering a list<string> with a number-only predicate, are now rejected before evaluation.
  • The minimum supported Node.js version is now 22.3.0. Older Node 21 releases are no longer supported.
  • About now returns a dictionary. Read fields such as About(Pi)["type"] or About(f)["signature"] instead of parsing a display string.
  • Bare collection types now mean values-only collections. list, set and similar bare types are synonyms for their <unknown> form, not <any>. Use collection<any> when absence markers such as Missing should be admitted.
  • Dictionaries now print in dictionary literal form. toString() and the Epsil REPL print dictionaries as {"key" -> value}, with {->} for an empty dictionary. MathJSON serialization is unchanged.

Types And Pattern Matching​

  • Added first-class type values with TypeFrom(...), Subtype(...) and the primitive type. Example: Subtype("integer", "number") evaluates to True.
  • is tests and match type patterns now accept full type expressions, including unions, negations and parameterized collections. Example: x is list<integer> and x is number | string.
  • Protocol tests can be written with is. Example: x is Hashable & Comparable.
  • Collection declarations with a bare element type can refine from assignments. Example: after let xs: list; xs = [1, 2, 3], xs is typed as a list of integers.
  • Uses of assigned symbols are checked against their assigned type instead of narrowing the symbol silently. This reports incompatible uses earlier, before evaluation.

Epsil And VS Code Extension​

  • Added console I/O with print(...) and input(prompt?). print writes to the host console and returns Nothing; input reads one line of text where the host supports it.
  • The CLI and REPL no longer echo a final Nothing result, so a program ending with print("done") only displays done.
  • Very deeply nested Epsil expressions now produce an expression-nesting-limit diagnostic instead of overflowing the JavaScript stack.
  • Lexing and parsing now respect the evaluation time budget, so an oversized program can time out before execution begins.
  • The VS Code extension gained Go to Definition, Find All References, Rename Symbol, richer hovers and diagnostic links to the error reference.

Strings And Regular Expressions​

  • Added regular expressions with the regexp type and RegExp(pattern, flags?). New operations include IsMatch, StringMatch, StringMatchAll, and regular-expression forms of StringSplit and StringReplace.

    IsMatch("abc123", RegExp("[0-9]+"))
    StringReplace("a1b22c", RegExp("[0-9]+"), "#")
  • Regular expressions use the host JavaScript regexp dialect. The g and y flags are rejected because their mutable position would make evaluation order visible.

  • StringMatch returns match text, ranges, numbered groups and named groups. Ranges are character-based, matching the rest of the string library.

Symbolic​

  • And and Or once again compare and match symbolically without depending on operand order. Evaluation still runs left to right and still short-circuits.
  • Applying a value that is known not to be a function now reports expected-function instead of staying inert. Example: Pi(2) is now an error.

Engine State​

  • Added engine checkpoint APIs: ce.checkpoint(), ce.restore(cp) and ce.discard(cp). Notebook-like clients can checkpoint before a cell, restore to that point after an edit, then replay later cells.
  • Checkpoints preserve expression validity and cache identity where possible. Restoring replays state changes, but effects still run again: Random() draws again and Print(...) prints again.

Compilation And Numeric​

  • Numeric operators now accept operands that may be numeric and report a typed error only when evaluation proves the value is non-numeric. Example: a + 1 is allowed for a: value, but a = "hello" later produces an incompatible-type error.
  • Fixed several compiled complex-number cases that could return NaN or corrupt values while reporting success, including color functions, list broadcasts, complex-declared symbols with real values, and promoted radicals inside broadcasts.
  • CompilationResult.mode now reports the actual arithmetic mode used by the emitted code. A default compile that promotes to complex reports mode: "complex".
  • Deprecation warnings for realOnly and complexPromotion now appear through target-level compile entry points as well as the standalone compile() export.
  • Consumers that discover the engine through the global registration object can now access the supported compile() helper there too.

Collections​

  • Sequence now splices into List, Set and Tuple literals. Example: List(1, Sequence(2, 3), 4) becomes [1, 2, 3, 4].
  • Lazy set operations now deduplicate consistently. Join(Set(5, 2), Set(2, 3)) now has three elements, not four with a duplicate 2.
  • Map over a set now returns the distinct image of the set. Example: Map(x => x^2, Set(-1, 1, 2)) is Set(1, 4).
  • Joining or appending dictionaries and records now merges keys with the last value winning. Example: Join({"a" -> 1}, {"a" -> 2}) yields {"a" -> 2}.
  • Truncated set previews now show an ellipsis when more elements remain.

Type Fixes​

  • Fixed bare collection and type-variable inference so bare collection operands stay in the values-only family.
  • Fixed intersections, never, negated types, collection meets and function-signature intersections so type reductions are more consistent.
  • Fixed several type edge cases, including matching bare names against intersections, preserving vector and matrix spellings, and reporting arity errors for malformed Type(...) calls.

Documentation​

  • Removed documentation for the non-existent Extract and Exclude operators. Use At, Slice, Reverse or DeleteAt instead.

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE 0.116.0CE 0.100.0SymPymath.jsMathematica
\pi^28.47.51771,3163.9
\sin 121212204405.1
\cos 121212206187.1
\ln 215143504,3443.7
e^{\pi}14132135,3084.6
\zeta(3)1,5331,551267—49
\Gamma(\tfrac13)829821342—213
\psi(\tfrac13)7157162,786—171

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case. Compare the CE 0.116.0 and CE 0.100.0 columns to see what is new since the last published benchmark (a — under 0.100.0 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE 0.116.0CE + R/FCE 0.100.0SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx3.7×2.0×2.8×0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx7.7×1.4×5.8×0.08×—1×
\int\frac{1}{x^3+1}\,dx4.2×0.8×3.7×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—1.7×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—1.0×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—1.0×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.03×0.03×0.03×0.001×0.003×1×
Simplification
\sqrt{3+2\sqrt2}41×30×29×——1×
\sqrt6\,x+\sqrt2\,x63×55×49×3.2×16×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}33×12×33×3.0×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x3.8×3.5×4.4×2.1×—1×
\int_1^2\tfrac1x\,dx3672×4204×4152×80×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx262×100×237×2.6×—1×
Solving
x^4+x^2-1=00.2×0.2×0.3×0.06×—1×
x^3-x-1=01.5×1.6×1.4×0.04×—1×

Across the cases both solve, Compute Engine is a median 3.8× faster than Mathematica (up to 3672×) — in the browser, not a proprietary kernel.

Measured 2026-08-19 · Compute Engine 0.116.0 (current build @ c5f1f3be) · published 0.100.0 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.115.0 2026-08-17​

Breaking Changes​

  • The default JavaScript and Python compile mode is now auto. Expressions such as Sqrt(x), Ln(x) and non-integer powers may promote to complex arithmetic when the sign is unknown. For example, compiled Sqrt(x) now returns {re: 0, im: 1} at x = -1 and 2 at x = 4. Use mode: "strict" for the previous real-only compile behavior.
  • complexPromotion and realOnly are deprecated. Use mode: "complex" or mode: "strict" instead. realOnly still works for this release but now warns.
  • Compiled JavaScript returns plain numbers for exactly real results. A complex result object {re, im} is now returned only when im !== 0. Use typeof value === "number" to test whether a sample is real.
  • StringJoin now joins one collection, with an optional separator. The old variadic concatenation form changed meaning for two strings. StringJoin("ab", "cd") is now "acdb" because "cd" is the separator between the characters of "ab". Use Join("ab", "cd") or interpolation for string concatenation.
  • Several string collection operators now preserve string results. RandomShuffle("abc") and DeleteAt("abcdef", 2) return strings, not lists of characters. Chunking and combinatorics over strings now return lists of strings, for example SlidingWindow("abcd", 2) returns ["ab", "bc", "cd"].

Compilation​

  • Added explicit compile modes: "strict", "complex" and "auto". Unsupported modes now return a clear diagnostic instead of silently changing behavior.
  • CompilationResult now includes mode, promoted, escalation and structured diagnostic data so callers do not need to parse error messages.
  • Strict mode now rejects complex values where the generated code expects real numbers, with a lane-mismatch diagnostic.
  • Complex mode now treats broad or unannotated numeric inputs as complex-capable and promotes unknown-sign radicals consistently.
  • JavaScript runners now validate incoming values against the shapes used during compilation. For example, a complex object passed to a real-analyzed binding throws a clear TypeError.
  • Fixed compiled complex behavior for multi-clause functions, protocol methods, fallback evaluation, real-only special functions and promoted radicals.
  • (Recorded after release.) A compiled Reduce or Scan whose accumulator becomes complex mid-fold through an unknown-sign radical — for example Reduce([4, -4], (a, x) -> a + sqrt(x), 0) — returned success: true with value: null in 0.114.0. The complex-mode migration in this release fixed it (the radical now promotes and the fold computes {re: 2, im: 2}, matching the interpreter). Documented here retroactively after a consumer traced the fix to this release; the change landed in commit 7cfa95b4.

Epsil​

  • Added definition attributes for user-defined functions: bind parameters, algebraic properties such as commutative and associative, and doc comments.

    /// Square a value.
    function square(x) -> number { x^2 }
    About(square)["description"]
  • Fixed pipe placement when a stage has a missing trailing argument. Examples such as xs |> Fold(f, 10) now put xs in the collection slot instead of displacing arguments incorrectly.

  • Multi-clause functions now map over collections the same way function literals do. Example: fib(5..10) evaluates element-wise.

  • Which and If now hold or broadcast correctly when a comparison's broadcast shape cannot be decided statically.

  • Sum and Product now substitute loop indexes correctly inside held If and Which terms.

  • A symbol's inferred type is updated when a later value proves the earlier guess was too narrow.

Strings​

  • Join now concatenates strings. Example: Join("ab", "cd", "ef") returns "abcdef".
  • Added contiguous sequence search: RangeOf, ContainsSequence, StartsWith and EndsWith. Example: ContainsSequence("abc", "ab") is True, while Contains("abc", "ab") is False.
  • Slice accepts Nothing, so Slice(xs, RangeOf(xs, needle)) returns the match or Nothing without an extra branch.
  • Added StringReplace, Trim, TrimStart, TrimEnd, StringRepeat, PadStart and PadEnd.
  • Added case operations: ToUpperCase, ToLowerCase and CaseFold. Use CaseFold for case-insensitive comparison.
  • Added StringCompare(a, b) for deterministic code-point ordering.
  • Added NumberFrom(s, base?) for parsing numbers from strings. Failures return an error value, not NaN.

0.114.0 2026-08-16​

Breaking Changes​

  • Strings are now indexed collections of characters. Length("shop") is 4, "abc"[2] is "b", and generic collection code can now accept strings. If a function should accept collections but not strings, use a type such as collection & !string.
  • string is no longer a scalar. Declare string, or string | number, when strings are intended.
  • Added a distinct character type. Characters are single user-perceived characters and are not one-character strings, although they compare equal by value where appropriate. Characters("ab") now returns list<character>.
  • String-preserving collection operators now return strings for string input. Examples: Reverse("abc") returns "cba" and Take("abc", 2) returns "ab". Transforming operators such as Map still return lists.
  • Materializers and set operators read strings as characters. ListFrom("abc") is ["a", "b", "c"], and [..."ab"] is ["a", "b"].
  • Max, Min, GCD and LCM no longer expand strings. Strings are treated as non-numeric values for these operators.
  • Invalid UTF-16 surrogate halves are normalized when strings enter the engine. They are replaced with U+FFFD so all string operations work on well-formed Unicode.
  • Compiled string collection operations are grapheme-correct in JavaScript and fail closed on targets that cannot support them. Python and shader targets no longer emit approximate string collection code.

Strings​

  • Added CharacterFrom(s) and host API support for character values. String(c) converts a character back to a string.
  • Strings work with collection operations over grapheme clusters. Example: Tally("mississippi") counts characters, and "7" in "0123456789" is True.
  • String(collection) now joins a finite collection into one string. Example: String([1, 2]) returns "12"; use Map(String, [1, 2]) to convert each element separately.

Epsil And Types​

  • Added hold functions, whose arguments are bound as written and evaluated only when used in the function body. Example: hold f(e) = Head(e) lets f(a + 1) inspect the expression structure instead of the evaluated value.
  • Re-declaring an object type now re-checks protocol conformances that depend on its stored fields. This makes notebook-style re-runs update field-backed protocol behavior correctly.
  • Protocol member effect annotations in implementation blocks are now honored and checked.
  • Writes through mutable objects consistently carry the state effect.
  • Head and Tail now resolve a symbol's assigned expression during evaluation while staying structural for unbound symbols.

Numeric And Symbolic Fixes​

  • Reduce and Scan now handle complex accumulators correctly in both interpreted and compiled numeric paths.
  • Abs over a negation now returns the absolute value rather than the negated expression.
  • The derivative of a vector norm now uses the vector norm rule instead of the scalar absolute-value rule.
  • A declared subscripted symbol with a Greek base, such as \eta_w, now remains the declared joined symbol instead of being captured as an index when \eta becomes a collection.
  • A compiled user function with a complex parameter now receives and returns correctly shaped values.

0.113.0 2026-08-16​

Breaking Changes​

  • Protocols that can mutate state now require object types. A protocol with a readwrite property or a member declared with state effects can only be implemented by an object{...} type. Use an object type when writable protocol properties are needed.
  • Assigning to a protocol property now stores into the object. p.name = "Ada" mutates the object referred to by p and returns the assigned value. It no longer calls a setter that rebuilds and rebinds p.
  • Collection operators now report result types by collection kind. Operators such as Reverse, RotateLeft, RotateRight, Rest, Most and Filter no longer promise to preserve every indexed collection kind. For example, reversing a tuple now reports a list type.

Objects And Protocols​

  • Stored object fields can now satisfy protocol property requirements with the same name. Example: type Person = object{name: string} is Nameable can satisfy a readonly name property without writing a getter.
  • Field-backed writable properties store into the object in place.
  • A stored field and an explicit accessor for the same protocol property are now reported as a conflict.

Collections​

  • Slice now accepts an index span. Example: Slice(xs, 2..4) is equivalent to Slice(xs, 2, 4).
  • Bare collection element types now consistently read as unknown rather than any.
  • Assigning a value to a symbol declared unknown now refines the symbol from the assigned value. Declare any when the type should remain fully permissive.
  • Speculative parsing no longer narrows symbols declared unknown in the surrounding engine.

Numeric, Compilation And Parsing​

  • Fixed division by a scaled vector norm, which could incorrectly collapse to 0.
  • Fixed compiled calls where a complex value is passed to a user function with a wide parameter.
  • Lowercase LaTeX operator names such as \operatorname{unique}, \operatorname{sort}, \operatorname{reverse} and \operatorname{total} now parse to their intended operators.
  • Overload diagnostics now display the declared type shape instead of confusing ...<unknown> instantiations.

0.112.0 2026-08-15​

Breaking Changes​

  • Epsil lambdas now use =>. The old |-> spelling reports a diagnostic with a fix-it. Function types still use ->, and dictionary entries still use ->.

    let square = x => x^2
    [1, 2, 3] |> Map(_ => _^2, _)
  • record and object field types now use braces. Write record{x: integer, y: integer} and object{name: string} instead of the old angle-bracket form.

  • And, Or and related boolean operators now short-circuit and preserve operand order. This can change serialized order for expressions that were previously sorted during canonicalization.

Epsil​

  • Lambda parameters and for loop bindings can destructure tuple elements. Example: pairs |> Map(((x, y)) => x + y, _).
  • Callback arity mismatches are now reported statically. Example: Map((x, y) => x + y, [1, 2, 3]) reports that Map calls its callback with one argument.
  • A pipe stage that cannot accept exactly one piped value now reports pipe-stage-arity instead of returning a partially applied function.
  • Duplicate lambda parameter names are now parse diagnostics.
  • for and while loops now serialize back to Epsil loop syntax instead of generic Loop(...) calls when possible.

Types And Collections​

  • Added the range type for ascending, contiguous 1-based index spans. It is still an indexed collection of integers, but can also be required by span-consuming operators.
  • A lazy collection callback now runs once per element per consumption, avoiding duplicate side effects from probing.
  • Max, Min and statistics over Linspace now handle descending, single-sample and symbolic cases correctly.
  • Pipes that implicitly map over a collection now report the mapped collection type.
  • Comparisons that may broadcast now report a broadcastable<...> type, allowing Which and Sum to type their results more accurately.
  • Scaling or shifting collections now widens element types correctly. Example: (1..4)/2 reports rational elements, not integers.
  • Loop and comprehension binders can widen inferred element types when the iterated collection under-declares its elements.

Compilation And Numeric​

  • Shader compilation now emits valid integer conversions for symbolic Sum and Product bounds.
  • Statistics functions such as Mean(x) now compile correctly for a single value.
  • Differentiating point-valued functions installed through assign is componentwise again.
  • Primes before subscripts now attach to the subscripted function name. Example: F'_{0}(t) parses as the derivative of F_0 applied to t.
  • Element-wise arithmetic over collections of complex values now compiles where a single real-or-complex convention fits.
  • Real-only numeric operations such as Floor, Max and Mod now decline complex operands instead of compiling to NaN.

0.111.0 2026-08-15​

Breaking Changes​

  • RecordFrom has been removed. Use DictionaryFrom. Given identifier keys, DictionaryFrom([("a", 1), ("b", 2)]) already produces a value typed as a record.

Objects​

  • Mutable object fields can now be assigned. Example: p.age = p.age + 1 stores into the object in place, and all aliases see the update.
  • Assigning to fields of immutable values now reports immutable-value-assignment with guidance to use an object type or build an updated copy.

Epsil And Performance​

  • Epsil programs now stop when the active time budget expires, instead of continuing after a statement times out.
  • Parsing long Epsil programs is now linear in program length.
  • Canonicalization now checks withTimeLimit deadlines while it runs.

Collections And Numeric​

  • Operators now stay symbolic for collection-typed symbols that have no value yet, instead of guessing scalar or empty behavior. Example: Mean(L) stays symbolic for unassigned L: list<number>.
  • Indexed reads from collections with complex elements now compile correctly or decline when no single result shape fits every possible index.
  • Collection comparisons no longer overflow the stack for declared-but-unassigned collection operands.
  • Subset and superset operators now use the documented operand order and handle ranges, intervals and strictness more accurately.
  • Closed degenerate intervals, such as Interval(1, 1), are now treated as non-empty.
  • Declared fixed-size collection types now expose their count without requiring a value. Example: a vector<2> symbol has count 2.

0.110.0 2026-08-15​

Breaking Changes​

  • Functions that write to outer variables must declare the scope effect. Unannotated functions now guarantee they do not mutate outer bindings. Use function bump(n) scope { total = total + n } when mutation is intended.

Compilation​

  • Added complexPromotion as an opt-in compile option for complex results from Sqrt, Ln and Log when their operand sign is unknown.
  • Compile-time constant folding now includes small constant indexed collections. Example: At(Map(_ => _^2, 1..6), k) can bake the mapped list into emitted code.
  • Computed Range bounds now use their runtime length when compiled, instead of being mistaken for numeric literals.
  • Compile-time folding is now deterministic and based on expression cost, not wall-clock timing.
  • Local function definitions now compile and stay local to their block instead of leaking into the global scope.

Epsil And Types​

  • Writes to a function's own parameters are treated as call-local and can remain pure.
  • Property writes are judged by the base variable, so writes to local objects do not incorrectly require scope.
  • Declared placeholder signatures using unknown now refine from the function body. Use any for a true accept-anything contract.
  • Declared function clauses can omit parameter annotations when a declared signature already supplies them.
  • Assigning to a declared subscripted name now prefers the joined symbol, and ambiguous undeclared function-family assignments now report an explicit error.

Numeric And Collections​

  • Adding three or more collections now handles lazy views correctly instead of nesting results.
  • Arithmetic on vector-valued calls with not-yet-defined arguments now remains valid when the eventual operation is element-wise.
  • Dividing a point or vector now widens component numeric types honestly. Example: [6, 2] / 4 has rational components.
  • Equal and NotEqual between a collection-typed application and a list now produce one whole-collection boolean.
  • Nested-list broadcasts no longer retype inner collection-valued symbols.
  • Large counted Take and Drop operations in compiled code now honor the requested count instead of throwing at the iteration limit.
  • Filter and Unique now apply the iteration cap to unproductive pulls, so productive infinite filters can be consumed safely with Take.

Performance​

  • Long flat sums, products and related chains now parse in linear time.
  • Deep expression trees can be boxed more reliably before hitting the host stack limit.
  • Large documents involving comprehensions and broadcasted indexed reads now parse much faster.

0.109.0 2026-08-14​

Breaking Changes​

  • Defining the same function clause twice in one Epsil program is now an error. Clauses with different parameter domains still accumulate for multi-clause functions, and re-running a later program still replaces definitions notebook-style.

Epsil​

  • Spreading declared-but-unassigned dictionaries and records now stays symbolic instead of erroring. Example: {->, ...d, "k" -> 3} can be built before d has a value.
  • Dictionary conversion errors now return error expressions instead of throwing uncaught JavaScript exceptions.

Compilation​

  • Degree-mode compiled angular functions with constant arguments now agree with interpretation. Example: compiled sin(90) in degree mode now returns 1.
  • Epsil programs that define a local function and call it can now compile to JavaScript, including recursive calls and use as callbacks where supported.
  • Lambda parameters named _ no longer shadow the generated JavaScript vars object, fixing compiled callbacks that refer to free variables.
  • Drop with a negative count now reports the same element count it actually yields.
  • Compiled counted Take and Drop over infinite streams now work when the requested count is larger than ce.iterationLimit.

0.108.0 2026-08-14​

Breaking Changes​

  • Map now takes the function first: Map(f, xs). The legacy Map(xs, f) order now reports an incompatible-type error. Pipeline calls should use xs |> Map(f, _) or simply xs |> Map(f).

Epsil​

  • A type, protocol or sum name may only be declared once in a single Epsil program. Re-running a later program can still replace the declaration.
  • Added spread syntax in list and set literals and dictionary merges. Examples: [...xs, 3], {1, ...s} and {->, ...defaults, "verbose" -> true}.

Compilation And Numeric​

  • Compiled code now folds pure constant subexpressions to literals. Example: x + Sum(Map(_ => _^2, 1..5)) can compile as x + 55. Use constantFold: false to inspect structural output.
  • Difficult symbolic derivatives now fall back to numeric differentiation for N() and compiled evaluation when a closed form is too large. Exact evaluate() still stays symbolic.
  • At(xs, indexes) now preserves known selection counts, improving Zip and Map over gathered elements.
  • expr.unknowns no longer reports typed lambda parameters as free variables.
  • Numeric approximation of divergent infinite sums and products no longer returns silently truncated partial results.
  • 170! no longer overflows to Infinity in machine-precision paths.
  • Python-compiled counts for Take, Drop, Tabulate and Fill now round like the interpreter.
  • Numeric quadrature now honors active time limits.

Strings​

  • IntegerString now preserves the minus sign. Example: IntegerString(-42) returns "-42".
  • StringSplit(s, "") now splits into grapheme clusters instead of UTF-16 code units, avoiding corrupt non-BMP characters.

Performance​

  • Large documents with symbolic range bounds now avoid major slowdowns and memory pressure.

0.107.0 2026-08-13​

Epsil​

  • Pipelines gained concise stage forms. A missing argument is filled by the piped value, inline lambdas can appear directly after |>, and a one-parameter lambda stage over a collection maps each element.

    1..oo |> Take(10) |> _^2 |> Sum
  • otherwise is now accepted as the wildcard case in match.

  • Named calls to a function assigned earlier in the same program no longer produce false static diagnostics.

  • Diagnostics inside reordered named calls now underline the argument that actually failed.

  • Inline function literals now accept named arguments. Example: ((x, y) => x - y)(y: 2, x: 10) returns 8.

Compilation​

  • Take-bounded infinite pipelines now compile to JavaScript safely. Unbounded infinite pipelines fail at compile time instead of throwing a runtime range error.
  • Fractional Take and Drop counts in compiled JavaScript now round like the interpreter.

Performance And Static Checking​

  • Canonicalizing or typing expressions that reference comprehension-bound names is much faster.
  • Static Epsil checking no longer mutates engine state. Inferred types, forward references and declarations from a check are discarded afterward.

Effects​

  • Added the state effect label for object creation and mutation. It is available in type strings and effect contracts, preparing for mutable object support.

0.106.1 2026-08-13​

Issues Resolved​

  • Epsil no longer silently reads the equivalence glyphs as the wrong equality tier: ≡, ≢, and ≣ are now rejected outright (unexpected-symbol). Previously a typed ≡ (IDENTICAL TO) silently weakened to == — arithmetic Equal, not the identity prover the glyph means in mathematics — ≣ quietly parsed as === (Same), and ≢ was a broken mapping that errored confusingly. Rejecting all three is deliberate (rather than assigning them tiers): the glyphs' bar counts cross the =-run lengths (≡ has three bars, ≣ four, while === has three characters), so a visual transliteration in either direction lands on a different comparison that silently answers a different question. The equality tiers in Epsil are ASCII-only — == (arithmetic, tolerant, may stay inert as a condition), === (Same, structural, total) — and the identity-prover tier is spelled as a call, IdenticallyEqual(a, b). LaTeX is unaffected: \equiv still parses as IdenticallyEqual.

  • .toString() no longer prints arithmetic equality in Same's spelling: Equal now serializes as == (was ===) and NotEqual as != (was !==). The old JS-flavored spellings predate Epsil, where === parses as the structural Same tier — so the string view printed an Equal in the exact spelling that reads back as the other operator. Same now also serializes in operator form (===) instead of function-call form; IdenticallyEqual stays in call form (the same no-equivalence-glyphs ruling as the Epsil entry above). Compiled JavaScript is unchanged (Equal still compiles to JS ===, which is correct there).

  • An exact integer beyond the safe-integer range now has one storage form too, completing the 0.106.0 normalization (the item-178(b) residual, filed by the Tycho team at their 0.106.0 adoption; witness 8e19 from their seed corpus, minimal witness 1e16). The 0.106.0 fix normalized a bigint WITHIN the safe range to machine storage but left an integer-valued machine number BEYOND it un-normalized, so ce.box(1e16) and the parse of its own serialization were isSame with byte-equal MathJSON yet hashed differently — the same isSame ⇒ equal hash break, past the boundary (2^53 itself was clean). The constructor now stores integer-valued machine numbers outside the safe range as bigints — the storage the parse route produces — so each side of 2^53 has one canonical form (machine below, bigint above); the conversion is exact for every integer-valued float64. Serialization follows the storage, so such literals now print in the compact exponent form (ce.box(1e16).toString() is "1e+16", no longer "10000000000000000"); MathJSON output is unchanged.

  • Assigning to a symbol auto-declared with type unknown now applies the same literal-type promotion as a fresh declaration, so the settled type no longer depends on whether a boxing auto-declared the symbol before the assignment (observed by the Tycho team's order-matrix probe at their 0.106.0 adoption). Boxing an expression can auto-declare a mentioned symbol at type unknown when its uses record no type evidence (a bare List sibling, a binder-body occurrence); assigning 5 to such a symbol then adopted the value's raw type (finite_integer), while the same assignment on a fresh symbol declares the promoted integer. The unknown-incumbent case now takes the declaration promotion, so box-first and assign-first orderings settle on the same type (integer). The 0.106.0 assignment-narrowing guards are unchanged: declared types are never touched, assignment-derived incumbents stay widen-only, and the D11 incompatible-guess adoption keeps the value's raw type.

New Features​

  • Protocol dispatcher effects are now derived from the registered conformers; a requirement's effect specifier is an opt-in ceiling. A protocol function requirement with a bare specifier no longer acts as a pure ceiling: implementations may carry any effects, and the dispatcher's effect set is the live union of the inferred effects of the registered conforming implementations — pure while every conformer is pure (calls through it stay cacheable and compile-eligible), widened the moment a mutating or drawing conformance registers, with dependent caches recomputing (a call boxed before the registration reports the new effects, and an already-inferred user function whose body calls the member re-derives). The dispatcher's serialized signature snapshots the union as of serialization. A requirement with a specifier (including the explicit pure) is a durable ceiling: a conformance whose implementation declares or whose body infers effects beyond it is rejected (protocol-signature-mismatch names the exceeded labels and points at the ceiling as the fix site). Registering a conformance that would widen a bare-requirement union past a standing declared effect contract — a function annotated pure whose body dispatches through the member — is itself rejected (conformance-widens-declared-contract, naming every violated dependent and the exceeding labels) and leaves the registry unchanged. Behavior change: an implementation declaring effects against a bare requirement was previously rejected; it is now accepted, by design (docs/TYPE_SYSTEM_ROADMAP.md Appendix B, "Changing a field is an effect" — the bare-means-pure ceiling was explicitly rejected there).

0.106.0 2026-08-13​

Breaking Changes​

  • The Error JSON shape gains an operand: argument-validation errors now carry the FAULTED OPERAND as a site operand, before the trace. An incompatible-type error minted while validating an argument reports which operand faulted:

    before f("str") ["Error", EC]
    Sqrt("s").evaluate ["Error", EC, ["ErrorTrace", …]]

    now f("str") ["Error", EC, "'str'"]
    Sqrt("s").evaluate ["Error", EC, "'s'", ["ErrorTrace", …]]

    ErrorTrace stays LAST and is head-identified, as always — code that finds the trace by scanning for the ErrorTrace head is unaffected. Code that reads the trace positionally as operand 2 now gets the faulted operand instead — a plausible-looking value rather than an error node, so that failure mode is a silently wrong read, not a crash: grep for positional Error operand reads before adopting. Rendered messages now name the site ("… at p"). Two contract repairs ride with the change: match's Error(c) patterns ignore the site the way they already ignore the trace (pattern arity decides — Error(c, w) binds the site), and static-diagnostic deduplication keys on a SITE-LESS description so one problem does not split into per-site diagnostics. Any stored/expected MathJSON containing Error nodes (snapshots, seed corpora) will change shape even where nothing is wrong — re-baseline those deliberately rather than reading the churn as a regression.

Epsil​

  • Named-argument calls. An Epsil call can pass arguments by parameter name: interest(principal: 1000, rate: 0.05). Named arguments may be given in any order, may follow positional arguments (never precede them), and are matched against the parameter names of the declaration the call resolves through — including overloaded and multi-clause functions (each overload is matched with its own parameter order; a named argument is also a branch selector — a clause that does not declare the written names is never chosen, so with clauses (z: 0) and (n: integer), f(n: 0) runs the general n clause while f(0) runs the base clause) and protocol members — bare (tag(prefix: "→", self: s)) and qualified (Tagged.tag(prefix: "→", self: s)) alike, dispatching on self wherever it is written. A call that uses any name is a complete call: omitted optional parameters are fine, but it never curries, and it cannot fill a variadic tail. New diagnostics: argument-name-unknown (with a "did you mean"), argument-order-invalid, argument-name-duplicate, argument-names-unavailable, and argument-optional-skipped, all covered by epsil doc <code>. Parameters without a declared name remain positional-only, as do unannotated function literals. Design record: docs/plans/2026-08-12-named-arguments-design.md (spec: docs/TYPE_SYSTEM_ROADMAP.md Appendix C, rulings C1–C6).

  • The VS Code extension shows what the engine makes of your file. Epsil: Show Representation (the {} button in the editor title bar) opens a read-only pane beside an Epsil file showing one of five representations: the MathJSON the parser produced (before canonicalization), the canonical form of each top-level statement, or the program as compiled by one of the engine's code-generation targets — JavaScript, Python (NumPy), or GLSL. The pane tracks the buffer as you type — unsaved and untitled buffers included — and nothing is evaluated, so rendering a view never runs the program. A program a target cannot compile (for now that includes any program defining a function, since DefineFunction has no lowering in any target) reports the compiler's explanation in the pane instead.

New Features​

  • A definition-level compile handler on Which is now honored (Tycho item 180). The per-operator compilation extension point excluded every control-flow head; Which is now the carve-out, because it has no binding structure — its operands are plain condition/value pairs a handler can compile through the callback it is given (unlike a Sum index or a Function parameter list, which remain non-overridable). The handler has the same contract as every other operator compile handler: it takes precedence over the built-in lowering, and returning undefined falls back to the stock Which compilation, complex-branch coercion and frame-protocol wrapping included. The override is per-engine — attach it to the engine's own definition, which keeps the stock evaluation semantics:

    const def = ce.lookupDefinition('Which');
    if (def && 'operator' in def) def.operator.compile = myWhichHandler;

    (Do not ce.declare('Which', {...}) for this: a re-declaration replaces the stock evaluate/canonical handlers, so the operator would compile with the custom handler but no longer evaluate correctly.)

  • Speculative parsing: ce.parse(latex, { speculative: true }) leaves no trace in the engine's type state (Tycho item 179). A normal parse has two persistent side effects: an undeclared symbol is auto-declared into the current scope, and a narrowing use moves the inferred type of an already-declared symbol (Fibonacci(u) narrows an inferred u: number to integer) — and the second one is not confined by parsing inside a child scope, since the write lands on the resolved definition wherever it lives. With speculative: true the parse runs in a transient scope that is discarded on the way out, and every mentioned symbol whose ambient type is inferred is shadowed in that scope with its current type, so narrowing refines the discarded shadow instead of the ambient symbol. Symbols with a declared (non-inferred) type, constants, and operators are not shadowed, so the string parses exactly as it would without the option, and the result's type and MathJSON are identical to a normal parse's. Intended for derive-style parses that only read the result (its type, structure, or serialization): the result's bindings refer to the discarded scope, so it should not be retained, evaluated, or compared against later expressions. Mutually exclusive with the scope option, which has the opposite contract (the scope receives the writes, to be read back).

Issues Resolved​

  • A qualified protocol member now works as a collection callback. Map([1, 2, 3], Negatable.negated) used to answer ["Missing", "Missing", "Missing"]: the callback arrives at canonicalization as the field expression Field(Negatable, "negated"), and the shorthand-lambda lift read it as a lambda body, turning the protocol name into the parameter — so every element was bound to the protocol-name slot and read as the base of a field access. A qualified protocol member is now recognized as a function value (the same contract that already kept Apply(InverseFunction(f), 2) from beta-reducing) and the callback dispatches per element. A protocol name shadowed by a valued binding is not a protocol reference and reports the ordinary not-a-function error instead of silently dispatching.

  • A protocol dispatcher call inside a shorthand lambda body no longer errors. Map([1, 2, 3], negated(_) * 10) reported incompatible-type number/unknown on the dispatcher call, while the identical shape through a plain declared function worked. Cause: at an undecided receiver, the requirement's Self-typed result substituted as a type reference named "unknown" — printing exactly like the primitive but defeating every gate that admits a primitive unknown operand as "defer and infer later". The reference is now reduced to the primitive, so the shorthand evaluates ([-10, -20, -30]) and an undecided receiver defers symbolically as before.

  • An assignment can now refine a use-inferred symbol type, so the inferred type no longer depends on whether the first use or the assignment came first. Previously the inference direction rules were strictly one-way: uses narrow, assignments widen. A symbol whose numeric use was seen before its assignment therefore settled on a wider type than the same symbol assigned first (v used in x·v then assigned 5 stayed number, while assign-first gave integer) — a divergence between orderings, not a wrong type under the rules. Now, when a symbol's current type came from USE inference (not from a previous assignment or a declaration), an assignment refines it to the assigned value's type, with three guards: the promoted type installs only when it is a subtype of the current one; a symbol whose latest type evidence is itself assignment-derived stays widen-only, so alternating assignments (v := 2.5 then v := 5) do not oscillate the type; and the union-adoption path for declared unions is unchanged. Declared types are never affected. If you probe type stability across orderings (box-first vs assign-first), expect both orderings to converge on the assignment's type now — a deliberate change, not drift.

  • A parsed integral now binds its integrand's free symbols the same way as boxing its canonical MathJSON does (the residual surface of Tycho item 178(a), distinct from the first-boxing divergence below — it was order-independent, and pre-declaring the symbol did not remove it). Integrate's canonical handler derived a shorthand integrand's Function literal by inferring a parameter for each free body symbol — declaring those parameters in the body scope, with the body's occurrences bound to them — and then swapped the parameter list for the integration variables, leaving the body's occurrences bound to the discarded parameters. A parsed \int_{-x}^{x} \cos(x)\,dn therefore carried a body x bound to a discarded parameter while the bounds' x bound the engine's definition, and compared isSame false against ce.box() of its own .json (and against the box route generally) with byte-equal MathJSON. The handler now passes the integration variables to canonicalFunctionLiteral as the intended parameter list, so the body canonicalizes with exactly those parameters declared — the same binding structure the explicit-Function route produces. A knock-on repair: a parenthesized explicit Function integrand (Delimiter-wrapped) now keeps its user-supplied parameters instead of having them overwritten by the integration variables. Values were never affected — evaluation resolved identically on both routes — and serialization is unchanged (zero snapshot churn).

  • Boxing the same MathJSON twice now produces isSame expressions — including the first-ever boxing of a shape (Tycho items 178(a) and 178(c), two surfaces of one defect). A not-yet-declared symbol occurring both inside a binder's scope and outside it (e.g. ["List", ["Integrate", ["Function", … y_r …], …], "y_r"]) made the first boxing bind the two occurrences differently: the occurrence inside the function body auto-declared into a scope local to that one canonicalization, while the sibling outside declared into the surrounding scope. Every later boxing found the surviving surrounding binding first and resolved both occurrences to it — so on a fresh engine ce.box(J).isSame(ce.box(J)) was false (and a box and a parse of the same integral disagreed), breaking the documented contract that isSame is safe as a dedup/matching key. The boxing machinery now detects the conflict — one construction both auto-declaring a name into a scope it created and then declaring the same name into a scope that outlives it — and rebuilds the construction once, so the first result is the same one every later boxing produces. No resolution rule changed and nothing new is declared: a function body's free symbols remain invisible to the caller, and capture through assigned document variables is untouched.

  • .json no longer reports a different operand order than the expression it serializes (Tycho item 178(d)). .json goes through the structural form, which re-sorted a commutative operator's operands — but a CANONICAL expression has already had its operands ordered, and that order is the one .ops, .toString(), isSame and hash all read, so re-sorting could only disagree with it. It did, because order() breaks a tie between two same-operator applications on leaf count, and a complex literal is one atom in canonical form (i) but a two-operand application in structural form (Complex(0, 1)): the operands of m(M_1, i, n)·m(M_2, n, j) counted 6-vs-4 structurally where they counted 4-vs-4 canonically, so the tie broke the other way and one object reported two different operand orders depending on which accessor you asked. A canonical expression now keeps its own order; a non-canonical one is still sorted, which is what puts it in structural form at all. Four arithmetic snapshots record the corrected order — in each, .json now matches .ops (1.1 + 2 + 5 + 5/7 + 7/9 + √2 + π serialized √2 before 1.1 while the tree held the reverse).

  • An exact integer now has one storage form, so it hashes the same however it was built (Tycho item 178(b)). ExactNumericValue accepted an integer as either a machine number or a bigint and kept whichever it was handed, but numberToString() formats the two differently — a bigint with more than five trailing zeros compacts to an exponent (1e+11) while the identical machine integer prints in full (100000000000) — and BoxedNumber.hash hashes that string. So ce.box(100000000000) and its own reparsed serialization were isSame with byte-equal MathJSON yet hashed differently, breaking the documented isSame ⇒ equal hash contract; any integer with more than five trailing zeros was affected, 1000000 included. The constructor now applies the same safe-integer normalization reducedRational() already used. Magnitudes beyond the safe-integer range are untouched, so bignum results keep their compact exponent form (√(1234567e+19) is unchanged).

0.105.0 2026-08-12​

Breaking Changes​

  • The prefix forall quantifier was replaced by a trailing where clause. A generic signature states its type variables after the signature instead of before it: forall T. (T) -> T is written (T) -> T where T, forall T: indexed_collection. (T) -> T is (T) -> T where T: indexed_collection, and forall T, U. (list<T>, (T) any -> U) -> list<U> is (list<T>, (T) any -> U) -> list<U> where T, U. The clause is always last — after the effect specifier and after the return type — and an omitted bound means any, so where T is shorthand for where T: any. Semantics, solving and bounds are unchanged. The prefix spelling is removed, not deprecated.

    Consequences:

    • where is now a reserved type name and forall is not: ce.declareType("where", …) is a reserved-type-name error, and ce.declareType("forall", …) now succeeds. A nominal type named where must be renamed.
    • A type string beginning with forall reports a targeted migration diagnostic rather than a cascading parse error.
    • .toString() emits the trailing shorthand ((T) -> T where T, never where T: any), so a type has one canonical string. Variable names round-trip as written.
    • Per-arm quantification of an overload set now needs parentheses, since where binds looser than &: ((list<T>) -> T where T) & ((set<T>) -> boolean where T). The unparenthesized form is rejected with a message naming the fix.

New Features​

Epsil: language​

  • Protocols. A protocol declares a set of functions and properties a type implements to conform; conformance drives dynamic dispatch:

    protocol Comparable {
    function compare(self, other: Self) -> "<" | "=" | ">"
    }
    type string is Comparable {
    function compare(self: Self, other: string) -> "<" | "=" | ">" {
    if (self < other) { "<" } else if (self > other) { ">" } else { "=" }
    }
    }
    compare("a", "b") // dispatches on the first argument -> "<"
    Comparable.compare("a", "b") // qualified form

    Includes readonly/readwrite properties with getter/setter implementations (person.name, and person.name = v as rebinding sugar over the immutable value model); protocol constraints in where clauses (function sort(xs: list<T>) -> list<T> where T is Comparable); conditional conformance (type list<T> is Comparable where T is Comparable { … }, recursive — list<list<string>> conforms when string does); qualified property disambiguation person.(Protocol.name); and the host API (ce.declareProtocol, ce.declareProtocolImplementation). Protocols are engine-global, statement re-runs replace them (host redeclaration throws), and conformance is monotone: a declared-but-unimplemented conformance warns at the end of each batch until fulfilled. Protocol names are not types — using one in type position steers you to a constrained type variable. A second implementation block for the same (type, protocol) pair within one batch is a protocol-implementation-duplicate error; the same statement re-run in a later batch still replaces (the notebook pattern).

  • Sum-type declaration sugar. One statement declares a tagged sum — the variants and the union together:

    type TrafficLight = red | green | yellow
    type node = lit(num: number) | plus(op1: node, op2: node) | times(op1: node, op2: node)
    type tree<T> = leaf | node(value: T, children: list<tree<T>>)

    This is exactly N nominal variant declarations plus one transparent type alias union. The sum's own name is usable in variant payloads without a type forward-reference marker, a generic sum distributes its type parameters to each variant by usage, and a variant name colliding with an existing type, a reserved word, or a builtin rejects the whole declaration atomically. Existing spellings are unchanged: type X = A | B over known types stays the opaque nominal, type alias X = A | B stays the transparent union.

Epsil: compilation​

  • Protocol dispatch compiles to JavaScript. Bare (compare(x, y)), qualified (Comparable.compare(x, y)) and property GET/SET (p.name, p.name = v) calls all compile: a statically resolved call becomes a direct call, a dynamic one a most-specific-first guard chain on the receiver. A receiver no conformance covers throws protocol-implementation-missing at runtime (the interpreter yields the corresponding error value). Anything unprovable — conditional conformances, host implementations, ambiguity-capable conformance sets — declines compilation and falls back to the interpreter; Python and GPU targets keep failing closed.

  • Sum types compile to JavaScript. For sugar-declared sums, match constructor patterns and variant constructors now compile (they previously failed closed). Per sum, the compiler picks a representation: when every variant has a distinct JavaScript shape (jnull | jbool(boolean) | jnum(number) | jstr(string) | jarr(list<…>)) values stay erased and dispatch uses typeof/Array.isArray; otherwise values carry their tag. Tagged values do not cross the compiled↔engine boundary (a unit whose result is a tagged sum declines); Python, GPU and interval targets keep failing closed.

Epsil: tooling​

  • Calls to a function your program defines are now checked statically. epsil check (and the VS Code extension) reported wrong calls to library functions but let wrong calls to your own functions through — so function foo(x: string, n: integer) { x } followed by foo("hello") checked clean and only failed at run time. Wrong arity, wrong argument type and extra arguments are now all reported before anything runs, with the signature note and the definition site. Multi-clause definitions accumulate clause by clause, and a definition inside a block stays scoped to that block. A generic definition (function id<T>(x: T) -> T) is still checked only when it runs.

  • Signature errors explain the signature. A call that does not match its callee used to report only what went wrong ("a required argument is missing"). The report now carries the callee's signature and the faulted position, plus a second annotated block pointing at the definition when the program defines it:

    error: Runtime error: a required argument is missing
    --> example.epsil:3:1
    |
    3 | foo("hello")
    | ^^^^^^^^^^^^
    = note: `foo` has signature `(x: string, n: integer) -> string`; argument 2
    (`n: integer`) was not supplied

    note: `foo` is defined here
    --> example.epsil:1:10
    |
    1 | function foo(x: string, n: integer) { x }
    | ^^^
    = note: `epsil doc missing` explains this error

    The same notes reach epsil check, the REPL, and — machine-readable, with the definition's location — --diagnostics json and check --json, where a diagnostic may now carry a notes array. In the VS Code extension the signature appears in the diagnostic's hover, and a note pointing at a second place in the file is published as related information.

  • The VS Code extension answers hovers. Hovering a library name shows its signature (or, for a symbol, its type and value) with its description — the same entry epsil doc <name> prints, so the editor and the CLI cannot drift. Hovering a name the file itself declares shows that declaration as written: a function's header without its body, a let/const's statement. A name inside a string or a comment is not a hover target.

Collections​

  • isEnumerableCollection now answers for arithmetic broadcasts. A broadcast node (x + [1,2], Sin(1..99)) used to answer undefined for the whole class; it now answers from its participants: true when they agree on a length, evaluation is draw-free, and every collection-typed participant is itself enumerable; false when a participant is definitively unwalkable — including an application of an unbound head (x + Total([1,2]) with Total undeclared); undefined when participant lengths disagree or an impure participant (RandomShuffle(xs) + 1) makes per-index coherence unpromisable. true is a delivery promise: each() and at() will serve the elements.

  • at() now works on impure broadcasts whose randomness is confined to a lifted scalar. ([1,2] + RandomInteger(1,10)).at(1) answered undefined; since such a broadcast distributes structurally without consuming randomness, at() now serves the same unevaluated element each() yields (1 + RandomInteger(1,10)), at any nesting depth. An impure participant (RandomShuffle(xs) + 1) still declines, since per-index reads could mix draw sets.

  • Operators can declare an elementCount handler (companion to canEnumerate) to report their length without evaluating.

Resolved Issues​

Epsil​

  • A static diagnostic no longer underlines a whole function definition. epsil check and the VS Code extension anchored every canonicalization-time error on the enclosing statement, so one wrong argument four lines inside a definition flagged the entire definition. IndexOf(digits, cs[i], 23) now underlines the 23, and the message quotes the failing call. An ambiguous match (two IndexOf calls in one statement) still falls back to the statement.

  • A statement that evaluates to an error now stops the enclosing block or loop. A non-final statement's value was discarded — including a refusal that is the statement's value, such as a mistyped write or a rejected indexed assignment — so execution continued past the fault. An Error result now propagates like Return: it becomes the block's value, and a loop stops iterating and surfaces it.

  • A protocol-property write in a loop body no longer silently no-ops. p.name = 10 * i was refused because the unevaluated right-hand side types wider than the property's declared type; the check now runs on the evaluated value. Genuinely mistyped writes are still refused.

  • A loop index over an integer range is now integer-typed. The index of for i in 1..3 was bound as number, so 10 * i typed finite_number and tripped integer-typed checks. The binding is now typed from the collection's element type (Range(1, 3) → integer, a float list → real), including through a type alias (type ints = list<integer>).

  • A fully-known argument now always dispatches a multi-clause function. With clauses a(t: integer) and a(t: real), a(0.3) stayed inert under evaluate() while compiled code correctly selected the real clause — two different answers for one input. Concrete values are now tested against each clause for both admission and refutation, so interpreted and compiled dispatch agree. Symbolic arguments keep their deliberate inertness.

  • A cyclic type alias no longer overflows the stack. A non-progressing structural alias cycle (type a = a | 0) crashed with RangeError when a concrete value was tested against it, from multi-clause dispatch and from typeAcceptsValue. Genuinely recursive values against recursive aliases still admit; a non-progressing back edge now answers definitively.

Compilation​

  • A function declaration contradicted by its own body no longer compiles to wrong results. With a declared (number) -> number but assigned a list-valued body (a := t ↦ [cos t, sin t]), compiled code trusted the declaration wherever the result was consumed as a scalar: Σᵢ a(h(i)) and a(u) + 1 returned strings, 2·a(u) and sin(a(u)) returned NaN, comparisons returned a wrong boolean, If took the wrong branch — all behind success: true — and the GPU targets emitted shader source that does not compile. When the body provably constructs a collection while the declared result type says scalar, compilation now fails closed with a message naming both sides of the contradiction, and the interpreter answers instead. Deliberately unchanged: a bare a(u) on JavaScript still compiles (it returns the correct array today), open declarations (-> unknown) keep compiling element-wise, and boolean / broadcastable<T> big-op bodies are untouched.

  • A Sum/Product whose body applies a list-valued user function now compiles correctly. With a(t) ≔ [cos t, sin t] under an open (unknown) -> unknown signature, Σ_i a(h(i)) compiled success: true and returned a JavaScript string. It now compiles to the element-wise broadcast fold and returns the correct array. On the non-JavaScript targets the same shape emitted shader source that does not even compile; GLSL, WGSL, Python and interval now fail closed with an actionable message.

  • A block-local introduced by bare assignment now compiles correctly. A multi-statement body binding a local with a plain assignment — t ↦ (w ≔ 2t; w + 1) — compiled behind success: true but ran to NaN (on GLSL the local was simply undeclared). Compiled results now match the interpreter.

  • Compiled writes to an outer binding now reach the same place reads look. A block assigning a name the enclosing scope already binds — including the library-predeclared single letters e, i, m, s — wrote a stray global and read undefined: (s ≔ 0; s + 1) compiled to NaN where the interpreter answers 1, and a function body accumulating into s through a loop returned undefined behind success: true. Where reads bake a folded value or a constant, so no coherent write target exists, compilation declines.

  • Complex-valued locals compile correctly. A loop-body local bound to a complex value miscompiled on JavaScript (NaN behind success: true; the interpreter answered 3√10), and a GPU function whose body merely contained a complex local while returning a real produced invalid shader source. Loop bodies now share the block compiler's complex inference, and the GPU return type is derived from the body's value rather than from interior locals.

  • The expression-only compilation routes now fail closed on statement bodies. compileToSource() (GPU and Python) and compileShader() promise a single expression, but a statement block escaped into them — compileToSource(Block(s ≔ x; Return s)) returned "s = x\nreturn return s" — behind a successful return. Both now throw, naming the expression-only contract and pointing statement bodies at the statement-capable sibling (compile() on GPU, compileFunction() on Python). Also gated per language semantics: an assignment body on WGSL and Python (still allowed on GLSL, where assignment is an expression), and a bare declaration body, which additionally dropped its initializer silently. The function-emission siblings decline the same shapes: Python compileFunction no longer emits def f(x): return s = x, and compileLambda catches single-line statement bodies. Expression bodies are byte-identical on every route.

  • A Return in a GPU function body now emits valid source or declines. A final-statement Return emitted return return … and a Return inside a conditional emitted return in expression position — neither is GLSL or WGSL — both behind success: true. Two gates now hold: a shape gate (a Return whose value shape disagrees with the function's signature declines, naming both shapes) and a placement scan on the finished body. Early Returns of matching shape in plain statement position compile unchanged, as does every JavaScript path.

  • Compiling an assignment to a non-variable target now fails closed. A sequence definition (L_0 := 5) inside a compiled body emitted the silent no-op _ = 5 behind success: true (and, in sloppy mode, wrote a stray global). Such an assignment now declines and falls back to the interpreter.

  • A nominal-typed argument no longer broadcasts at compiled call sites. With type bag = list<number> and function size(b: bag) -> number, compiled size(bag([1,2,3])) answered [42, 42, 42] where the interpreter answers 42. Nominal values are atomic, like tuples: the whole value binds. A broadcastable<bag> slot now also accepts a bag argument directly.

  • Typed locals now compile inside loop bodies. A let q: Person = … declared in a loop body rides a different path than a block local, so a protocol property GET/SET on it failed to compile — and an ordinary named-tuple field read (q.y) on a typed local failed on every path. Both now resolve, nested chains (q.inner.y) included.

  • Contradiction gates hardened. Multi-statement lambda bodies are judged by their final statement (a body ending in a list no longer slips past the declaration-contradiction gates), and -> boolean declarations over collection-constructing bodies now decline everywhere a condition or logical operand consumes them — an If no longer takes a branch off array truthiness.

LaTeX serialization and round-tripping​

  • Block expressions now round-trip. Four shapes changed value on a round trip and are fixed:

    • A single-statement block (Block(s ≔ 2)) serialized as bare s\coloneq2, which reparses as a plain assignment — the block-local leaked into the enclosing scope. It now serializes with a trailing semicolon (s\coloneq2;), and the parser no longer produces the vestigial Nothing statement a trailing ; used to add.
    • A multi-statement block with no assignment (Block(s+1, s+2)) serialized as s+1; s+2, which reparses as a 2-tuple. It now serializes as a one-column \begin{cases}…\end{cases} (core amsmath), and such an environment parses back as a Block. Real piecewise (Which) always serializes two columns and is byte-identical.
    • A block of bare equations (Block(x=1, y=2)) collided with the system-of-equations reading and came back as a List; it now serializes as explicit \operatorname{Block}(x=1, y=2).
    • A Block used as an argument — of any function call, a Loop body, or a comprehension body — emitted a bare ;-statement list, which binds looser than the argument comma and the for clause, so the block swallowed what followed and the value changed. Block operands are now fenced.
  • A two-element list no longer turns into an interval — in any set-operator position. Element(i, List(1,2)) ("i ranges over the two-point list") serialized as i\in\lbrack1, 2\rbrack, which reparses as Element(i, Interval(1,2)) ("i is any real between 1 and 2"). The same conversion fired on both operands of Union, Intersection, SetMinus and the Subset/Superset families, including nested. A 2-element-list operand is now spelled \operatorname{List}(1, 2) everywhere (membership, both sides of every set operator, and the big-op subscript), and the parser applies the interval reading only to operands actually written with brackets — an explicitly named List head is never overridden. Authored bracket spellings keep their interval meaning, including \mathopen\lbrack a,b\mathclose\rbrack and \mleft…\mright. Other list lengths are unchanged.

  • Miscellaneous parsing fixes. An authored trailing \mathrm{Nothing} statement survives parsing, a nested Block(Block(…)) operand no longer defeats the cases-environment fence, and SymmetricDifference reads and writes its operands with the same interval/list conventions as the other set operators.

Evaluation​

  • count no longer invents a length for reshaping operators. The broadcast count read participants' lengths without checking that the operator actually broadcasts, so Chunk([1,2,3], 2).count answered 3 (true count: 2), and a dozen other reshaping operators (BinCounts, Histogram, Tally, Shape, …) reported their operand's length. Sort, Ordering and RandomShuffle now report their source's length (with zero draws), Chunk(xs, k) reports k for a literal k, and operators whose length is not cheaply knowable answer undefined instead of a wrong number.

  • A parenthesized juxtaposition on a collection-valued symbol is a product regardless of the argument's type. With A a list and B a list, A(B) and A(t-B) canonicalized to function applications of a non-function — evaluating to Error("incompatible-type", …) — while A(m) with scalar m correctly multiplied. The decision now keys on the head: a head whose value is multiplicative (a number, matrix, list, collection, broadcastable, or numeric tuple) always reads as a product. Undeclared and function-typed heads still read as applications.

  • A non-multiplicative value head applied to a scalar is now an illegal application, not a product. With t := "hello", t(2) canonicalized to Multiply and the type error blamed multiplication; string-, set- and boolean-valued heads now take the application route, so the error blames the actual mistake.

  • A negated product no longer produces an unflattened Multiply. (b·-a)c built a node whose .json serialized flat (ordered abc) while isSame and hash saw the nesting, violating the documented isSame ⇒ equal hash contract; a negated rational coefficient with a Power factor hit the same problem. .json, isSame and hash agree again.

  • A PointList body is now differentiated componentwise. With a lambda stored as a symbol's value — p := (t) \mapsto \operatorname{PointList}(\sin t, \cos t, t) — p'(0.25) returned a sum of inert Apply(Derivative("PointList", …), …) terms, because the differentiator applied the generic chain rule to the PointList OPERATOR. It now differentiates each component and keeps the PointList head, so p'(0.25) is the point (0.9689…, -0.2474…, 1), matching what the same curve already gave when defined as g(t) \coloneq …. This holds for components that are themselves lists (PointList(t\cdot[1,2], t^2), which zips to a list of points): differentiation and the zip commute.

0.104.1 2026-08-11​

Issues Resolved​

  • A compiled big op whose bound is a function parameter no longer folds against a same-named library constant (Tycho item 176). Compiling a Sum/Product folds its bounds at compile time when both are constant, but that fold read the bound's value through the ENGINE symbol, ignoring that the name might be bound in the compilation context. A user function's parameter named after a library constant therefore produced silently wrong code behind success: true: in F(i) = \sum_{m=1}^{i} m the bound i resolved to the imaginary unit, whose real part is 0, so lower 1 > upper 0 read as an empty range and the entire body folded to the identity — const _fn_F = (i) => 0, against an interpreted 6. A parameter named e picked up Euler's number and summed two terms (3). The same shape emitted float _fn_F(float i) { return 0.0; } on the GPU targets. A bound that mentions any compile-bound name — a function parameter, an enclosing binder's index, a broadcast element — is now treated as symbolic and emitted as a loop over the parameter, matching the interpreter on every parameter name. This affected the javascript, GLSL, WGSL and interval-js targets; Python was already immune. A bound naming a constant that is NOT shadowed still folds as before.

  • The derivative of a tuple-valued function differentiates componentwise (Tycho item 174). A Tuple is a container, not a function of its elements, but it had no case in the differentiation rules, so it fell through to the generic chain rule and differentiated the Tuple operator: with g(t) = (\cos 2\pi t, \sin 2\pi t, t), both g'(t) and D(g(t), t) left inert Apply(Derivative("Tuple", 0, 1, 0), …) nodes. Tuple now takes the same elementwise branch List already had, preserving the container head and nesting — d/dt (fₓ, f_y, f_z) = (fₓ', f_y', f_z'), so g'(0.25) is (-2\pi, 0, 1). This closes the Frenet-frame idiom (F_0 = f'/|f'|, F_1 = F_0'/|F_0'|), which never closed before because f' stayed inert. Unregistered heads are unaffected: Point types unknown, so it stays an opaque application rather than acquiring invented componentwise semantics.

  • Equal/NotEqual against a list no longer overflows the stack when the other operand is opaque. ce.parse('A(t) = [t]').evaluate() — one line on a bare engine — threw RangeError: Maximum call stack size exceeded, as did q(2) = [1, 2] with q declared (number) -> unknown. These operators broadcast element-wise only in the list-vs-scalar case, and that decision was taken twice with two different rules: before evaluation the engine skips broadcasting when two or more operands are collections or possibly-collection typed, then defers to the evaluate handler — which counted only actual collections. A top-typed application is possibly-collection typed and stays so after evaluation, so the handler kept rebuilding the identical node. The two rules now agree. Such a comparison also stays inert instead of answering False: an opaque operand may still be that collection, so a structural mismatch is not something the engine can claim.

  • .count no longer outruns each() on a broadcast over an unbound head. An undeclared call binds vacuously and its result type lifts over a collection argument, so Total([1, 2]) types list<unknown^2> and looked countable — but nothing produces those elements and the walk yields none. count answered 2 for a collection that can never yield an element, and it propagated: x + Total([1, 2]) counted 2 while walking 0. An unbound head now reports undefined, the same honesty Add([1,2], [1,2,3]) already gets. Broadcasts over declared operators are unchanged.

  • Quartiles and InterquartileRange no longer throw on thin or symbolic data. Quartiles(2.5) — a single inexact datum — threw a TypeError (median of an empty quartile half), as did any symbolic datum reaching the numeric path (Quartiles(z) for an unassigned z). The empty median is now NaN, matching the machine-float path, and symbolic data leaves both operators inert instead of baking a (…, NaN, …) tuple that a later assignment contradicts: Quartiles([1, 2], z) stays symbolic and answers (1, 2, 3) once z := 3. A NaN literal datum still takes the documented absence path.

  • ListFrom/SetFrom/TupleFrom no longer wrap an unresolved collection operand as a scalar. For a valueless list-typed symbol xs, ListFrom(xs) answered ["xs"] — a one-element list containing the symbol — and ListFrom(Divisors(n)) similarly wrapped the unevaluated producer. A collection-typed operand that is not a collection in the current state is unresolved, not a datum: the conversions now stay inert (and report isEnumerableCollection: false without evaluating). Genuine scalars still contribute themselves: ListFrom(5, [1, 2]) is [5, 1, 2].

  • Dot's isEnumerableCollection no longer misreports a matrix product. The result type of Dot is the wide value, which the facet read as "not a collection" even when Dot(m1, m2) evaluates to a matrix. Dot now answers per shape: a provably scalar inner product is false, a matrix-ish product is decided by its operands (Quartiles also gained a decline-only canEnumerate now that its symbolic case is inert).

  • Stacked restrictions no longer depend on the order they were written. When(When(e, c1), c2) merges to When(e, And(c1, c2)), but the merged conjunction was built without going through canonical And, so it kept the authored operand order. Since .json serializes through the structural form, which DOES order a commutative operator's operands, the two spellings of one restriction set disagreed depending on which accessor you asked: x\{|z|<5\}\{0<y\} and x\{0<y\}\{|z|<5\} produced byte-identical MathJSON while isSame() answered false and their hashes differed, .json and .toString() of the SAME expression listed the conjuncts in opposite orders, and ce.box(expr.json) was not isSame to expr. Reported by Tycho as the 11 differs-json-equal rows of the item-153 corpus seed.

  • Indexed wrappers over an eager collection producer no longer read it as empty (design: docs/plans/2026-08-11-eager-collection-enumerability.md). An eager producer (Divisors, Characters, Eigenvalues, … — an operator whose collection exists only as its evaluate() result) could be walked through each() but not indexed through at(), so every wrapper that reads its source by index walked it as empty — wrong values on fully-ground input: Filter(Take(Divisors(12), 3), _ > 1) answered [] (now [2, 3]), Any(Reverse(Divisors(12)), _ > 1) answered False (now True), and Sum(Take(Divisors(12), 3)) stayed inert (now 6). at() now has the same materialize-on-demand fallback each() always had — pure sources only, and the evaluated form is computed once per instance, not once per index.

    With it, eager producers can now declare a canEnumerate handler — the decline test their evaluate handler already starts with — so isEnumerableCollection answers without evaluating: Divisors(n) for a free n reports false (wrappers over it stay inert instead of answering []/False/0), Divisors(12) reports true, and an operator whose success is not cheaply decidable (Solve) stays undefined and resolves by evaluating, as before. Adopted in this release — 45 of the 73 eager producers: Characters, GraphemeClusters, UnicodeScalars, Utf8, Utf16, StringSplit, Divisors, PrimeFactors, FactorInteger, IntegerDigits, Shape, Keys, Values, AbsArg, ComplexRoots, ExtendedGCD, PlusMinus, and (decline-only) Sort, Ordering, Unique, Tally, Eigenvalues, Eigenvectors, Eigen, SingularValues, SVD, LUDecomposition, QRDecomposition, Cross, MatrixMultiply, HadamardProduct, Flatten, Chunk, GroupBy, ListFrom, SetFrom, TupleFrom, DictionaryFrom, RecordFrom, BinCounts, Histogram, ContinuedFraction, RandomShuffle, RandomChoice, RandomSample (the impure trio answers from its domain operand's facets alone — consulting the predicate consumes no random draws). The rest are deliberate skips: solver-class operators stay in the undefined tier by design, and a handful (Pair, Vector, Tail, …) never exist as canonical leaves at all.

  • A type alias naming a union of nominal types now confers membership, so a sum type is usable through its own name. Given

    type lit = tuple<num: number>
    type plus = tuple<op1: type expr, op2: type expr>
    type alias expr = lit | plus

    every declaration was accepted and then every use failed: lit <: expr answered false, so passing a lit where an expr was expected was an incompatible-type error. Recursive shapes failed at the first nested construction — total(node(1, [node(2, [])])) reported expected list<tree<finite_integer>>, got list<node<finite_integer>^1> even though node<integer> <: tree<integer> held when asked directly.

    Three defects in the unfolding of a structural alias standing on the RIGHT of a subtype check: isSubtype short-circuited to a name comparison whenever both sides were references, so a reference on the left never reached the unfold at all (which is why the relation was asymmetric — expr <: lit held while lit <: expr did not); an applied reference SNAPSHOTTED its alias flag while delegating def to the declaration record, so a forward reference captured inside a variant's payload kept the placeholder's alias: false after a type alias fulfilled it; and the unfold compared against the alias's open body without substituting the application's arguments, so node<integer> <: tree<integer> asked node<integer> <: leaf | node<T> and failed on the type variable.

    Nominal opacity is unchanged: type X = A | B still declares a new opaque type that neither A nor B inhabits — only type alias X = A | B is a sum.

  • A type whose body is nothing now has a nullary constructor, so a payload-free variant can be built. type leaf = nothing minted (nothing) -> leaf, which no call could satisfy: nothing is the unit type and its sole inhabitant Nothing elides as an operand, so leaf() was a missing argument and leaf(Nothing) collapsed to the same call. The constructor is now () -> leaf, the shape an empty tuple body (type unit = tuple<>) already had.

  • A ground union arm binds its type variables to never even when the union is reached through an alias. Rule U gives an operand accepted by a ground arm no say over the variable, so it contributes never and a constraining operand wins outright — but the rule lives in the solver's union case, which a parameter still spelled as a forward-reference alias never reached. The union was hidden behind the reference, no arm contributed, and the variable fell through to the unknown default: plus(lit(5), lit(2)) typed plus<unknown> and was then rejected by an expr<number> parameter, where plus<never> is accepted.

0.104.0 2026-08-11​

Breaking Changes​

  • Types are now engine-global; type statements are top-level only. Type declarations (and the value constructors they mint) now live in one engine-level registry rather than the lexical scope chain: a declared type name means the same thing everywhere on the engine, for the engine's lifetime, and a duplicate name is an error. Consequently a type statement inside a do block, function body, if branch or loop body is now a hard error — type-declaration-not-top-level at parse time in Epsil, an invalid-type-declaration error value on the MathJSON ["DeclareType"] route — instead of declaring a block-local type; there is no hoisting, and the former type-shadow warning is gone (shadowing is impossible). The top-level statement-replace flow (re-running an edited notebook cell) is unchanged. This closes the class of bugs where a value of a block-local nominal type escaped its block and re-bound to a different same-named type (or typed as unknown), makes any serialize→reparse of a type sound anywhere in the engine, and is the prerequisite for protocols. Host note: ce.declareType() under a pushed scope now targets the engine registry — popScope() no longer removes a type. See docs/plans/2026-08-10-global-type-registry.md.

New Features​

  • Annotation-bound parameters in Epsil typed declarations (the "lambda lift"). A parameter name now binds wherever it appears: when a declaration's annotation is a literal function type with named parameters and the initializer is not a lambda, the annotation's names become the parameters of an automatically built lambda — const f : (x: number) -> number = x^2 + 2x - 1now means= (x) |-> x^2 + 2x + 1. When the initializer is an explicit lambda, the two parameter lists must agree positionally; a disagreement is the new parameter-name-mismatchdiagnostic, with a fixit that renames the annotation to the lambda's names. Alias annotations stay opaque (names bind only where they are written), only the outermost arrow of a nested signature lifts, and zero-parameter, generic, effectful, optional/variadic, and partially named signatures never lift. Seedocs/plans/2026-08-08-annotation-lambda-lift.md.

  • The ->-for-|-> typo is now diagnosed and recovered in Epsil. A KeyValuePair arrow whose left side is shaped like a parameter list — a typed parameter ((x: number) -> x^2), a parameter tuple ((x, y) -> x + y), an empty (), or a bare symbol right after ( or = (f = x -> x + 1) — is a function written with the wrong arrow: none of those shapes is a valid dictionary key. The parser reports the new mapsto-arrow-expected diagnostic with a fixit on the arrow and recovers as the intended lambda, so the program still runs. It also no longer emits a spurious unexpected-symbol ":" for typed parameters before a ->. Legitimate dictionary spellings ({one -> 1}, "a" -> 1, {->}) are untouched.

Issues Resolved​

  • D no longer swallows what follows it when serialized (Tycho item 166). The Leibniz spelling put the differentiand undelimited and trailing the fraction, where the parser binds it greedily — so any D with a right neighbour re-read as a different expression: ["Add", ["D", ["Subtract","x","d_t"], "y"], 1] serialized to \frac{\mathrm{d}}{\mathrm{d}y}x-d_{t}+1 and re-parsed as D(x - d_t + 1, y). The isolated case round-tripped only because that greed happened to re-absorb exactly what it emitted.

    Delimiting the body is not sufficient — the differentiand is parsed at addition precedence, so the parser consumes past a closing delimiter and \frac{d}{dy}(x-d_t)+1 still re-read as D((x-d_t)+1, y). A COMPOUND differentiand now folds into the numerator instead (\frac{\mathrm{d}(x-d_t)}{\mathrm{d}y}), a spelling the parser already accepts at every order and which is self-delimiting by construction.

    The parser's greed is deliberately unchanged, so documents relying on the current trailing binding are unaffected: a TIGHT differentiand (a symbol, a number, a function application, a power) serializes exactly as before.

  • .count answers for an un-evaluated arithmetic broadcast (Tycho item 167). [1,2,3]+1 reported no count even though its type says vector<finite_integer^3>, and 2(1..99)/99-1 reported none even though the Range inside it reports 99 — because the broadcasting arithmetic operators carry no collection handlers (broadcasting is a property of how they evaluate, not a collection operator), so count had nothing to delegate to. A caller wanting to prove finiteness BEFORE deciding whether to evaluate then had no way to do so short of the eager walk it was trying to avoid.

    count now reads the operands when no count handler is declared. That is exact because the length rule for a lifted operator is agreement, not zip-to-shortest (docs/BROADCAST-MODEL.md): a scalar operand is a lift and never participates, and participants of differing lengths are incompatible-dimensions rather than a shorter result — so mismatched or unknown-length participants report undefined rather than a guess. A declared count handler still owns its own answer, and isCollection/isFiniteCollection are deliberately unchanged: this answers the length question only.

  • toLatex() and .latex are total: formatting no longer throws (Tycho item 168). Serializing a lazy collection materializes it first, and materialization EVALUATES — so a comprehension over an unresolvable binding raised Condition must evaluate to "True" or "False" out of a formatting call, taking down whatever asked for it. An unresolvable binding is not a formatting error: the tree is perfectly printable, and prints identically when nothing is bound at all. The materialization is now guarded and falls through to that symbolic spelling. Successful output is unchanged — the guard only covers what previously threw.

    A CancellationError still propagates: deadlines are installed only by an enclosing ce.withTimeLimit() span, and a caller who set a budget must see it expire rather than receive a silently degraded spelling.

    Callers who want the un-materialized form by contract rather than by fallback should pass toLatex({ materialization: false }), which never evaluates. That is the supported opt-out and predates this fix.

  • A broadcastable<T> parameter now admits exactly what T admits (Tycho item 157(4), generalized). The filed case — (broadcastable<value>) accepting a function-typed argument that (value) rejects — was one instance of a wholesale hole: (broadcastable<number>) also accepted a string and a boolean.

    Neither the admission path nor isSubtype was at fault; both were already correct (isSubtype(function, broadcastable<value>) is false). The gap was in provablyDisjoint: a broadcastable<T> spans two category buckets (T and indexed_collection<T>), so the category test found no bucket and fell through to the conservative "may overlap". That is safe for the predicate in isolation, but argument checking only KEEPS a type error when every candidate parameter is provably disjoint from the operand — an un-rejection that exists so a bare symbol with a provisional type is not eagerly refused. A parameter kind that is never provably disjoint therefore admitted every operand with a free variable. provablyDisjoint now distributes over broadcastable<T> exactly as over a union, using the same T | indexed_collection<T> expansion isSubtype uses.

    What broadcastable is for is unaffected: scalar and collection arguments are admitted as before, and broadcastable<value> still accepts string and boolean because value does.

  • Divide no longer drops the tuple type of a PointList numerator (Tycho item 165). canonicalDivide scales a tuple / scalar component-wise only when the numerator's components are ACCESSIBLE — a Tuple/Pair/Triple/ Single head. Any other tuple-TYPED numerator, notably the PointList head importers emit, stays an inert Divide, and the type handler had no tuple branch, so that inert form collapsed to number. PointList(x, y) / n typed number while PointList(x, y) · n and (x, y) / n both kept the tuple — Divide was the sole outlier among the arithmetic operators (Multiply, Add, Subtract and Negate were already correct).

    The collapse cascaded: a list of such quotients typed vector<n> — a list of NUMBERS — so PointX/PointY over it took the element-INDEX reading instead of the elementwise one, and a mixed p + p/n failed outright with incompatible-type. Downstream, a consumer proving collection-ness by type saw a scalar and failed closed, so dependent rows never registered and a plot silently drew nothing (while getting faster, because the work was skipped).

    The fix mirrors the Multiply handler, which also stays inert on this input yet reports the tuple type. It is type-only: no canonical form changes, so the PointList head — which carries a consumer compile contract — is preserved, and the accessor narrowing that surfaced the bug is untouched (PointX over a genuinely flat numeric list remains the element-index read).

Improvements​

  • Declared-type mismatches on let/const are now caught statically. The Epsil static pass (epsil check, editor diagnostics, executeEpsil's pre-run check) never evaluates, and the declared-type check lived only in Declare's evaluate path — so let s: string = 42 produced no diagnostic until the program ran, and none at all in the editor. The pass now checks each annotated declaration's initializer against its annotation without evaluating, reporting only provable mismatches so there are no false positives: disjoint types (let s: string = 42, and the unnamed-signature near-miss const f : (number) -> number = x^2 + 1, which carries the same explanation as the runtime error), plus closed literals that fail the full covariant check (let n: integer = 1.5). Unknown-typed initializers, overlapping types, and cross-statement bindings remain the run phase's job — incomplete rather than unsound.

  • The declared-function-type near-miss now explains itself. const f : (number) -> number = x^2 + 1 used to fail with a bare incompatible-type (expected "(number) -> number", got "finite_number") — cryptic enough that readers mentally auto-insert the missing parameter name and cannot spot the mistake. When a declared type is a function signature and the initializer is not a function, the error (on both the host ce.declare/ce.assign throw and the Assign/Declare error-value routes) now spells out the near-miss: a signature's parameters bind only when they are named, so nothing in (number) -> number binds and x^2 + 1 is an expression in the unknown x, not a function of x — and names the exact rewrite when the initializer's unknowns pair off with the unnamed parameters ("(x: number) -> number", or "(x) |-> x^2 + 1").

  • New expr.isEnumerableCollection: telling an empty collection from one that cannot be walked. each() yields nothing for two unrelated reasons — the collection is empty, or its elements have no computable value (Range(a, b) over free variables, Linspace(a, 1, 3), Repeat(3, n), a declared-but-unassigned symbol, or any wrapper over one of those). The walk alone cannot tell them apart, and the facets that could (count, isEmptyCollection) are undefined in both cases, so the library decided by EVALUATING the source. The new predicate answers structurally, without evaluating: true (an empty walk means empty), false (an empty walk means nothing), or undefined for the one undecidable case — an eager collection operator such as Characters(s), which has no collection handlers until it is evaluated. Custom collection operators declare it with the optional isEnumerable collection handler; a wrapper propagates it from its source (default: true).

    This closes a family of wrong answers over wrapped unknown sources: for a valueless xs, Filter(Take(xs, 2), p) answered [], Any(Reverse(xs), p) False, CountIf(Take(xs, 2), p) 0, Position(Take(xs, 2), p) [], Ordering(Take(xs, 2)) [], Count(Take(xs, 2), 3) 0, and Length(Filter(Take(xs, 2), p)) 0 — answers a later xs := [1, 5] contradicts. All of them now stay inert, as Length, Total, Sort and Map always did over the same source spelled directly. The same applies to a symbolic-bound source: Filter(Range(a, b), p) no longer reports itself empty, and the multi-source Map now consults every source rather than only the first.

    One shape is knowingly left: an eager collection leaf under a wrapper (Filter(Take(Characters(s), 2), p) for a valueless s) still reads as empty, because an eager operator has no collection handlers to answer from and the wrapper's evaluated form is still a wrapper. Tracked in ROADMAP.md; the fix is to give those operators lazy collection handlers.

  • Engine construction is ~3× faster, and the gradual registration accretion is recovered (ROADMAP P-BOX, residual half). Every new ComputeEngine() re-parsed the standard library's type strings from scratch — 1,953 parseType() calls per construction, all resolver-aware and therefore ineligible for the resolver-less memo cache, despite only ~308 distinct strings. As the library grew each release, construction and fresh-engine registration accreted a few percent per release. parseType() now uses two-step resolution: the cached resolver-less parse runs first — sound because a user type name can never shadow built-in type syntax — and only strings that actually name a user-declared type (or use the type X forward-reference spelling, which the parser now tracks via sawForwardRef, since it parses resolver-less into an unresolved placeholder and registers a forward reference as a resolver side effect) fall through to the uncached resolver-aware parse. Full parses per construction drop from 1,953 to 343 on the first engine and 48 on subsequent ones; a 120-function registration drops from 2,278 to 0. Construction: ~2.7 ms vs 7.6–8.2 ms for all published 0.10x versions; re-registration is now at or below the 0.100.1 baseline.

0.103.3 2026-08-10​

Issues Resolved​

  • Indexed-then-accessed chains compose again (Tycho item 164): the 0.103.2 narrowing of PointX/PointY/PointZ to (collection | tuple) -> any rejected At's optional result — an in-range unprovable access types missing | tuple<…>, so every S[n].x chain errored incompatible-type at canonicalization. The accessors (and First/Second/Third/Last and Distance, which had the same composition failure) now declare a missingBehavior, admitting the missing arm through strip-before-validate (§3.B of the missing-value design) while keeping the narrowed parameter evidence. At run time an absent point's coordinate is NaN (numeric-slot marker), an absent element access propagates Missing (mirroring At), and Distance of an absent point is NaN.

Improvements​

  • Wildcard dimensions no longer display as negative lengths: a list type with open (sentinel -1) dimensions and a non-numeric element printed as list<T^(-1x-1)>, which neither parses back nor means anything. The serializer now expresses open rank by nesting (list<list<T>>, list<list<T>^2> for a bounded outer dimension), which round-trips.

  • Deadline cancellations during canonicalization name their budget: the error canonicalizing console line printed a bare Timeout exceeded, indistinguishable from an engine-imposed deadline (no such deadline exists — deadlines are only ever installed by an enclosing ce.withTimeLimit() span). The line now appends the expiring span's owner label and span chain (Tycho item 163).

  • The 0.103.0 per-box slowdown is fixed (ROADMAP P-BOX): generic boxing had regressed ~1.25× in 0.103.0 through an interaction between the R-D5 ground-display cache — which runs on every symbol .type read and is keyed on BoxedType identity — and type inference, which rewrote an already-inferred symbol's type with a freshly allocated BoxedType on every use, defeating that cache twice per boxing call and tripling GC pressure. Three changes, each independently useful:

    • primitive types bypass the display-projection cache entirely (a primitive carries no callback<S>, so the projection is the identity);
    • a re-inference that lands on the type already recorded no longer writes — which also stops the spurious per-use _writeVersion bump on value definitions and the engine-wide _semanticVersion/_worldVersion bumps on operator definitions;
    • ce.type() now interns primitive type names per engine, so ce.type('number') is identity-stable.

    The box-microloop canary in benchmarks/effects-registration.ts returns to its 0.102.0 baseline (0.0091 vs 0.0105–0.0108 ms/iter regressed).

0.103.2 2026-08-10​

Breaking Changes​

  • The prose ellipsis no longer binds inside an inline /. 1/2...5 is Range(1/2, 5), where it was Divide(1, Range(2, 5)). Division was the one operator that captured the ellipsis into its right operand: +, *, ^, the implicit product and \frac (a primary) all took the whole preceding element as the range anchor, so the SAME expression parsed two ways depending on how its division was spelled — [1+\frac{8}{d}...5] anchored on 1+8/d, while [1+8/d...5] anchored on d alone and produced 1 + 8/Range(d, 5). [1/2, 1/3...0] was [1/2, 1/Range(3, 0)].

    A PARENTHESIZED range still divides — 1/(2...5) is unchanged, and pinned. That form was the stated reason the ellipsis sat above the / right-operand floor, but parentheses are not reachable by precedence, so the constraint cost the anchor without buying anything.

  • A two-sample range fuses over a SYMBOLIC first anchor. [1+\frac{4}{d}, 1+\frac{8}{d}...5] with d := 500 is a 500-element Range, where it was a 2-element List — the gate required the first anchor to be numerically KNOWN, so any anchor built from a document constant fell through. Nothing about the symbol's value is consulted (the parser cannot read one, and a value read there would be frozen into the document's parse): both anchors and the step are emitted as expressions, so re-assigning the constant re-evaluates the range.

    Two consequences for shapes that used to stay a placeholder List. A symbolic OFFSET is now a step — [m+n, m+n+x, ..., m+n+60] steps by x, matching the symbolic steps this pass has emitted over a numeric anchor since 0.100.0. And where the second anchor is the first with terms appended, the step is those terms rather than s1 - s0, so it reads x instead of m - m + n - n + x.

    What still stays a List: bare symbols ([x_1, x_2, ..., x_n]), a function application at ANY depth ([f(1), f(2), ...] and [1+f(1), 1+f(2), ...] — previously this fell out of the numeric reduction's recursion and is now a stated rule), and an anchor pair that is not one family, i.e. where the first anchor mentions a symbol the second does not ([m+n, m+k+15, ..., m+n+60], whose "step" k+15-n is fabricated from two unrelated bases).

Issues Resolved​

  • A declared signature's PARAMETER types now reach the function it is assigned to. Reconciliation ascribed a declaration's result onto the stored literal but dropped its parameters, so declare L : (list<real>) -> list<real> with L(a) := a + 1 stored a literal typing (unknown) -> list<real> and the parameter fell back to usage inference, which read the body as scalar arithmetic. The two halves of a compiled call then disagreed: the call site consulted the DECLARED type and passed the list whole, while the emitted body was scalar code, so L([3, 4]) ran [3,4] + 1 and returned the string "3,41" behind success: true — including under realOnly: true, which promises a number. L(a) := |a| degraded to NaN the same way, making the declared spelling worse than the undeclared one.

    Two knock-on gains: Length(a), Map(a, …) and Sum(Map(a, …)) over a declared list parameter now compile on the JavaScript target, where they previously declined on every target; and a declared list parameter no longer needs the vector<n> spelling to compile at all.

    Scope is deliberately narrow. Only NON-SCALAR parameters are ascribed — that is precisely the disagreement, since only a parameter that binds its argument whole can be handed a value scalar-compiled code cannot read. A scalar parameter is left alone (ascribing one re-canonicalizes the body against a narrower type and changed how a tuple argument broadcasts), as is broadcastable<T>, which is a declaration contract with its own enforcement, and any parameter the author annotated or whose type mentions a quantified variable. The multi-clause route is unchanged: a clause is checked as an arm of the declared signature, and stamping the general parameter types onto it would make that check vacuous.

  • A function parameter passed to PointX/PointY/PointZ (or the .x/ .y/.z spelling) now infers as non-scalar, so a list argument is applied instead of broadcast. g(a) := PointX(a) answered [g(3), g(4)] for g([3,4]) — per-element nonsense that compiled to [null, null] behind success: true — where it now answers 3. Parameter evidence comes from the callee's declared parameter type, and these accessors declared (any) -> any, which contributes none; they now declare (collection | tuple) -> any. That is what they already accepted: a scalar or a string was rejected at run time before, so the rejection has only moved earlier, to canonicalization. Distance was narrowed the same way, and for the same reason, in 0.100.1.

    Norm is deliberately unchanged: Norm(-5) is 5, so a signature excluding scalars would be false and its parameter stays unlifted.

  • A point accessor over a flat point compiles on the shader targets. PointX([3, 4]) declined with "a list of points has no GPU lowering" while PointX((3, 4)) compiled to vec2(3.0, 4.0).x — the GPU lowering read every indexed collection as a list of points, so the flat list<number> spelling a data import produces could not reach a shader. A numeric list of width 2–4 IS a vecN there, so it now swizzles exactly like the tuple spelling, and gains that spelling's static arity check (PointZ([1, 2]) is a typed error rather than a decline). A list too wide for a vecN lowers to an array and still declines, as does a genuine list of points, in either spelling.

  • A point accessor over a flat list types the scalar it returns. PointX([3, 4]) is the coordinate 3 but typed vector<2>: the result type read broadcast-vs-index off the static type alone, while evaluation peeks the first element to decide, so the two disagreed on every indexed collection of scalars. Values were already correct — the JS target's runtime shape dispatch absorbed the mismatch — but it emitted a needless broadcast wrapper, and a non-indexed source (a Set of points) had the mirror-image defect, typing an element access while returning a list.

  • An indexed Sum/Product no longer discards its indexing set when the body is a collection. \sum_{k=0}^{2}[k, 2] canonicalized to Reduce([k, 2], Add, 0) and answered k + 2 — the range gone and the bound index leaking out free. The collection-reduce rewrite is the meaning of the NO-INDEX form (Sum([1, 2, 3]) is still 6); with an indexing set the operator iterates and accumulates element-wise, so that expression is now [3, 6], matching the list-valued CALL spelling (\sum_{k=0}^{2}a(k)), which always took the index loop. Compiled and interpreted results agree.

  • A Sum/Product body that cannot accumulate numerically now fails the compile instead of emitting a bare +. \sum_{i=0}^{2}\text{ab} compiled to ("ab") + ("ab") + ("ab") and ran to the string "ababab" behind success: true — including under realOnly: true, whose overload is typed to return a number. Add/Multiply reject such an operand at box time, but a big-op body stays raw, so nothing re-ran that check before the emitters saw it. Both lowerings (unrolled and looped), Product, and every target now decline. The decline needs positive evidence, so wide and unknown bodies are unaffected; boolean bodies keep compiling (the counting idiom \sum_k (x_k > 0), where + over booleans is numerically faithful), as do broadcastable<T> bodies, which are routinely scalar at run time.

  • A Range step built over an assigned symbol no longer freezes its value. [2a, 3a...9a] with a := 2 canonicalized to Range(2a, 9a, 2) — the step folded to a literal while the bounds stayed symbolic. Re-assigning a := 3 then produced an 11-element range of spacing 2 instead of the 8-element one of spacing 3: the start moved with a and the step did not, so the result was silently wrong rather than merely stale. Range's canonical handler folds its step operand so a decimal progression stays exact (1.016 - 1.008 must become 0.008, not 0.008000000000000007); it now folds only when every symbol in the step is a CONSTANT. Pi/4 folds as before, and a step over a document variable stays unevaluated and re-reads its binding per use (Range has supported symbolic bounds and steps since 0.100.0).

  • A nested Map that closes over the enclosing lambda's parameter now substitutes it. Min(Map([1,2], k ↦ Max(Map([1,3], j ↦ j·k)))) evaluated to Min(Max(k, 3k), Max(k, 3k)) — the inner map reduced, but k stayed free — where it is 3; with a +x in the outer body the result went non-finite instead of 3+x.

    The drain-time Map fusion bypasses makeLambda and evaluates the level itself, so it pushes a scope to resolve operands the lambda closed over. It pushed the mapping function's BODY scope; the chain those operands must resolve in starts one level OUT. Canonicalizing a nested literal auto-declares the free names of its body — including one the enclosing lambda binds — into that body scope, VALUELESS, so pushing it let the valueless shadow win over the binding that holds the value. The element then came back symbolic, and the outer application's binding-keyed substitution correctly declined to touch it, because it genuinely is a different binding. The drain now pushes the parent, read at drain time (a body scope is re-parented for the duration of each call, so a parent captured when the level was lowered would be a stale link into a frame that has since returned). The general route never had the bug: makeLambda pushes the call's fresh scope and reaches the closure chain through its parent, so a body scope is not in its lookup path for a non-parameter name either.

0.103.1 2026-08-10​

Breaking Changes​

  • A point inner product is now typed as the sum it is, instead of a flat number. Dot((1, 2), (3, 4)) reports finite_integer — exactly what 1·3 + 2·4 reports — where it previously reported number for every provably numeric pair. The component types carry through: real components give finite_real, a complex one gives a complex result. The answer is taken from the arithmetic handlers rather than restated, so it cannot drift from the written-out form.

    The old claim was not merely loose. number includes complex, so a point inner product was rejected by every real-declared slot in the library: Hypot(Dot(p, p), Dot(q, q)) — real by construction — reported incompatible-type('real', 'number'), as did Degrees, Haversine and PrimePi over the same operand. Those compose now, and a genuinely complex inner product is still refused there, loudly.

    Nothing else about Dot moves: an operand whose components cannot be read (a symbol declared tuple<number, number> or vector<3>) keeps the wide number; a tuple that is not provably a fixed numeric point keeps value (the rule against claiming a numeric result on retractable evidence); unequal fixed lengths still report incompatible-dimensions; and the matrix product is still value. Update any pin asserting the literal string number on a fixed numeric Dot.

  • A norm and a distance are now typed as the reals they are. Norm((3, 4)), Abs((3, 4)), Norm([3, 4]) and Distance((0, 0), (3, 4)) report finite_real where they reported number, and real when a component's finiteness is not provable. A norm is √(Σ|xᵢ|²), which is real whatever the components are — ‖(3+4i, 0)‖ is 5 — so the number claim (which admits complex) was refused by the same real-declared slots as the Dot claim above: Hypot(‖p‖, ‖q‖) did not typecheck.

    The demotions follow the convention Abs already uses for the same question one operand down: a provably NaN component gives number (only a literal proves NaN, so a merely-unknown component does not demote), and so does a provably non-finite one. No narrower tier than finite_real is claimed — unlike |·| of a scalar, a norm does not preserve the integer or rational tier, since ‖(1, 1)‖ is √2.

    Unchanged: a point whose component carries a collection still reports list<number> (it zips into one norm per element), a list of points still broadcasts to list<number>, Distance over an undecidable operand still reports number | list<number>, and an operand with no readable components (a symbol declared tuple<number, number>, a matrix, whose elements are rows) keeps the wide number.

Issues Resolved​

  • A point whose component is itself an inner product now compiles on the GPU targets. Dot((Dot((x, y, z), (1, 2, 3)), 0), (1, 2)) — a 2-D point product one of whose components is a 3-D one, the shape a gradient-noise field has — emitted correct GLSL and was then rejected by the shape gate that had just produced it: dot(vec2(dot(vec3(x, y, z), vec3(1.0, 2.0, 3.0)), 0.0), vec2(1.0, 2.0)) declined with "no room for the aggregate vec3 value standing in each slot". The gate scanned a vector constructor's argument text for an aggregate constructor anywhere in it, so it could not tell a vec3 standing in a slot from one consumed by an enclosing dot(). What stands in a slot is now judged after the scalar-reducing builtins (dot, length, distance, determinant, any, all — each returns a scalar whatever its argument shapes) are taken out of the source. The composition nests to any depth, works with the reduced component under arithmetic (vec2(dot(…) + 1.0, 0.0)), and holds on both GLSL and WGSL. An aggregate genuinely standing in a slot — Hypot over two point operands, emitting length(vec2(vec2(x, y), vec2(1.0, 2.0))) — still fails closed with the same diagnostic.

0.103.0 2026-08-09​

Breaking Changes​

  • A collection operator whose source has no value now stays inert, instead of answering as if the source were empty. Filter and TakeWhile answered an empty collection, Find answered Nothing, IndexWhere 0, Any False and All True when handed a source they could not see into — a symbol declared but never assigned, an undeclared symbol, or an application of an unknown operator. Those answers were not conservative: with xs declared list<integer> and unassigned, Any(xs, x ↦ x > 2) answered False, and assigning xs := [1, 5] afterwards makes the same expression True. The walk that produced them cannot tell "this collection is empty" from "there was nothing here to walk". Every one of these now behaves the way Length, Total, Sort, Map, CountIf and Position always have on the same input, and answers normally as soon as the source has a value. A genuinely empty collection still gets the definite answer.

    This covers the source operand itself. A WRAPPER around a valueless source — Filter(Take(xs, 2), p), Any(Reverse(xs), p) — still answers as if the collection were empty, because the wrapper does have collection handlers while its own walk yields nothing. That case is unchanged by this release and tracked in ROADMAP.md; it needs an O(1) propagating enumerability facet on the collection handlers.

  • A parameterless operand at a callback slot is now rejected across the whole collection family. Map(xs, 5) answered [5, 5, 5] and Any(xs, 5) carried a constant thunk, while the identical Sort(xs, 5) and CountIf(xs, 5) reported incompatible-type function/finite_integer. The split was an artifact: the lazy operators routed their operand through the shorthand path, which LIFTS a value with no wildcard and no free unknown into the constant () ↦ 5, while the eager ones validated against the declared slot. All of them now report the declared slot's error — for a plain value, a string, and a symbol whose declared type is provably not a function (Map(xs, k) with k := 5). What a function slot admits is unchanged: named operators, function-typed symbols, wildcard shorthands (Map(xs, _)), free-unknown shorthands (Map(xs, q + 1)), an explicitly written nullary literal (["Function", 42]), and any symbol whose type is not yet known — the forward reference — all still pass. Apply(3, 5) is unaffected: it is not a callback slot, and applying a constant is its documented shorthand.

  • The Cortex language has been renamed Epsil. The experimental scripting language previously called Cortex is now Epsil, and every public surface follows: the CLI binary is epsil (was cortex), the conventional source file extension is .epsil (was .cortex / .cx), the package subpath is @cortex-js/compute-engine/epsil (was …/cortex), and the API entry points are parseEpsil(), serializeEpsil() and executeEpsil() (with the corresponding ExecuteEpsilOptions/ExecuteEpsilResult types). The npm package name and @cortex-js scope are unchanged. There are no compatibility aliases: update imports and scripts to the new names. A ./cli package export exposes the CLI entry point so the standalone epsil launcher package (and other tools) can forward to it.

  • The structural: true boolean is no longer part of ce.function()'s typed signature — use { form: 'structural' }. The form option has been the documented spelling for the creation modes since the structural tier was introduced, and ce.expr(), ce.box() and ce.parse() had already dropped the boolean from their public types; ce.function() was the last one still carrying it, which made the surface inconsistent and advertised a spelling the guide tells you not to use. The boolean is still accepted at runtime — optionsToInternal() maps { structural: true } and { form: 'structural' } to the identical internal form — so only code type-checked against ComputeEngine/IComputeEngine is affected, and the fix is a rename at the call site. { canonical: false } (equivalently { form: 'raw' }) is in the same position and was already untyped.

  • Cortex: = is now positional — it assigns only as a whole statement, and compares everywhere else. := always assigns and == always compares; a bare = means Assign when it is the top-level operator of a statement whose left side is a binding target (a name, or a field/index path rooted at one), and Equal in every other position. The canonical trap simply works: Solve(x^2 = 4, x) is the equation and returns [2, -2], where it used to assign and report no solutions. So do if a = true { … }, while x = 5 { } and [a = 1], each of which previously assigned silently — the C footgun no longer exists in Cortex.

    The decision is purely syntactic: it never depends on evaluating a value, on scope, or on which definitions are installed. As a comparison = binds at the relational tier, so if x = 5 && y groups as (x = 5) && y; as an assignment it binds loosest and takes the whole right-hand side.

    Two consequences. A statement whose left side is not a binding target now compares — x^2 = 4 on its own line is the equation, not an assignment to a power. And a = b = 5 would assign a the boolean b == 5, which is never what a chained assignment means, so it reports chained-assignment; write a := b := 5 to chain, or a = (b = 5) if the comparison was meant.

    := is unconditional, so it still reaches a condition where a bare = no longer can. if flag := true { … } assigns and then uses the assigned value as the test — with no type error to catch it — so it now reports assign-in-condition, a warning (the spelling is deliberate). It fires only where a value is consumed as a boolean, not for f(a := 1).

    The assign-in-argument diagnostic is removed: f(x = 4) is now an ordinary comparison, so there is nothing left to diagnose. Serialized output always uses the explicit := and ==, never a bare =, so a round-trip is exact regardless of position — which means the formatter rewrites an authored = to := where it assigns.

New Features​

  • Dot accepts numeric Tuple/PointList operands. Dot((1, 2), (3, 4)) — and the equivalent PointList spelling — is now valid, typed number, and evaluates to the inner product, matching the already-supported List vectors; mixed tuple · list products work too. Dot is the sanctioned representation of a point inner product (tuple · tuple has no implicit product and remains an error), so the explicit operator now accepts the operands the implicit one rejects. Point operands lower to the native dot() builtin on the GLSL/WGSL compile targets (with the usual operand-shape fail-closed guards: matching widths, vec2–vec4 only) and to np.dot on Python. Unequal fixed lengths report incompatible-dimensions; a tuple operand that is not provably a fixed numeric point (a symbolic point, a point list with a collection component) stays symbolic.

  • Inline callback lambdas now infer their parameter type from the call site. An unannotated function literal passed directly as an argument is re-derived with its parameter typed, exactly as if you had annotated it by hand, in two situations: when the callee — user-defined functions included — declares a concrete function-typed parameter (function apply2(f: (number) -> number, x) { f(x) } types the n of apply2(n |-> n + 1, 3) as number), and when the callback of Map or Filter is applied to a collection whose element type is a provable composite (a tuple or a nested collection). The headline win: point-list predicates now compile without annotation — Filter(points, pt |-> pt == (0, 0)) with points: list<tuple<number, number>> lowers to the same element-wise code as the hand-annotated spelling, and the literal's signature reflects the element type everywhere it is read.

    The inference is per-application and behaves exactly like a hand-written annotation, loud type errors included. It never touches a shared callback (a lambda bound to a name keeps its own typing at every call site), never overrides an explicit annotation, and does not fire for union element types of Map/Filter — heterogeneous "errors are values" programs keep their per-element behavior, and the vectorization default for evidence-free lambdas is unchanged. Optional and variadic callback positions are covered. The whole mechanism is driven by the callee's declared signature, and a generic function you declare yourself gets the same inference — provided the callback slot is spelled callback<(T) -> …> rather than as a plain generic arrow (T) -> …. The callback<…> spelling is what asks for the inference, and it is also the permissive one: the slot still admits any function, exactly as a bare function parameter does, and a callback that does not match is applied and judged per element as before. A plain generic arrow does neither — it declines the inference and rejects a callback whose declared type does not fit the slot. A callback that combines several collections at once (Map(xs, ys, f)) is admitted and applied dynamically, as it always was, rather than annotated.

  • Annotated lambda parameters no longer disable the Map fast paths. The Map-fusion and exact-compile gates previously required bare (unannotated) parameters, so the hand-annotated spelling of a mapping function paid an interpretation penalty. The gates now accept an annotated parameter whenever the source collection's element type provably satisfies the annotation — the per-element type check is a no-op in that case, so the fused drain is unobservable — and still fall back to the enforcing path (loud error included) when the annotation is narrower than the elements or the source type is unknown. With this, the element-type inference above extends to scalar element types too: Map(Range(1, 200), x |-> Mod(x, 7)) infers x: integer, keeps fusion, and still compiles through the exact tier. A fused result is also re-validated when an inferred source type changes, so a collection that retracts to a wider element type raises the annotation error instead of reusing a stale fused pipeline.

    One consequence of the annotation contract to be aware of: an expression you retain (a lazy Map/Filter you hold and re-evaluate) keeps the parameter type it inferred when it was created. If the source collection is later reassigned to elements of a wider type (an inferred list<integer> becomes floats), re-evaluating the retained expression reports a per-element incompatible-type error — exactly as the hand-annotated spelling would — rather than silently adapting. Boxing the expression afresh after the reassignment infers the new element type and evaluates normally.

  • JavaScript target: string equality now compiles. Scalar Equal/NotEqual with a provably string participant — and no provably numeric one — lowers to a strict ===/!==, the interpreter's own string semantics, instead of failing closed. All-string collection equality keeps the _SYS.eq/_SYS.neq dispatch, whose scalar leaf gained a string branch, so Equal(["a","b"], ["a","b"]) is True and Equal(["a","b"], "a") is the element-wise [True, False]. A mixed provably-string/provably-number equality and the chained (n-ary) form still fail closed. The Python target mirrors the scalar rule with a structural ==/!=. With this, character-scanner programs (skipWs(cs, i) = skipWs(cs, i+1) if i <= Length(cs) && isWs(cs[i]) else i over Characters(text)) compile end to end.

  • JavaScript target: Characters, GraphemeClusters and StringJoin now compile. Characters segments UAX #29 grapheme clusters through the same Intl.Segmenter the interpreter uses, so a combining sequence, a ZWJ emoji or a flag is one element — matching interpretation element for element (neither [...s] nor split('') is faithful; both are pinned as counter-examples). StringJoin compiles the variadic all-string form and the single string-collection form; shapes the interpreter leaves inert fail closed. Known accepted divergence: compiled comparisons do not Unicode-normalize a raw non-NFC string bound to a compiled parameter (the interpreter stores every string in NFC; literals and Characters/StringJoin results are normalized) — normalize at the host boundary if you feed unnormalized user input into compiled predicates.

  • broadcastable<T> as a parameter declaration is now an elementwise contract. A parameter declared broadcastable<T> (e.g. ce.declare('f', '(broadcastable<number>) -> unknown'), or an Epsil function f(x: broadcastable<number>) { … }) maps an indexed-collection argument element-wise — even when T would admit the collection whole (broadcastable<value>: the collection arm wins) — and the map descends exactly one rank: each element binds to the parameter whole, even a nested collection, unlike the unannotated default, which descends to the scalar leaves. T is checked per element (a violating element produces a loud per-element error carrying the broadcast context, and its siblings still evaluate), and a scalar argument binds directly with no list wrapper. Applications now also type like the inferred path — a definite collection argument gives list<R>, a possibly-collection argument broadcastable<R>, a scalar R — where the declared spelling previously typed the strictly weaker bare declared result (an explicitly more precise declaration yielded strictly less type information). A collection- or tuple-declared sibling slot still binds its argument whole, and the strict length-mismatch policy applies across the mapped slots only. The unannotated vectorization default is unchanged. Design record: docs/plans/2026-08-08-broadcastable-param-semantics.md.

  • Compiling an application of a function with a declared broadcastable<T> parameter over a possibly-collection argument fails closed with a diagnostic (interpreted evaluation handles it) instead of emitting scalar code that returned garbage on arrays — the compiled broadcast helper recurses into nested arrays, which is exactly the leaf descent the one-rank contract rules out. The decline is per slot: an argument at a slot the declaration binds whole, an atomic tuple the element type admits, and a provably scalar argument all still compile. A multi-clause function with one broadcastable arm declines conservatively for the whole set.

  • Epsil debugging support in the engine. Two additions that let a debugger (such as the VS Code Epsil extension's DAP adapter) pause and inspect Epsil programs at statement granularity:

    • Source positions survive canonicalization. The sourceOffsets metadata the Epsil parser attaches to every node is now preserved through canonical boxing — custom canonical-handler results and the numeric fast-path constructors re-attach it, the .canonical getters (function and symbol) thread it, closure capture keeps it on rebuilt bodies, and the recursion knot-tying re-box serializes it. Positions are advisory metadata: default JSON serialization does not emit them (opt-in via metadata: ['sourceOffsets']), and interned singletons are never stamped.
    • Debug statement hooks. src/common/debug-hook.ts exposes a synchronous, module-global pre-statement hook and post-statement result hook, fired by the statement sequencer (Block bodies, lambda bodies, if branches) for source-mapped statements only. One comparison per statement when unset; not part of the public engine API.
    • Function-application scopes are now pushed with the context name 'call' (previously an anonymous placeholder name), so ce.trace and debuggers can delimit activation frames.
  • Destructuring assignment — (a, b) := (b, a). A tuple pattern may now appear on the left of a Cortex assignment, writing bindings that already exist instead of declaring new ones. The pattern grammar is the destructuring let's — at least two elements, each a bare symbol, a _ skipping that position, or a nested tuple pattern — and a shape mismatch is the same incompatible-type error value.

    The right-hand side is evaluated once, in full, before any target is written, which is what makes a swap mean what it reads: (a, b) := (b, a) exchanges the two values rather than assigning b to both. The same holds for a rotation ((a, b, c) := (c, a, b)) and for the pair-carrying loop step that is the usual reason to want this — (a, b) := (b, a + b) is an entire Fibonacci iteration, and (a, b) := (b, a % b) an entire Euclid step, with no temporary.

    Unlike a destructuring let, the targets keep their identity and their declared type: a value that does not fit a target's type is an error value, and assigning to a const fails. Those two are found only by attempting the write, so they are not atomic — targets earlier in the pattern stay written. A shape mismatch is atomic and writes nothing, including when it is nested under a position that would have bound; the destructuring let gained the same guarantee, which it did not previously have. Lowers to the Assign primitive with a Tuple pattern in the target position, held raw (canonicalizing it would fold a single-letter target such as i into the constant of that name), and accepted on all routes.

    In compiled code it lowers to per-leaf temporaries followed by per-leaf writes — (a, b) := (b, a + b) becomes let _tv1 = b; let _tv2 = a + b; a = _tv1; b = _tv2 — which is what keeps the compiled form honest: the targets already exist, so the naive a = b; b = a would read the a it just clobbered. Temporaries never capture a name the program already uses. Every target — JavaScript, Python, GLSL and WGSL — compiles it in any statement position, including a loop body, so the Fibonacci and Euclid steps above compile. Value position (a block's last statement, whose value is the block's) and a non-literal tuple value fail closed (D6) and the interpreter takes over; a destructuring let in value position is now refused the same way, explicitly, rather than by emitting source that happens not to parse. (This also fixes a silent divergence in the same family as the destructuring-declare one: a tuple target previously compiled as _ = …, leaving every target at its old value behind success: true.)

    ce.box(['Assign', ['Tuple', 'a', 'b'], ['Tuple', 'b', 'a']]).evaluate();
  • Cortex diagnoses a tuple pattern written with a bare =. A parenthesized left side is not a binding target, so (a, b) = (b, a) resolves — correctly, under the positional-= rule — to a comparison of two tuples whose result is discarded: the swap it looks like silently does nothing. That shape is almost always a typo for the destructuring assignment above, so it now reports destructuring-bare-equal; write (a, b) := (b, a) to destructure, or == if the comparison was meant. The node is unchanged — the diagnostic reports, it does not reinterpret.

    The check is deliberately narrow: it fires only statement-leading, and only when the left side is shaped exactly like a destructuring pattern (bare names, _, nested tuples), so a genuine tuple equation with computed components — (x + 1, y) = t — stays silent.

  • Parameterized nominal types: type tree<T> = tuple<value: T, children: list<tree<T>>>. A nominal type declaration now takes the same type-parameter clause a generic alias takes, in Cortex as above and from the host with ce.declareType('tree', '…', { typeParams: [{ name: 'T', variance: 'out' }] }). Unlike an alias, an application is opaque — tree<integer> is never expanded — which is precisely what lets the definition refer to itself, so a recursive parametric container (a rose tree, a JSON with a payload, a zipper) is expressible for the first time. The arity, bound and unused-parameter rules are the alias's, shared and generalized; self-reference, which an alias forbids, is the point here.

    A parameter carries a variance marker — out (covariant), in (contravariant) or inout (invariant) — saying how two applications relate: under out, a tree<integer> is usable where a tree<number> is expected. No marker means out, declared rather than inferred and verified against the definition like a written one: values are immutable, so covariance is sound and is what a payload container wants, and only the consuming minority pays an annotation. Because the default is declared, a definition that uses its parameter in an input position does not quietly change the type's subtyping contract — it is a variance-violation at the declaration, naming the violated variance and where it came from, the offending occurrences by path (notify.(arg 1)), and exactly the markers that would verify. inout verifies against any definition. Variance and bounds do not interact.

    A tuple definition mints a quantified constructor (tree: forall T. (T, list<tree<T>>) -> tree<T>), so tree(1, []) solves T = finite_integer from its arguments; a record definition is still inhabited by a constructor function, whose own clause is independent of the type's. Field access reads the definition instantiated at the application's arguments — with t: tree<number>, t.value is a number. match is a binding of values, not a projection of the annotation: each capture takes the matched value's own type, usually narrower — on a t built as tree(1, []), match t { tree(v, cs) => … } binds v: integer and cs: list<never>, not number and list<tree<number>>. Compilation erases the tag at the instantiated definition, as it already did for an unparameterized nominal type: tree<integer> compiles like the equivalent tuple, and declines identically where that would. One documented limitation: a construction solves its parameters from its arguments alone and an annotation does not widen them, so an explicitly inout/in parameterized type can only be constructed at exactly its argument type. See the new "Parameterized Nominal Types" section of the types guide.

  • A type variable may now appear in one arm of a union: type opt<T> = T | missing and forall T. (T | missing) -> list<T> are accepted, and at a call the argument takes exactly one arm — the open arm binds the variable (refutation included), a ground arm binds never, the narrowest member of the family. At most one arm of a union may mention a variable (T | U is unsolvable by construction). Intersections and negations remain rejected wherever the declaration mints a constructor — the minted signature is what is checked, so a record body or a mint: false declaration goes unchecked — and the intersection diagnostic now steers to the spelling that replaces it, a bound (forall T: number.).

  • A Cortex type statement re-declaration (a notebook re-run, or an edited definition) now UPDATES the existing type record in place instead of installing a new one. Types that mention the name — and applied references such as box<integer> already built — follow the new definition, so a node parsed before the re-run and one parsed after can no longer give different subtyping answers for the same pair of types, and a mutually recursive set converges on the second run rather than the third. A re-declaration that breaks a type depending on it now fails on the run that introduces it, rather than silently leaving that type reading a stale definition: an edit that changes the type-parameter count while a dependent still applies the old arity is a generic-alias-arity error, and one that makes a dependent's declared variance unsound is a variance-violation. Both are attributed to the dependent and name the re-declaration as the trigger, and both roll the statement back completely — definition, type-parameter clause, verified variance and minted constructor all restored. Re-declaring a type through the host ce.declareType() API still throws, unchanged.

  • Cortex: break and continue. They leave, or skip to the next iteration of, the innermost enclosing while/for loop, and lower to the engine's existing Break()/Continue() primitives. Valid anywhere in a loop body, including inside an if, a match case, or a do block. The loop context resets at every function and lambda boundary — a break written inside a lambda defined in a loop body does not target that loop, and is a control-outside-loop diagnostic — because the engine's Block short-circuits on Break/Continue structurally, so a laxer rule would permit non-local control flow. Value-carrying break value remains unspelled, pending the ruling on a general return.

  • Cortex: ?? for absence coalescing. a ?? b is Coalesce(a, b): the value of a unless it is absent (Missing or NaN). It discharges absence; it does not rescue an Error. Right-associative, at precedence 18 — looser than |> so xs |> f ?? 0 defaults the pipeline's result, tighter than |-> so x |-> x.a ?? 0 defaults inside the body.

  • Cortex: is for dynamic type tests. x is integer lowers to Element(x, integer) — the same test a match type pattern performs. The right operand is a type name, so a typo (x is intger) is a parse-time diagnostic rather than a comparison against an undeclared symbol. Simple named types only for now: a compound type (!error, integer | string, list<integer>) reports type-pattern-unsupported, as the equivalent typed pattern already does. is is a contextual word — let is = 5 stays legal.

Improvements​

  • Signature(op) works on the ce.box and ce.parse routes. The operator holds its operand and had no canonical handler, so the name arrived unbound and Signature answered Nothing for every operator unless the caller went through ce.function(), which boxes its arguments first. The name is now resolved by lookup — which also keeps the route read-only, where canonicalizing the operand would have DECLARED an unknown name as a side effect of asking about it.

  • A malformed predicate is reported by the operator that consumed it. CountIf, Find, IndexWhere and Position all threw an error whose message named Filter, an operator the user never wrote — Filter's message had been copied verbatim into each of them.

  • The signature-driven callback stamp declines a wrong-arity literal instead of half-annotating it. A user signature declaring a concrete arrow parameter ((cb: (integer) -> boolean) -> boolean) annotates an inline literal passed there by pairing parameters positionally; given (a, b) ↦ a > b it annotated a and left b bare. The whole stamp now declines, as the contextual-callback<S> route already did. Evaluation is unchanged either way — the arity error dominates — but a declined application no longer carries a half-written contract. Optional and variadic declared parameters widen the admissible arity accordingly.

  • src/math-json/OPERATORS.json regenerated, picking up the contextual callback<S> signature strings, the variadic Append, and three operators missing from it entirely (Adjoin, IdenticallyEqual, QuotientRing). Two defects in the generator are fixed along the way: a forall-quantified signature reported its arity as "unknown", and the arity count split on every comma — including those inside a parameter's own type, which made Fold 4-ary.

  • Element-wise (broadcast) failures are now self-diagnosing. A user function with scalar parameters is automatically applied element-wise over an indexed-collection argument; when that fired unexpectedly, the resulting per-element failures gave no hint a broadcast was in flight — the motivating symptom read Condition must evaluate to "True" or "False" with nothing tying it to a mapping. An error produced inside a broadcast element now carries an ["ErrorBroadcast", name, index, length] entry in its ErrorTrace breadcrumb, rendered as (while applying 'skipWs' element-wise over 4 elements (element 2)); an error thrown out of an element (a non-boolean if condition, say) has the same context appended to its message. Every failing element is annotated with its own index. Errors that never went through a broadcast are byte-identical to before, successful broadcasts pay nothing, and Epsil runtime-error diagnostics report the context too.

  • The Epsil debugger's variables pane marks broadcast behavior on function signatures: (n: number) -> number [elementwise] versus (xs: list<number>) -> integer [binds whole], so the derived vectorization behavior is visible before anything runs. A function-typed (callback) parameter does not suppress the marker, matching the eligibility rule.

  • Membership in a value collection now types the tested function parameter. Element(c, digits) — Epsil c in digits — inside a function body narrows a not-yet-typed parameter to the collection's element type (digits: list<string> ⇒ c: string), the membership counterpart of the collection evidence Length(cs) and cs[i] already contribute. The evidence lands on the parameter's binding only: the function's arrow still reports a scalar parameter slot as unknown, so the lambda auto-broadcast default is unchanged (isDigit(["5", "x"]) still maps elementwise). Two deliberate exclusions: a global symbol is never retyped — membership is a predicate (x in [1, 2, 3] on a string-valued x is False, not a type error), and a Solve domain spec such as Element(x, Range(1, 9)) constrains its unknown without narrowing it — and membership in a set (x ∈ ℤ, x ∈ {1, 2, 3}) stays with the assume machinery, which applies such refinements scoped.

  • Epsil debugger: function signatures in the Variables panel show inferred parameter evidence. The engine's arrow deliberately hides evidence that does not rule out broadcasting, so a function like skipWs(cs, i) = … cs[i] … displayed as (dictionary | indexed_collection, unknown) -> …. The debugger now reads the parameter bindings instead and shows names alongside everything inference recorded: (cs: dictionary | indexed_collection, i: boolean | indexed_collection | number | string) -> …, and isDigit shows (c: string) -> boolean. Display-only; the engine's types are untouched.

  • Cortex: most reserved words are now ordinary identifiers. Only the words the grammar actually consumes are reserved: the literals (true, false, Infinity, oo, NaN) and the active keywords and word operators (break, const, continue, do, else, for, function, if, in, match, while). The other 76 words in the documented list — set, with, label, where, to, each, and so on — can name a binding, be assigned to, be a |-> parameter, and be called. Previously a binding name accepted them but a bare assignment target, a mapsto parameter, and a call's callee did not, so label(6) = 1 was accepted while label(6) was an error.

    Relatedly, assigning to a literal word is no longer silently accepted: true = 5 and NaN = 1 now report reserved-word like every other binding position.

Resolved Issues​

  • tuple · tuple and x / tuple now reject consistently, regardless of how precisely the element types are known. The canonicalization guards only counted provably numeric tuples, so a product (or a division) with a tuple<broadcastable<number>, …> operand was accepted (typed number) and the identical incompatible-type rejection surfaced only at evaluation — making validity appear to depend on the order in which operand types were refined (a more precise type turned an "accepted" expression into an error). Both guards now count operands by tuple-ness, exactly like the evaluation path: a tuple product with another tuple, or a tuple divisor, never becomes valid under any element refinement. The LaTeX rendering of these rejections now suggests Dot — the operator that does accept points — instead of only reporting that a tuple is not a number. (scalar + tuple with unproven elements deliberately stays symbolic with an honest union type: per the retractable-evidence ruling, Add bakes its error only when both the tuple and the scalar are proven.)

  • A seedless Reduce now seeds with the collection's first element. Without an initial value, Reduce folded from the Nothing sentinel, which only looked right for a reducer that splices it away: Reduce([1, 2, 3], (a, b) |-> a - b) answered -6 — ((nothing - 1) - 2) - 3 — where the corresponding Scan ends at -4, and a reducer that does not splice leaked the sentinel into the result (Reduce([2, 3, 2], Power) produced a Nothing-valued power instead of 64). The seedless fold now starts at the first element and folds from the second, matching Scan and the compiled fast path; an empty seedless fold still answers Nothing. Folds given an explicit initial value are unchanged.

  • Any and All now surface an element's type error instead of silently discarding it. When a predicate fails on an element — for instance a callback whose inferred parameter type a retracted element no longer satisfies — the quantifiers previously treated the error as "undetermined" and returned nothing, and Any could even short-circuit True straight past the failing element. They now return the element's error, in enumeration order: a definite answer found before the failing element still short-circuits (matching the family's laziness), and an error encountered before any decision is the result.

  • Sum, Reduce, Total and other finite-only operations now work over FlatMap. FlatMap never reported whether its result was finite, so every consumer that requires a finite collection stayed inert over it. It now reports finite when its source is finite and the mapping function's result is provably finite (a scalar, or a finite collection); anything unprovable is left undetermined, as before.

  • A partially annotated function literal no longer rejects arguments at its unannotated parameters. Annotating one parameter of a literal activated per-application validation for every parameter, and a bare (unannotated) parameter was checked against its inference residue — which, among other things, made a seedless Reduce with an annotated element parameter reject its own Nothing seed with incompatible-type unknown nothing. A bare parameter now imposes no constraint; explicitly annotated parameters are enforced exactly as before.

  • Membership queries on derived collections no longer answer a definite False when the underlying membership is undecidable. Nine collection contains handlers (Filter, Join, Append, Reverse, RotateLeft, RotateRight, Cycle, CartesianProduct, PowerSet) collapsed an undecided sub-query into false — so Element((1, x), {1, 2} × ys) with ys a symbolic set evaluated to "False" instead of staying symbolic. All nine are now three-valued: a definite refutation still answers False immediately, and only a genuinely undecidable membership is left undecided. In the same pass, a Filter whose predicate returns a non-boolean now reports the malformed predicate from a membership query exactly as it does from iteration, counting, and emptiness — it was the one silent facet.

  • An element that fails a callback's declared parameter type is now loud everywhere. Previously it could surface as a nonsensical Filter predicate must return "True" or "False". Unknown symbol … message (which spell-checked the lambda's own parameter), be silently swallowed by TakeWhile/DropWhile (an Error treated as an ordinary "stop"), or be laundered into NaN by a fused Map pipeline doing arithmetic on the Error value. All three paths now surface the element's own incompatible-type error: stream operators emit it in the element's place, scalar operators (CountIf, Find, Position, IndexWhere, Partition) return it as their result, and fused pipelines propagate it exactly like the unfused evaluator.

  • Compiled collection callbacks no longer ignore parameter type annotations. On both the JavaScript and Python targets, a callback such as (n: integer) |-> n > 0 compiled to a plain function with the annotation dropped, so compiled Filter/Map/TakeWhile/… could silently produce different values than the interpreter on elements that violate the annotation. Compilation now admits an annotated callback only when the collection's element type provably satisfies the annotation — the same rule the evaluator's fast paths use — and declines to compile otherwise, so the interpreter's per-element errors are preserved. Applies to inline and named callbacks across all collection operators, including Reduce/Scan and Tabulate/Fill.

  • An ordering such as j <= Length(cs) no longer declines to compile when the same local is also used as an index (cs[j]). At's index slot is typed boolean | indexed_collection | number | string (a gather index may be a collection, a dictionary key a string), which permanently widened the local, and the string arm of that union was read as positive string evidence by the mixed-string ordering gate. A type that admits a string and a number and a boolean and an indexed collection — that exact four-armed shape — says no more than the top type does, and is no longer treated as evidence, so ordinary scanner loops (while j <= Length(cs) && isWs(cs[j]) { … }) compile again, on both the JavaScript and Python targets. A deliberately written union of scalar sorts (string | number | boolean) is still evidence, and genuinely mixed shapes (Less("a", 1), a number | string participant, Less("a", [1, 2])) still fail closed.

  • An else-less if statement in a function body compiles (JavaScript target). if cs[j] == "-" { sign = -1; j = j + 1 } in statement position lowers to a bare if (…) { … } instead of failing with If: wrong number of arguments. Loop bodies already handled this; a plain block's statements did not. An else-less if in value position still fails closed — it has no value. The Python, interval-JavaScript and GPU targets still decline the shape in a plain function body.

  • Iterating over a function parameter now counts as collection evidence. A parameter whose only use was for c in cs stayed untyped, so the lambda auto-broadcast mapped the function over the argument's elements instead of binding the collection whole: a function that accumulated t = t + c over for c in cs and was applied to [1, 2, 3] returned [0, 0, 0] instead of 6, and its inferred signature read (unknown) -> …. The iterated operand of a Loop/Comprehension Element clause now narrows an as-yet-untyped symbol to collection, matching what Length(cs) and cs[i] already did. Scalar parameters are unaffected — f(x) = x * 2 still broadcasts over a list.

  • A function forward-declared with the bare function wildcard (ce.declare('clean', 'function')) now participates in parameter collection-inference. Previously a caller such as proc(cs) = clean(cs) + 1 learned nothing from clean in either definition order: the wildcard declaration carries no parameter types, and assigning a function literal deliberately leaves the declared type alone (so re-assignment at a different arity stays legal), leaving no signature to narrow from. The application site now reads the assigned literal's own signature — for argument narrowing and for the application's result type, which used to be broadcast-typed (list<unknown^3>) where the value was a scalar — and a caller canonicalized while the callee is still a bare wildcard registers for re-derivation, so the later assignment repairs it: proc([10, 20, 30]) answers 11 instead of broadcasting into [proc(10), proc(20), proc(30)]. The wildcard remains a widening: re-assignment at a different arity or parameter type still works, and applications are still not validated against the assigned signature.

  • Compiled string comparisons fail closed instead of returning wrong values. The JavaScript compile target is numeric at heart: Equal and NotEqual lower to a tolerance test (Math.abs(a - b) <= tol), which for string operands is NaN <= tol — so s == "a" compiled to false behind success: true, and IndexOf over a list of strings returned 0 for the same reason. Both now fail closed (D6) when an operand is provably a string (a string literal, or statically string-typed — an unknown-typed symbol never gates, so inferred-parameter plot equalities compile byte-identically), and the interpreter fallback returns the correct value. Orderings (Less, Greater, …) are gated more narrowly, on the mixed case only ("a" < 1 — inert in the interpreter, false compiled): an all-string comparison compares strings exactly as the interpreter does (raw code-unit order) and keeps compiling, with parity pinned. match on string constants was never affected — it emits a real === — and is now pinned too. The Python target needed no gate: its wrong shapes raise a loud runtime TypeError rather than a silent value, and its IndexOf is genuinely correct.

  • The broadcast route no longer miscompiles string comparisons, and whole-array string equality fails closed. Two stragglers of the string-comparison class above reached the emitter through different doors: a mixed ordering over a collection (Less("a", [1, 2])) broadcast to [false, false] via _SYS.bcast before any gate ran, and whole-array equality (Equal(["a","b"], ["a","b"])) compiled to false because _SYS.eq's per-element tolerance test makes equal strings unequal — the gate tested operands, and neither operand is a string scalar. The string gates are now element-aware ("participants": scalar operands and the provable element types of collection operands), and the string-evidence test is recursive: nested string lists ([["a"]] == [["a"]]), heterogeneous literals (["a", 1] == ["a", 1]), a symbol typed broadcastable<string> or list<string> in an ordering, and — via the same walk — whole-value equality over dictionary/record/tuple-typed symbols (whose element types reach string) all previously compiled to wrong booleans and now fail closed. Admission is deliberately narrower than decline: only flat all-string orderings keep compiling (their interpreter parity is pinned); nested all-string shapes decline. Numeric shapes keep byte-identical codegen. (The Python target has the same broadcast-route defect — np.less("a", [1, 2]) — recorded as a known open hole, not yet fixed.)

  • The Python compile target now fails closed on the string and aggregate comparisons it miscompiled. Two shapes returned wrong values behind success: true: an ordering that mixes strings with numbers, where NumPy coerces the number to a string and compares as text (Less(["a", 10], ["b", 9]) ran to [True, True], while the interpreter answers ["True", "False"] — 10 < 9 is False), and equality with a tuple participant, where the tuple is looked inside instead of binding atomically (Equal(Tuple(1, 2), List(1, 2)) ran to True against the interpreter's False). Both now decline (D6) and the interpreter fallback answers correctly, as do dictionary/record comparisons (no positional lowering at all) and scalar string equality (abs("a" - "a") raises). Gating is at COMPILE time on static type evidence, not on the emitted code raising: several mixed shapes are loud on NumPy 2.x but historically returned a scalar False with a FutureWarning, and emitted code runs on the user's NumPy. As on the JavaScript target, an unknown-typed operand is never string evidence, so numeric and plot comparisons compile byte-identically. What keeps compiling, each with executed-parity pins: all-string orderings (scalar, chained, list-vs-list, list-vs-scalar), tuple-vs-tuple equality, and — unlike the JavaScript target, whose kernels are numeric there — IndexOf in every shape (including a tuple needle in a point list) and all-string collection equality, both of which lower to Python's own structural comparison and are faithful.

  • Compiled IndexOf's element test is now EXACT and bool-aware, like the interpreter. IndexOf([1, 2], True) ran to 1 on both the JavaScript and Python targets, where the interpreter's structural element test answers 0 (and symmetrically, IndexOf([True], 1) ran to 1): Python's True == 1 is True, and Math.abs(true - 1) is 0. Both targets now compare bool-ness first — Python through a new _ce_indexof/_ce_same runtime adapter, JavaScript through a strict === in the emitted element test. This is a runtime adapter, not a compile-time gate, so boolean IndexOf keeps compiling instead of declining. Both element tests also dropped their numeric TOLERANCE leaf: the interpreter's number .isSame() is exact, so IndexOf([0], 5e-11) and IndexOf([0.30000000000000004], 0.3) answer 0, where the compiled forms answered 1. (The tolerance leaf came from a probe artifact — IndexOf([0.3], Add(0.1, 0.2)) agrees only because Add(0.1, 0.2) evaluates to exactly 0.3 by exact decimal folding, so the element test never saw a near-miss float. The residual: a needle computed to a near-miss f64 at runtime is not found, which is the ordinary exactness loss of compiling to f64 arithmetic.) The adapters also FIND a NaN needle, as the interpreter's structural element test does: IndexOf([NaN, 3], NaN) ran to 0 on both targets (nan == nan is False in Python, and Math.abs(NaN - NaN) <= tol is false) and now answers 1. Otherwise unchanged: numbers still match across int/float, a tuple needle is still found in a point list, and a missing needle is still 0. On the Python target an np.ndarray on either side is now normalized to a nested list, so a caller-supplied (n, 2) point matrix works instead of raising an ambiguous-truth-value error.

  • Compiled Python orderings over two collections now reject a length mismatch. The interpreter answers Error("incompatible-dimensions", …) for Less([1, 2, 3], [1, 2]) and for Less([1], [1, 2]). NumPy only half agreed: it raises on 3-vs-2, but silently BROADCASTS 1-vs-n (np.less([1], [1, 2]) → [False, True]) — a wrong answer behind a success: true. The ordering ufuncs are now emitted through a _ce_ord runtime shape guard that raises on any list-like-vs-list-like length mismatch, the 1-vs-n case included. Scalar-vs-collection broadcasting is unaffected.

  • compileLambda (Python target) no longer emits references to undefined runtime helpers. It guarded only _ce_bcast; an IndexOf body, a collection/tuple Equal body, or a collection ordering produced a lambda referencing _ce_indexof/_ce_eqcoll/_ce_ord, which a bare lambda has no place to define — a NameError at call time. All four now fail closed with a message pointing at compileFunction.

  • A destructuring Declare with a positional initial value now binds (tuple patterns). ["Declare", ["Tuple", "x", "y"], "unknown", ["Tuple", 3, 4]] — the positional-value spelling the Declare contract documents and the scalar path already honors — silently declared nothing on the tuple path, which only read the trailing-attributes dictionary. The two forms now share one value resolution. A positional type on a tuple pattern, previously a silent no-op on this dead path, is now applied per bound name and surfaces an incompatible-type error value when it doesn't fit — loud over silent, and atomic: every leaf is validated against the type before any binding is installed, so a failure on the second leaf no longer leaves the first one declared. (No surface route emits either spelling: the Epsil parser uses the dictionary form.)

  • A destructuring assignment is now atomic too. (x, y) := (7, 4.5) with both targets declared integer used to write x and then fail on y, leaving the tuple half-assigned; the same happened when a later target was a constant. Every leaf is now validated against its target's existing binding — declared type, constness — before the first write, using the very check the write itself performs, so the diagnostic is unchanged and a rejected pattern leaves every target at its OLD value. (Failures raised deeper inside the install machinery — function-literal reconciliation, effect contracts — stay sequential.)

  • A destructuring declare or assign whose right-hand side is a tuple-valued expression now compiles (JavaScript target). let (v, j) = parseValue(cs, i) and the state-threading idiom (v, j) := step(j) previously failed closed unless the right-hand side was a literal tuple. When the pattern is flat and the right-hand side's static type pins the tuple arity (-> tuple<T1, T2>), the compiler now binds the whole result to one temporary and reads components positionally — let _tv1; _tv1 = step(k); let v = _SYS.at(_tv1, 1); … — preserving the interpreter's evaluate-once-then-write order (so swaps and _ positions behave identically). A tuple-typed symbol right-hand side rides the same path. Nested patterns, statically-unknown arity, and every non-JavaScript target (GLSL, WGSL, Python, interval) keep the fail-closed refusal.

  • A function no longer broadcasts over a collection argument its body consumes whole. A user function's unannotated parameters default to scalar, and a scalar-parameter function maps over an indexed-collection argument (the vectorization convention: f(x) = 2x applied to [1, 2, 3] is [2, 4, 6]). But the collection evidence a body provides was being lost in three ways, so functions that plainly consume a collection whole were broadcast too — the body then saw a single element, and conditions inside it failed (Condition must evaluate to "True" or "False") or loops never terminated. All three are fixed, and each writes its evidence onto the parameter so the inferred signature reflects the use:

    • a parameter referenced only from a nested block scope (an if branch, a while body — while cs[j] != "z") auto-declared a throwaway per-scope shadow binding that swallowed the inference; bare parameters now share one cached binding across the whole body, which the literal's parameter declaration then adopts;
    • a function that merely forwards its parameter (g(xs) = f(xs)) learned nothing, because calls to inferred-signature functions skip argument validation — and with it, its narrowing side-channel; the collection-only parameter types of the callee now narrow unknown symbol arguments even on that route;
    • Length(x) contributed nothing because its parameter is deliberately any (Length(5) stays symbolic); it now treats a not-yet-typed symbol operand as collection evidence, like an indexed read does.
  • Collection evidence now survives definition order: a function defined before its callee is re-derived when the callee arrives. The narrowing described above — a callee's collection parameter teaching the caller's parameter — could only fire when the callee was already defined. Write the caller first (process(cs) = clean(cs) + 1 before clean(v: list<number>) = v[1]) and process was canonicalized while clean was still unknown: cs learned nothing, so process([10, 20, 30]) broadcast elementwise, handing clean one number at a time — each call then failed against the declared list<number> parameter, and the errored elements surfaced as inert [process(10), process(20), process(30)]. Definition order changed semantics, which the engine already refuses to accept for juxtaposition (g(t) := 2a(t) written before a is defined re-reads as an application when a arrives): the same repair machinery now covers forward-referenced calls. While a function literal's body is canonicalized, a call whose callee is undefined — or known only by a guessed signature — registers the literal as a dependent of that name; when the name later gains a definition (assigned a body, or merely declared with a signature), the literal is re-derived from its raw operands and the parameter picks up the evidence, so the caller binds the collection whole and answers 11. Helpers can now be written below their callers, and mutually recursive pairs work in either order. A scalar callee defined later leaves the caller vectorizing, exactly as if it had been defined first, and a self-recursive function skips the pointless re-derivation of itself. Chains repair transitively: re-deriving a forwarder is that forwarder's own name gaining a definition, so A → B → C written in that order all bind correctly once C arrives — including diamonds, where the apex is rebuilt once, after every forwarder below it is current — and mutually recursive forward references terminate instead of chasing each other.

  • A while loop inside a zero-argument function now terminates. function f() { let j = 1; while j < 3 { j = j + 1 }; j } hit the iteration limit: the nullary apply path skipped the sweep of stale canonicalization bookkeeping that the parameterized path performs, so the loop condition read a hoisted valueless binding forever. The nullary path now hides those bindings for the duration of the call, exactly like the parameterized path.

  • And/Or/Not accept a possibly-absent condition, Kleene-style. A comparison on an indexed read — cs[j] == "a", honestly typed boolean | missing since the index may be out of range — was rejected at canonicalization by the logic operators' boolean parameters, so the guarded loop condition j <= Length(cs) && cs[j] == "a" errored with incompatible-type. The three operators now declare the handle missing-value behavior and evaluate Kleene over absence: False dominates And, True dominates Or, Not(Missing) is Missing, and a surviving absent condition still surfaces through If's absent-condition error.

  • Epsil: a pinned match case after a result line is a new case. The case body 1 => "one" followed by a line starting == lim => … fused into the comparison "one" == lim (leading-operator line continuation), and the => then diagnosed. At the top level of a case body a linebreak now ends the body; parenthesized subexpressions keep the ordinary continuation. Two diagnostics were also sharpened: comma-separated cases get a targeted match-case-separator (with a fix-it to ;, and parsing recovers instead of dropping the remaining cases), and a conditional tail accidentally placed at the start of a line (x + 1 / if x > 0 else 0) reports conditional-if-line-start instead of the misleading opening bracket expected.

  • A shader loop body no longer emits a return inside the loop. On the GLSL and WGSL targets, a for body with more than one statement compiled as a value — its last statement became return <statement>, so the shader returned on the first iteration while reporting success: true. Two plain scalar assignments (a := a + k; b := b * 2) were enough to hit it. A loop body is now compiled as a statement list, which is also what lets a destructuring assignment lower on those targets.

  • An even root of an even power reduces, and no longer does so for complex values. \sqrt[4]{x^2} now returns \sqrt{|x|}. It did not before, because the result is structurally larger and the cost check rejected it — which was quietly masking a soundness bug: the rewrite had no real-domain guard, so for a value declared complex it would have produced \sqrt{|z|}, where the principal value of \sqrt[4]{z^2} at z=i is e^{i\pi/4}, not 1. The guard is now in place and the reduction is kept for being the reduced real-domain form rather than the smaller one. A complex-declared base is left alone.

  • A closure returned from a function now resolves captured variables from inside a nested block. k ↦ (x ↦ if x > 1 { k } else { 0 }) applied at k = 100 returned the symbol k instead of 100, while the same body without the branch blocks — or a plain do { k } — returned 100. captureClosures rebinds a returned function literal so its body block closes over the call's frame, but it reused the body's operands verbatim, so a scoped block nested inside that body kept its canonicalization-time parent chain and reached the stale copies of the same lexical levels. The walk now re-roots nested blocks onto the captured chain, keeping their own locals. Held operands are what introduce such a block — If branches are the common case, and Cortex compiles every if branch to a block, so any if inside an escaping lambda was affected, including one drained later from a lazy Map.

  • An annotated function parameter read from inside a nested block now resolves. In Cortex, function s(k: number) { if 1 > 0 { k } else { 0 } } returned the symbol k rather than the argument — while the same function with a bare k returned it correctly. evaluateBlock sweeps stale canonicalization bookkeeping from the block's scope, and its keep-test was "is this binding's type inferred". An auto-declared shadow inherits the DECLARED type of the outer binding it shadows, so an annotated parameter left an explicitly-typed valueless shadow that survived the sweep and hid the call value in the lambda's fresh scope. The keep-test is now "was this created by a Declare statement", which is what the sweep meant to ask; genuine block-locals still survive, including across a re-entered block. Cortex wraps each if branch in a block, which is why the conditional shape surfaced it.

  • A lazy Map returned from a function no longer loses the variables its mapping function closed over. f(k) = Map([1,2], x ↦ x + k) drained by the caller produced [k + 1, k + 2] instead of [101, 102] — a silently wrong value, with no error. Drain-time Map fusion serves each element with a direct operator application in the ambient scope, bypassing makeLambda and so its scope push, and the shape gate treated every parameter-free operand as a "closed" value. A free symbol is not closed: it resolves by binding lookup, and once the defining call has returned that binding is no longer ambient. The closure chain was always intact — captureClosures rebinds the literal to the call's frame — so the fix is for the drain to evaluate inside it. A level whose operands are all literals, the shape the fusion was built for (1 + Mod(Range(0,899) + 29, 900)), records no scope and keeps the original zero-scope-work path; the 3×900 witness is unchanged.

  • Annotating a callback parameter no longer switches off broadcasting for the whole function. function map(f: (A) -> B, t) stopped broadcasting over every parameter the moment f was annotated, so a recursive map(f, t.children) over a tree went inert while the identical function with a bare f worked. Broadcast eligibility (paramsAreScalar) is all-or-nothing across the parameter list and a function type is not a scalar type, so one callback annotation vetoed the rest. A function-typed parameter receives a function, never a collection, so it can never be broadcast over and now abstains instead of vetoing — which is the position the inference path already took, so a declared (A) -> B parameter and an inferred one of the same shape no longer disagree. A collection-typed parameter still suppresses broadcasting for the function: it consumes a whole collection, and that suppression is what keeps a nested collection argument from being descended into elementwise.

  • Reading a field through a recursive type's own recursive field no longer fails with Converting circular structure to JSON. With type tree = tuple<value: any, children: list<tree>>, the expression t.children[1].value produced that error rather than the field's value. Joining the element types of a list<tree> reaches unionTypes, whose de-duplication key was JSON.stringify(type) — and a recursive type reference reaches itself through its resolved def. The key now omits def, which is both cycle-safe and lossless: a reference is identified by its name, and def is the only edge by which a type cycle can close.

  • A recursive type alias no longer overflows the stack when a value fails to match it. type alias json = missing | boolean | finite_real | string | list<json> | dictionary<json> accepted every JSON shape correctly, but any rejected value — a function, a complex number, NaN — produced Maximum call stack size exceeded instead of an incompatible-type error. hasValueComponent unfolded structural alias references with no cycle guard, unlike isSubtype, which has had one at each of its own unfold sites. Cutting the back edge is exact rather than merely conservative here: every component reachable around the cycle is already reachable on the first unfold.

  • A forward type reference can now be fulfilled by a later declaration, so a mutually recursive set of types is writable. The documented spelling — ce.declareType("json", "… | type json_array") followed by ce.declareType("json_array", "list<json>") — failed with The type "json_array" is already defined in the current scope: the forward reference installed a type record, and the declaration meant to complete it read that record as a redeclaration conflict. The declaration now completes the record in place, so the types that captured the reference resolve through to the definition (a fresh record would leave them pointing at the empty one, and the recursion could never close). Only an unfulfilled reference is completable; a name that already has a definition is still a redeclaration error. This applies to the Cortex type statement equally.

  • A parameter typed by an alias of a collection now binds its argument whole instead of broadcasting over it. With type alias u = list<number>, a function (u) -> … applied to [1, 2] was mapped over the list and each element then failed the parameter check — while the inline (list<number>) -> … spelling bound correctly. isScalarType did not unfold alias references, so an alias of a collection read as a scalar. Nominal types are unaffected and still broadcast: their values are tagged applications, never collections, so a list of them is a genuine elementwise call.

  • simplify() now reduces inside \int, \sum, \prod and \frac{d}{dx}. The body of those operators was never simplified, so \int(\sin^2x+\cos^2x)dx stayed as written rather than becoming \int 1\,dx, and \sum(n+n) never reached its closed form. That was deliberate: the closed-form rules match on the body's shape, and simplifying first rewrites the shape out from under them — \sum k(k+1) becomes \sum(k^2+k) and the sum-of-products rule stops recognising it. The body is now simplified once at the fixpoint, after those rules have had every chance and none has fired, so both work: the integrand above collapses AND \sum k(k+1) still returns its closed form.

    This is new work on expressions that contain a binder — roughly 3x on binder-heavy input in exchange for simplification that previously did not happen at all. Expressions without a binder are unaffected.

  • Coalesce no longer evaluates its tail past an undecided operand. When an operand still carried free variables, the handler evaluated every remaining operand before returning the partially evaluated expression — so a later operand's effects ran, and its errors surfaced, on a path that a decided first operand would never have taken. The tail is now left unevaluated, which also makes the nested form Coalesce(a, Coalesce(b, c)) and the flat Coalesce(a, b, c) observationally equal.

  • The cost function no longer prices the same expression differently depending on which form it is handed. Square(x) cost 6 unevaluated but 1 canonical, and Exp(x) cost 10 versus 1 — yet both canonicalize to a Power. Since simplify()'s cost gate compares an incoming expression against a rule's result, and the two need not be in the same form, that made some comparisons off by up to 2×. Both now price through the same helper as Power, so the cost is a property of the expression rather than of its representation.

    Relatedly, a negation is now priced by what it is applied to: a sign on a term costs 1, while negating a whole sum keeps the higher cost of 4, since that forces delimiters. Previously both cost 4, which said -a - b - c is nearly twice as complicated as -(a + b + c) when it reads more simply — and made Subtract(a, b) cost less than the Add(a, Negate(b)) it canonicalizes to. One visible consequence: \int\sqrt{x^2-1}dx now returns \frac12(x\sqrt{x^2-1} - \operatorname{arcosh} x), matching the factored form its two sibling trig-substitution integrals already returned.

  • A coefficient no longer jumps in cost at an arbitrary size. The cost function treated a numeral times something as cheap only for an integer up to 10 (but any rational), and discarded the coefficient's own size entirely — so 10x cost 4 while 11x cost 10, a 2.5× step for the same shape. The coefficient's cost is now counted, which prices size continuously (integer literals are already priced by digit count), and the magnitude test is gone: 11x costs 5, 1000x costs 7. 2x still costs less than x + x, which is what the discount exists for. One visible consequence: \sum n^2 returns \frac16(2b^3+3b^2+b) rather than \frac13 b^3+\frac12 b^2+\frac16 b, matching the shape \sum n already returned.

  • Raising something to a power now accounts for what is being raised. The cost function priced a power by its exponent alone, so (a+b+c+d)^{20} — which expands to 1,771 terms — scored 2, the same as x^{20} and barely above x^2. The base is now counted. 2q^2 is still cheaper than the repeated multiplication it replaces, which is what that rule exists for. One visible consequence: (\sqrt2+\sqrt3)^2 now reaches its closed form 5+2\sqrt6 instead of staying unexpanded.

    Two long-standing shortcuts came out with it. A negated power was priced without its base, so -\sin^2x scored 4 where \sin^2x scored 12 — the same subexpression valued three-fold apart on nothing but a leading sign. And \sqrt{} carried hidden surcharges for a perfect-square or odd-power argument, added to push factoring rewrites like \sqrt{x^2y}\to|x|\sqrt y past the cost check. Those rewrites introduce an absolute value, so they genuinely do grow the expression; they are kept because they are correct on the reals, not because they are smaller, and they now say so directly rather than relying on a surcharge to disguise their size. Same for distributing an exponent over a product. Behavior is unchanged in every case.

  • A radical is no longer priced as if it were an ordinary decimal. The cost function reduced an exact value to its floating-point magnitude, so \sqrt3, 2\sqrt3 and \sqrt{17} all scored the same as the plain decimal 0.5 — the radical was invisible to every comparison. An exact value is now priced as rational × \sqrt{radical} with the radical counted, calibrated so a radical literal costs about what the equivalent expression costs (\sqrt3 and \sqrt y both score 6). Plain integers, decimals and fractions are unchanged.

    Because a radical now carries weight, keeping one factored out beats spreading it across several terms, so a number of antiderivatives come back in a tidier form: \int\frac{1}{x^2+x+1}dx returns \frac{2\sqrt3}{3}\arctan\left(\frac{\sqrt3}{3}(2x+1)\right) rather than distributing the \sqrt3 over both terms of the argument. Collapsing nested radicals (\sqrt{\sqrt{12}} \to \sqrt[4]{12}) also no longer needs a cost-gate exemption — it now wins on its own merits.

  • An unevaluated integral now weighs heavily against a closed form. Integrate was priced like any unrecognized operator, but an antiderivative can be much larger than its integrand — \int\sec^3x\,dx scored 49 against a closed form of 78, and \int\frac{1}{x^4+1}dx 62 against 145 — so a rewrite that resolved such an integral could be rejected for being "more complicated". Integrals now carry a large flat premium. It is a weight rather than an absolute rule — a closed form vastly larger than its integral could still lose. It is flat rather than proportional so that comparing two expressions which each contain one integral still turns on the integrands, and so that an expression with fewer integrals still wins.

  • ce.parse() now accepts a scope option in its public type signature. The implementation has always honored it — the whole parse runs with the supplied scope as the current lexical scope, so name resolution walks scope → parents and every auto-declare and inference lands rooted there — but the option was missing from IComputeEngine.parse(), which is the type the public ComputeEngine resolves to. Passing it was therefore a compile error (TS2769) even though it worked at runtime. ce.expr(), ce.box(), ce.function() and ce._fn() all already declared it; parse() was the lone omission. No runtime change.

  • Pochhammer(), Degrees() and DMS() no longer numericize an exact irrational argument. Found by auditing for the Beta() bug below, which turned out to be one instance of a small class. (\sqrt2)_2 returned 3.41421356… instead of the exact 2 + \sqrt2, and \mathrm{Degrees}(\sqrt2) returned 0.0246826… instead of \sqrt2\pi/180. Two different causes, one symptom: Pochhammer built its rising-factorial terms with the .add() method, which folds two exact literals to a machine float (the same slip as Beta, and its own symbolic branch alongside already did it correctly); Degrees fell back to ce.number(arg.re) for a non-rational argument, and .re is a machine float. Exact rationals, integers, floats, poles and symbolic arguments are unchanged in all three.

    The audit also found the nightly exactness grid was covering only 104 of the engine's numeric operators — which is why these went unnoticed. It now covers 28 more. Mandelbrot/Julia are deliberately excluded (a float is the answer for an escape-time sampler), as are Rational/Rationalize (they exist to turn a float into an exact value).

  • Beta() no longer numericizes an exact irrational argument. \mathrm{B}(\sqrt2, 2) evaluated to 0.2928932188… instead of the exact 1/(\sqrt2(1+\sqrt2)), breaking the contract that evaluate() returns the most exact form and only N() produces a float. The closed form \mathrm{B}(a, m) = (m-1)!/(a(a+1)\cdots(a+m-1)) was being built with the .add()/.mul() methods, which fold two exact literals to a machine float — so \sqrt2 + 1 collapsed on the very first factor and the whole result went inexact. Integer and rational arguments were unaffected (they fold exactly), which is why only an irrational argument showed it. Poles (\mathrm{B}(-1, 2) = \tilde\infty), the finite negative cases (\mathrm{B}(-2, 2) = 1/2) and float arguments are unchanged.

  • A collection rebound to a Map over itself no longer overflows the stack. Assigning xs the value Map(xs, f) — the shape an accumulator loop produces — made any later query throw a raw RangeError: Maximum call stack size exceeded out of evaluate(), rather than being absorbed as an Error value; a host calling evaluate() directly saw a hard crash. The self-referential-binding guard was in place and firing (xs.value reads undefined, as designed), but Map is the one lazy collection operator that answers isFinite/isEmpty from its source rather than its own iterator, and that path reached the stored value through evaluate() instead of the guarded value. Each turn — isFinite → source resolution → evaluate() → isFiniteCollection → isFinite — completed its dereference before the next began, so the existing cycle guard, which is released when the dereference returns, never observed the re-entry. Such a source is now left unresolved, putting Map on the same symbolic residual that Filter and every other lazy operator already produced. Eager and broadcast sources (Map(X - 1, f)) still resolve exactly as before.

0.102.0 2026-08-05​

New Features​

  • Two ring constructions, Adjoin and QuotientRing, with their standard notations. \mathbb{Z}[\sqrt2], \mathbb{Z}[\sqrt2,\sqrt3], \mathbb{Z}[i] and \mathbb{Z}[x] now parse as ring adjunction (["Adjoin", "Integers", …]), and both \mathbb{Z}_n and \mathbb{Z}/n\mathbb{Z} as the quotient ring (["QuotientRing", "Integers", "n"]), which serializes back to the subscript form. The blackboard-bold ring and field constants — \mathbb{Z}, \mathbb{Q}, \mathbb{R}, \mathbb{C} — are accepted as bases. Both operators are inert in this version: they stay symbolic, carry no membership test and no arithmetic in the constructed ring, but they do report an honest type — \mathbb{Z}[\sqrt2] is a set<finite_real>, \mathbb{Z}[i] a set<finite_complex>, \mathbb{Z}_n a set<finite_integer>. Note that \mathbb{Z}_p is read as the integers modulo p — the quotient reading — not as the alternative number-theoretic reading the same notation carries in some texts, and that field adjunction written with parentheses (\mathbb{Q}(\sqrt2)) is not parsed. Sign-restricted spellings are unaffected: \mathbb{Z}_+, \mathbb{R}_- and \mathbb{Z}_{\ge0} still name PositiveIntegers, NegativeNumbers and NonNegativeIntegers.

  • Quantifiers accept an undelimited parenthesized body. \forall x > 0 (x^2 > 0) — a condition followed by a parenthesized body, with no comma between them — now parses as ["ForAll", <x > 0>, <x^2 > 0>] for all five quantifiers (\forall, \exists, \exists! and the negated forms). Previously the group was absorbed into the condition as an implicit product. The split is accepted only when the group reads as a proposition — a relation, a logic connective, a membership, or a predicate application such as (P(x)) — so \forall x > 2 (\sin y) still reads the group as a factor of the condition.

  • More spellings of the quotient ring and the sign-restricted number sets parse. \frac{\Z}{n\Z} (and \dfrac, \tfrac) now reads as ["QuotientRing", "Integers", "n"], like the inline \mathbb{Z}/n\mathbb{Z}, and serializes back to \Z_{n}. The terse blackboard-bold family also accepts the short comparison commands — \R_{\ge 0}, \R_{\gt0}, \N_{\ge1} and their siblings — which previously required the spelled-out \geq/\geqslant forms.

  • Cortex: the conditional expression a if c else b. When both branches are single expressions, the braces of the block form are noise, and the conditional spells the same ["If", c, a, b] without them: let y = 10 if x > 3 else 20. It is the same operator the block form builds — only the branches differ, plain expressions instead of Blocks, so the conditional introduces no scope and no statement can appear in a branch. The else is mandatory (it is what ends the condition; a missing branch would leave the false case with no value to name), and 1 if c reports the new conditional-else-expected diagnostic — use the block form if c { 1 } when there is nothing to return. Chains nest to the right ("zero" if n == 0 else "negative" if n < 0 else "positive"), so there is no else if spelling to learn. The conditional binds looser than every operator that computes — as in Python, looser than || — but tighter than the four that bind or pair, =, |->, |> and ->, so the whole conditional is the right-hand side of an assignment, the body of a function, the piped value, or the value of a dictionary entry. Used as an operand it needs parentheses: 1 if c else 2 + 3 reads as 1 if c else (2 + 3). One layout rule: the if must be on the same line as the value before it — a line break separates statements, so an if that starts a line always begins a new if-statement. The block form is unchanged, and a match-case guard (n if n > 0 => …) is unaffected: patterns have their own grammar, which has no conditional.

Improvements​

  • Cortex: If now serializes as if, not as a function call. The if-expression syntax has always parsed, but the serializer had no rule to emit it, so a program written if c { 1 } else { 2 } came back from the formatter as If(c, do {1}, do {2}). It now round-trips to the form it was written in. The spelling is chosen by the shape of the branches, which is exactly what the parser distinguishes: Block branches give the block form (chaining to else if when the alternative is itself a block-form If), plain expression branches give the conditional form a if c else b. A shape with neither spelling — mixed branches, or an If with no else and a non-Block consequent — keeps the generic If(c, …) call form, which also re-parses faithfully. An If in operand position is parenthesized according to the conditional's precedence, so Add(If(c, 1, 2), 3) serializes (1 if c else 2) + 3 rather than the differently-parsing 1 if c else 2 + 3.

  • simplify() no longer returns a result more complicated than its input. The cost gate that decides whether to keep a rewrite used to tolerate growth of up to 30% — a proportion, so the bigger the expression, the more growth it allowed. It is now strict: a rewrite is kept only if it does not increase the cost.

    Two things were wrong with the tolerance. It let large expressions run away: instrumenting the gate across the test suite caught a single rewrite adding 1,693 cost units, and a chain walking one expression from cost 7,098 to 10,133 — every step recorded as a "simplification". And 90% of what the tolerance actually bought was the generic expansion rule, which was making results worse by blowing factored closed forms apart.

    Removing it restores them. \sum_{n=0}^{b}(a + dn) now returns the textbook (b+1)(a + bd/2) instead of a four-term polynomial; \int\sqrt{1-x^2}dx returns \frac12(x\sqrt{1-x^2} + \arcsin x); \int e^x\sin x\,dx returns \frac12(\sin x - \cos x)e^x; solving x^2 - 2x\cos t + 1 = 0 for t returns \arccos\frac{x^2+1}{2x}, which now agrees with the validity condition reported beside it; and \frac{-b+\sqrt{b^2-4ac}}{2a} stays in closed form instead of splitting into -b/(2a) + \sqrt{b^2-4ac}/(2a).

    A rule that should apply regardless of cost is tagged purpose: 'transform', which bypasses the gate entirely — that, not a numeric tolerance, is the supported way to express "preferred even though larger".

    Making that work meant tagging the rewrites that had been surviving on the tolerance. Nine families now declare purpose: 'transform' explicitly: the power combinations x^n·x^m → x^{n+m}, x·x^n → x^{n+1} and x^n·x → x^{n+1} (whose equivalent for three or more factors already carried the tag, so the same rewrite had been obeying two different cost policies depending on which implementation caught it); collapsing nested radicals (√√12 → ⁴√12); removing a logarithm from under an exponential (e^{\ln x + y} → x·e^y and its \log_c sibling); \log_c(x^n) → n·\log_c(x); the geometric-series and shifted/falling-factorial closed forms for Sum and Product; the \sin/\cos(π ± x) argument reductions; rationalizing a radical denominator; and \sqrt{x^{2n+1}} → |x|^n\sqrt{x} (a branch-cut correctness rewrite, not a size optimization). Several were untagged only because the original string-matching exemption list never named them. Tagging them also makes them robust to a caller-supplied costFunction. Distributing a negation over a sum (-(x+1) → -1-x) is tagged for the same reason — it trades one negation for one per term, so it always scores worse, but it is the form the rest of the engine works in.

  • ComputeEngine, expr.engine and ExpressionComputeEngine are now one interchangeable type — no more casts between them. The ComputeEngine exported from the package (and from the /core sub-path) is now a constructor value paired with the structural IComputeEngine interface, rather than the class itself, whose private fields made its type nominal. An engine obtained from expr.engine (typed ExpressionComputeEngine) can now be assigned or passed wherever a ComputeEngine is expected, and vice versa. new ComputeEngine(), instanceof ComputeEngine, InstanceType<typeof ComputeEngine> and the static ComputeEngine.getStandardLibrary() all work as before (the constructor's type is the new ComputeEngineConstructor interface). The interface also gained members that were previously only on the class: Two, toJSON(), suggestOperatorName() and functionProperties(). One typed-surface consequence: the legacy canonical/structural options of ce.expr() and ce.box() are not part of the interface — use the equivalent form option ({ canonical: false } → { form: 'raw' }, { structural: true } → { form: 'structural' }); the legacy options still work at runtime. The ExpressionComputeEngine type is now deprecated: it is interchangeable with ComputeEngine, which should be used instead.

Resolved Issues​

  • Serialization no longer writes to the current scope. toLatex() and toMathJson() internally re-canonicalize parts of the expression they lay out (for example to display a product with negative exponents as a fraction), and that re-canonicalization could declare an undeclared function head — or write an inferred type onto a declaration — in whatever scope was ambient at serialization time. In particular, serializing an expression that had been parsed against a per-call scope leaked its function heads into the surrounding scope, changing how later parses read (aQ_{z}(x,y) parsed before the leak as an implicit product, after it as a function application). Serialization now runs in a resolve-only region: names resolve against the scope chain but are never declared, and no inference is written. The same region now also covers the undeclared-head auto-declaration during partial-form boxing, which the resolve-only contract already promised but did not enforce.

  • Cortex: a wrapped operator chain no longer ends in a dangling operator. When an infix chain was long enough to wrap, the formatter emitted the operator after every element, including the last. With no closing fence to absorb it the output ended on the operator, and the result did not re-parse: Add(accumulator, someLongVariableName, x, y) at a narrow margin produced accumulator + / someLongVariableName + x + y +, which reports unexpected-symbol "+". Only the separators between elements are kept now; those are fine across a line break, since an operator with whitespace on both sides stays infix. Fenced lists are unaffected — a trailing , before ] or ; before } is legal Cortex and still emitted.

  • Cortex: a statement block that has to wrap is indented, not staircased. do { … } (and the new if block form) laid its body out with the generic fenced-list layout, which aligns continuation lines to the opening brace. For a statement block that pushed the body out to the brace's column, and at a realistic margin left so little width that the statements broke apart in turn. Both are now laid out anchored at the keyword: body one indent in, closing brace under the keyword, statements separated by the line break itself. An else if chain is flattened first, so every clause stays at the same column instead of nesting a level deeper per else if. Expressions and collections keep the existing brace-aligned layout.

  • A subscript or bracket on a set constant is no longer read as an index. \mathbb{Z}_n parsed as ["At", "Integers", "n"] — indexing into a set — which is not a valid type, and serialized back to \Z[n], which parsed as an incompatible-type error, so the expression did not survive a round trip. Those spellings now produce the ring constructions above. Indexing a genuine indexed collection is unchanged.

  • A bare N or D used as a variable now binds the same way everywhere in an expression. A single-uppercase-letter name that is also a standard library operator reads as a variable when it appears where a value is required (N + 1), and the engine declares that variable the moment it first sees such an occurrence. Occurrences boxed before that point — the first N of N, N+1 — kept the operator binding, so one expression carried two different bindings for one name (isSame() was false between two occurrences) and boxing the same input a second time produced a third. An expression such as N, N+1, N+2 or (DB+BC)^2 = AD^2+AC^2 therefore did not survive a serialize-and-reparse round trip. When the variable is declared partway through a boxing, the expression is now rebuilt against it, so every occurrence shares one binding. What the names mean is unchanged: N(2.3) and D(x^2, x) still apply the builtin operators, x^2 |> D still pipes into the derivative operator, and a bare mention on its own (ce.parse('N')) still leaves the operator definition intact.

  • A symbol spelled by the generic (name-based) speller now reads back as the same symbol. Two cosmetic spellings changed the symbol's identity on a round trip. A plain trailing digit run became a subscript, so the symbol x2 serialized as x_2 and parsed back as the different symbol x_2 (and Arctan2 as \mathrm{Arctan_2} → Arctan_2). Such a name is now spelled verbatim and upright — \mathrm{x2}, \mathrm{Arctan2} — which reads back as the original symbol; a name that already uses the _ subscript convention is unaffected (x_2 still serializes as x_2). This changes the rendering of digit-suffixed names: they display upright as x2 rather than as x₂; write x_2 for the subscripted form. Separately, a name from the Greek-letter table whose command the LaTeX dictionary gives to a constant was spelled with that command: the symbol pi serialized as \pi and parsed back as Pi, zeta as \zeta → Zeta, phiLetter as \varphi → GoldenRatio. Those names are now spelled \mathrm{pi}, \mathrm{zeta} and \mathrm{phiLetter}. The set is derived from the dictionary, not hardcoded: a constant that yields its bare command to a declaration does not claim the spelling, so the symbol gamma still serializes as \gamma — that command reads back as EulerGamma only in an engine where gamma is undeclared, i.e. never in an engine holding the symbol.

  • The imaginary unit now has a single canonical spelling: ["Complex", 0, 1]. A bare i parsed to the complex literal ["Complex", 0, 1], but \imaginaryI (and \mathrm{i}, \operatorname{i}, and the MathJSON symbol "ImaginaryUnit") canonicalized to the symbol ImaginaryUnit. Since the serializer emits \imaginaryI for both, ce.parse('i') did not round-trip, and two structurally identical expressions could compare unequal with isSame(). The ImaginaryUnit definition has always been declared holdUntil: 'never' — meaning its value is substituted at canonicalization — but the symbol was interned in the engine's common-symbol table, which short-circuited that substitution. It no longer is, so ce.parse('i'), ce.parse('\imaginaryI') and ce.box('ImaginaryUnit') now all canonicalize to the same complex literal. Migration: raw MathJSON "ImaginaryUnit" is still accepted and still round-trips non-canonically (ce.box('ImaginaryUnit', { canonical: false })), but code matching on the canonical form should test for the complex literal — expr.isSame(ce.I) or the isImaginaryUnit() helper — rather than for the symbol name.

  • The interned imaginary unit is now an exact value, so exactness-gated folds fire on it. It was built from a float-lane numeric value while the identical ["Complex", 0, 1] literal boxed exact, which made canonicalization disagree with itself depending on how i reached it: ["Power", "ImaginaryUnit", 2] stayed i^2 (and ce.parse('i^2').simplify() did not reduce) while ["Power", ["Complex", 0, 1], 2] folded to -1 — so ce.box(expr.json) did not round-trip to expr. Small integer powers of i now fold on every route, and \sqrt{-1}, (-1)^{1/2} and \frac{a}{i} canonicalize to i, i and -ia respectively.

  • A product of an infinity and the imaginary unit no longer collapses to NaN at canonicalization. ["Multiply", "PositiveInfinity", ["Complex", 0, 1]] canonicalized to NaN while the other operand order stayed symbolic: the n·i promotion (which folds 2·i to the exact 2i) accepted a non-finite left operand and built a value out of an infinite component. Infinities are excluded from that fold, so both operand orders now keep the symbolic product. evaluate() still returns NaN for the indeterminate form.

  • Degrees() of a non-real argument no longer drops the imaginary part. \imaginaryI\degree canonicalized to 0 (and (2+3i)\degree to \frac{\pi}{90}) because the conversion read only the real part of its operand. The linear conversion is now applied to the whole value: Degrees(i) = i\pi/180.

  • A canonical Multiply is now always flat. A product built by the invisible (juxtaposition) operator could keep a nested Multiply operand — for example ce.parse('2f(ab)') canonicalized to Multiply(2, f, Multiply(a, b)) instead of Multiply(2, a, b, f) — which broke the associativity contract and made two structurally identical products compare unequal with isSame(). Note the MathJSON serializer flattens on output, so expr.json printed the same ["Multiply", 2, "a", "b", "f"] either way; only expr.ops showed the difference. As a consequence exact numeric factors separated by parentheses now fold as they should: 2(3x) canonicalizes to 6x. One visible knock-on: two closed forms returned by Sum — (b+1)(a+bd/2) and its sibling — now come back from simplify() expanded (1/2·d·b² + a·b + 1/2·b·d + a) rather than factored. The values and the canonical forms are unchanged; flattening shaved the expanded rewrite's cost just under simplify()'s 1.3× acceptance gate (35 vs 35.1), so the rewrite is now accepted where it used to be rejected. That margin shows how finely the cost gate discriminates between a factored and an expanded form, and it is a candidate for tuning.

  • An operator used as a value — unapplied, as in ["Tuple", "A", "Abs"] or as a callback in ["Map", xs, "Factorial"] — no longer serializes to a fragment of its own notation. The serializer reached for the operator's LaTeX notation, which is written in terms of operands, so with none it emitted \vert\vert for Abs, ! for Factorial or \sum for Sum; none of those re-parse. A LaTeX dictionary entry now declares whether its notation stands on its own with the new standaloneSymbol property — true of the function commands (\sin, \ln, \arctan) and of the constant and set notations (\Z, \emptyset, \varphi) — and only a flagged entry's notation is used for an unapplied symbol. Every other operator is spelled out as \mathrm{Abs}, \mathrm{Factorial}, \mathrm{Sum}, which re-parses to the same symbol. Leaving standaloneSymbol unset on a custom dictionary entry is always safe: it only costs the nicer spelling.

  • The Fungrim identity artifact was regenerated against the canonical forms above (1445 rules: 1435 simplify + 10 solve). Five imaginary-unit identities were retired because canonicalization now performs them natively, which made each rule a no-op: \sqrt{-1} = i, i^2 = -1, 1/i = -i, i^3 = -i and i^4 = 1. Every rule matching on the imaginary unit was re-encoded to the ["Complex", 0, 1] canonical spelling; none was lost, and no rule's meaning changed. Separately, the offline rule compiler's self-test no longer rejects a rewrite whose result is structurally identical to the expectation but carries a different binder identity (the fallback compared with isSame and isEqual only, and isEqual no longer proves identities in free variables) — this recovers the Sinc derivative identity.

  • A negated product now has a single canonical spelling. canonicalMultiply normalizes signs before folding exact numeric factors, so a fold that itself produced a negative real coefficient — only a product with complex factors can, e.g. i \cdot i = -1 — stranded a literal -1 operand: ["Multiply", "ImaginaryUnit", "ImaginaryUnit", "a", "b"] canonicalized to ["Multiply", -1, "a", "b"], which serializes as -(ab) and re-parses as the structurally different ["Negate", ["Multiply", "a", "b"]]. A fold-produced negative coefficient now re-enters the sign normalization, so both spellings converge on Negate(Multiply(a, b)) — the same form literal input has always produced. With this, every remaining serialize-and-reparse exception in the MathNet corpus ledger is a documented-lossy prettification: the ledger carries zero bug classes (385/391 round-trip).

  • A function assigned at the top level is no longer mistaken for an un-applied builtin. The single-uppercase-letter fallback (see the N/D entry above) decided "standard library" by scope position, but ce.assign('F', ce.parse('x \mapsto x^2')) lands its definition in the same scope as the library — so F + 1 silently shadowed the function with an unknown variable, and from then on F(2) evaluated to the product 2F. The fallback now discriminates on the definition's origin: a user-defined function used as a numeric operand surfaces an incompatible-type error and the function stays intact. Devolution of the builtins themselves (N + 1, S/D) is unchanged.

  • A raw (non-canonical) symbol now evaluates through its binding, matching the behavior raw and structural function nodes gained in 0.101.0. With x assigned 5, ce.box('x', { canonical: false }).evaluate() returned the symbol x; it now returns 5, while the receiver stays on its tier. A symbol with no assigned value still evaluates to itself.

  • The ellipsis fold barrier holds at any depth, on every route. A product carrying a ContinuationPlaceholder (\dots) in a nested operand could still be spliced — and its factors folded or reordered across the ellipsis — when it arrived through raw MathJSON (ce.box), wrapped in a Sequence, or as an explicit \cdot/* chain hanging off a juxtaposed run: (px_1 + 1) \cdots (px_n + 1) \cdot p^m re-serialized with the \dots moved to the front. All flattening sites now share one depth-aware barrier, and an explicit multiplication folds a juxtaposed ellipsis run into a single flat notational product, which round-trips.

  • An unknown function head whose name collides with a claimed constant spelling is spelled upright. A function head literally named pi serialized as \pi(x), which re-parses as Pi — the constant, a different symbol. The head-spelling path now consults the same claimed-spellings table as bare symbols: \mathrm{pi}(x), \mathrm{zeta}(x). A custom dictionary entry for such a head with a notation of its own is honored — only the colliding auto-generated spelling is bypassed.

  • InterpolatingFunction used as a bare symbol no longer serializes with a trailing empty subscript (\operatorname{InterpolatingFunction}_{}); it is spelled \mathrm{InterpolatingFunction}, which re-parses to the symbol. The applied form keeps its domain-subscript notation.

0.101.0 2026-08-04​

Breaking Changes​

  • A bare G is now a variable, not Catalan's constant. The LaTeX dictionary claimed the bare letter G for CatalanConstant ahead of any declaration, so a formula such as \int_{t_i}^{t_e}(G - F)\,dt silently read as Subtract(CatalanConstant, F), and declaring G could not reclaim it. As with e and i, the constant is now reachable only through explicit upright markup: \operatorname{G} — also its serialized form, so CatalanConstant still round-trips — and \mathrm{G}. Two consequences: G, G(2) and G_x are ordinary symbols, and \mathrm{G} no longer parses as the upright symbol G_upright.

  • EulerGamma now serializes as \operatorname{EulerGamma}, not \gamma. Bare \gamma still parses as the constant, but it now yields to a declaration (see Improvements), which would have made the old serialized form ambiguous: in an engine where gamma is a variable, serializing an expression carrying the constant and parsing it back silently returned the variable. \gamma could not be disambiguated with upright markup the way G was — \operatorname{\gamma} already means the plain symbol gamma — so the constant serializes to its MathJSON name, which reaches EulerGamma through the generic symbol path regardless of what is declared. Rendered output that used to show γ now shows an upright EulerGamma.

  • An ungrouped full-signature marker on a function literal is now always the literal's own contract. ["Function", ["Typed", body, "'(x: number) -> number'"], "x"] previously read the marker as a return type — the literal typed (unknown) -> (x: number) -> number — unless the signature carried an effect specifier. It now declares the literal's own signature (parameter types, arity — now checked — and return), whether or not effects are stated, matching the forall and effect-bearing readings. A plain arrow still states no effect contract. To ascribe a function-returning type, use the grouped spelling, which is unchanged: ["Typed", body, "'((x: number) -> number)'"].

  • expr.isEqual() and = no longer prove symbolic identities. Equality is now arithmetic: the operands are evaluated, compared structurally, and — when their difference has no unknowns — compared numerically within ce.tolerance. No expansion, no simplification and no sampling is attempted. An identity between expressions with free variables, such as \sin^2 x + \cos^2 x = 1 or (x+1)^2 = x^2 + 2x + 1, therefore returns undefined instead of true, and the corresponding Equal expression stays inert (as it already did for any undetermined comparison, so an equation is still usable as an argument to Solve). Every = comparison is now cheap and predictable. Migration: wherever an identity was being proven, call expr.isIdenticallyEqual(other), or evaluate ["IdenticallyEqual", lhs, rhs] (LaTeX lhs \equiv rhs) instead of ["Equal", lhs, rhs]. Comparisons of numbers, of constant expressions, and of expressions that evaluate to the same structure are unaffected.

  • expr.isSame() no longer follows the value of a symbol. It is now strictly syntactic everywhere: it compares canonical forms as written and never substitutes an assigned value. Previously a top-level symbol-versus-literal comparison dereferenced the binding — with one := 1, ce.symbol('one').isSame(1) was true — while the same comparison between two symbols, or nested inside a larger expression, did not, which made the method inconsistent with itself. With x := 5, ce.symbol('x').isSame(5) is now false. Migration: use expr.isEqual(value) — or expr.is(value), which adds a numeric fallback for constant expressions — to compare values; .isSame() answers "is this the same expression?" only. Internal checks against a literal operand, such as op.isSame(0), are unaffected. One canonicalization consequence: the x^0 → 1 fold is now a pure generic-symbol fold (like x/x → 1), so z^0 canonicalizes to 1 even while z := 0 — previously the assigned value was peeked and the fold was blocked. The literal 0^0 still canonicalizes to NaN.

  • A bare \equiv now parses as IdenticallyEqual, not Equivalent. \equiv is the mathematical identity sign, so p \equiv q parses as ["IdenticallyEqual", "p", "q"] (see New Features). The biconditional keeps all of its other notations — \iff, \Leftrightarrow, \leftrightarrow, \Longleftrightarrow, \longleftrightarrow — and Equivalent still serializes as \iff, so logic round-trips are unaffected; only the reading of p \equiv q as a biconditional changes. Over boolean operands the two operators agree in any case, since two propositions are identically equal exactly when they are equivalent. Migration: write p \iff q where a biconditional is meant. A \equiv followed by \pmod{n} is still a Congruent, and \not\equiv still negates a congruence.

  • ce.expr()'s scope option now RECEIVES the boxing's writes. It used to steer lookup only: auto-declared free symbols, undeclared call heads and type inference all still landed in the engine's current scope, so the option half-contained a boxing. The whole box now runs with the supplied scope as the current lexical scope, so every declaration and inference lands rooted there and discarding the scope discards the writes. Code that passed scope to redirect lookups while deliberately keeping the declarations in the engine's scope must now box without the option (or re-declare the harvested names). The same semantics are what the new scope option on ce.parse() provides — see New Features.

  • evaluate() on a raw or structural expression now evaluates through its canonical form. Binder machinery — declaring a Sum index, normalizing Tuple → Limits — is a canonicalization step, so evaluating a structural binder ran its handler against an unbound index and returned a silently wrong value: ["Sum", "n", ["Tuple", "n", 1, 3]] boxed with structural: true evaluated to 9 instead of 6, and the same tree boxed raw evaluated to itself. Both routes (and .N() and async evaluation) now produce the canonical result: the receiver stays on its tier, but .evaluate() leaves the tier and every tier agrees on the value. Two consequences: a raw 2 + 3 now evaluates to 5 instead of echoing itself, and arity or type errors that only canonicalization checks can now surface from evaluating a raw tree (a raw 5 |> 3 evaluates to the same incompatible-type error the canonical route reports, where it used to stay an inert Pipe). A bare unbound symbol still evaluates to itself.

  • Quantifiers now canonicalize their operands. ForAll, Exists, NotForAll, NotExists and ExistsUnique held their operands without a canonical handler, so a parsed quantifier kept raw parse sugar in its body: InvisibleOperator where Multiply was meant, Delimiter(Sequence(…)) where a Tuple was meant, stray HorizontalSpacing from \quad. The condition and body are now canonical (e.g. a condition x > 0 normalizes to 0 < x like every other comparison), which also makes the serialized form round-trip.

New Features​

  • Evaluate handlers now receive the expression being evaluated. The handler options carry an optional expression field — the canonical node, whose .ops are the raw (pre-numericization) operands, unlike the handler's first parameter which holds the evaluated operands (see the EvaluateHandlerOptions documentation for the caveats: positional correspondence does not survive associative flattening, ReleaseHold, or dropped operands, and for lazy operators the first parameter is also unevaluated). The first consumer is Power: under .N() a negative base's real-vs-complex branch is now decided from the exponent's exact rational — read from the raw operand, or through a symbol's binding — so .N(), the type handler, and the compiled constant fold agree for exact odd-denominator exponents of any term size ((-2)^{1000003/1000001} is now real on every leg; parity is decided on the exact bigint terms, so denominators beyond 2⁵³ do not corrupt the branch). Exponents with no exact provenance (floats, π, a lambda parameter, a Sum body) keep the rate-bounded float reconstruction.

  • expr.hash is now a documented public property. A structural, bucketing-grade hash suitable as an in-memory cache or bucket key with a deep compare on hit. The documented contract: a.isSame(b) implies a.hash === b.hash (the hash is a pure function of the canonical tree — a symbol's assigned value never affects it); it is deterministic within a release but not stable across releases, so it must never be persisted; it is 32-bit-class, so a hash hit must be verified with isSame(); and it folds bound-variable names (binding-identity, not alpha-equivalence), matching isSame(). The property itself is unchanged — it was previously marked @internal.

  • Parametric polymorphism: forall type variables in function signatures. The type language gains rank-1 (prenex) type variables with optional ground upper bounds: forall T. (list<T>) -> T, forall T: indexed_collection. (T) -> T, forall T, U. (list<T>, (T) any -> U) -> list<U>. Any identifier can be a variable — the clause declares it, and within its arm the quantified name shadows a nominal type of the same name (forall itself is now reserved in type strings). User functions can be declared generically (ce.declare('swap', 'forall T, U. (tuple<T, U>) -> tuple<U, T>')); each call solves the variables from the operands by local type inference (repeated variables join — forall T. (T, T) -> T at an integer and a real gives real), the result type is obtained by substitution, and a violated bound reports the instantiated expected type. Overload sets quantify per arm, with a ground arm beating an equally-specific generic one. On the query APIs, matches with a generic pattern answers existentially ((number) -> number matches 'forall T. (T) -> T') while couldMatch reads each variable as its declared bound. A generic declaration may be implemented by an evaluate handler or by a function body — see the two entries below. See the new "Generic Signatures" section of the types guide.

    Twenty-five library operators now state their contracts declaratively with generic signatures instead of imperative type handlers, preserving operand kinds and dimensions exactly: Identity, Prime, BaseForm, Chop, PlusMinus, Remainder, Conjugate, Inverse, Reverse, the tuple constructors (Single, Pair, Triple, KeyValuePair), and the collection family (Take, Drop, Slice, DeleteAt, Insert, ReplaceAt, Sort, Unique, RandomShuffle, Tally, Partition, ChunkBy) — e.g. Reverse of a matrix<integer^(2x3)> is now statically a matrix<integer^(2x3)>, and Take of it a list<vector<integer^3>>. As part of this, a dimensioned collection is now also a subtype of a collection of its rows (matrix<integer^(2x3)> <: indexed_collection<vector<integer^3>>), matching how single-index access has always evaluated.

  • Generic function literals: a forall signature can now be implemented by an inline body. A whole-signature clause makes a ["Function"] literal generic — written as a signature string (["Function", body, "'forall T. (x: T) -> T'"]), as a Typed marker on the body, or as the declared type of the symbol the literal is assigned to — and it works on every route: ce.assign, the Assign operator, and an annotated Cortex const/let. Each call instantiates the clause, so on one engine f(5) types finite_integer and f("a") types string, bounds are enforced at the call, and a collection argument still broadcasts at the variable's bound. Declaring first and assigning after — ce.declare('nest', 'forall T. (x: T, n: integer) -> T') — makes generic recursion work for the first time. The body is canonicalized once with the quantified parameters erased: inside it, x: T is an ordinary unannotated parameter, a bound does not narrow it, two parameters sharing T are not known to have the same type, and the variable-correlated result is a trusted ascription rather than a run-time check. Four boundaries are rejected with dedicated diagnostics: partial application of a generic function, a generic clause in a multi-clause set (in either direction), a function-literal body for a generic overload set, and a forall clause on an individual parameter annotation.

  • Cortex: generic function definitions, function f<T>(…). A definition takes a type-parameter clause between its name and its parameter list: function g<T: number, U>(x: T, k: (T) any -> U) -> list<U> { … }. Bounds must be ground types, the effect specifier and return type are unchanged, and the clause names scope over the definition's head only — its parameters, effect specifier and return type — so a body-local annotation such as let y: T is an ordinary unknown-type error. Unused variables, result-only variables and non-ground bounds are diagnosed at parse time by the type grammar itself; an empty clause, a duplicate name and a generic clause in a multi-clause set have their own codes (empty-type-parameter-clause, duplicate-type-parameter, generic-clause-unsupported). A generic definition serializes back to the sugared form losslessly. The math definition form does not take a clause: f<T>(x) = x remains an ordinary expression, since it is genuinely ambiguous with a relational one.

  • Transparent generic type aliases: type alias Pair<T> = tuple<T, T>. A structural type alias can now take a type-parameter clause, in Cortex as above and from the host with ce.declareType('Pair', 'tuple<T, T>', { alias: true, typeParams: ['T'] }) — parameter names, { name, bound } records or one clause string ('T, U: number'). The applied spelling is usable anywhere a type is written: an annotation, a parameter, the element position of another type. It expands eagerly, at type resolution, into the substituted definition, so nothing downstream ever meets an applied reference: Pair<integer> is tuple<integer, integer>, and that expansion is what .type, toString(), matches() and error messages show — the source keeps the spelling it was written with, and a Cortex program round-trips let p: Pair<integer> = (1, 2) verbatim. Arguments nest (Pair<Pair<integer>>, list<Pair<integer>>) and aliases compose (type alias Wrap<T> = list<Pair<T>>). A parameter may carry a ground bound, enforced wherever the alias is applied; an argument that is itself a type variable — a forall clause's, or the enclosing alias's own parameter — is admitted by comparing bounds: the variable's declared bound must satisfy the parameter's (an unbounded variable is bounded by any, so a bare forall T. (Keyed<T>) -> T reports generic-alias-bound naming both bounds). Four limits, each with its own diagnostic: a generic alias may not refer to itself (recursive generic aliases are out of scope), every parameter must be used in the definition, a bare or wrongly-sized application is an arity error, and a parameterized nominal type is still unsupported. No constructor is minted for a generic alias and its name is not claimed in the value namespace at all, so a function of the same name stays legal, before or after. A dependent alias snapshots what it was built from: re-running a type statement replaces that alias, and re-running the cell re-declares the dependents in order. See the new "Generic Type Aliases" section of the types guide.

  • IdenticallyEqual: a dedicated operator for mathematical identities. ["IdenticallyEqual", lhs, rhs], the method expr.isIdenticallyEqual(other) and the LaTeX notation \equiv (the ≡ character parses the same way, and the operator serializes back to \equiv) ask whether two expressions have the same value for every value of their free variables. This is the tier that proves an identity: it applies expansion and simplification and evaluates both sides at pseudo-random sample points, so a True verdict may rest on sampling — a very strong indication rather than a formal proof, and the only comparison in the engine that can answer this way. It is three-valued: an identity that can neither be established nor refuted stays unevaluated. The machinery itself is not new — it is the prover that Equal used to run — but it is now reached explicitly. On the compile targets, Equal keeps its tolerance comparison while IdenticallyEqual and Same decline to compile.

  • Same (the Cortex === operator, also written ≣) is now specified as canonical-syntactic equality, matching expr.isSame(). It compares the canonical form of its operands as written and never dereferences the value of a symbol: with x := 5, ["Same", "x", 5] is False while ["Equal", "x", 5] is True. It remains total — always True or False, with no tolerance — and keeps no IEEE exemption for NaN: ["Same", "NaN", "NaN"] is True where ["Equal", "NaN", "NaN"] is False, and the same holds inside a collection (["Equal", ["List", "NaN"], ["List", "NaN"]] is False). Together with Equal and IdenticallyEqual, this gives three tiers of comparison — syntactic, same value, and same function of the free variables — described in the "Comparing Expressions" section of the Symbolic Computing guide.

  • Per-call scope control: ce.parse(latex, { scope }) and ce.createScope(). Canonical parsing writes to the engine's lexical scope — free symbols are auto-declared, undeclared call heads become inferred functions, types are narrowed by usage — which consumers parsing untrusted or out-of-order input had to contain with pushScope/popScope discipline. scope makes the containment first-class: the whole parse runs with the supplied scope current, so name resolution walks scope → parents and every auto-declare and inference lands rooted there. ce.createScope(bindings?, parent?) builds one from a declarations table, which turns each parse into a function of (latex, dictionary, declarations):

    const scope = ce.createScope({ h: 'function', p: 'tuple<3>' });
    const expr = ce.parse('h(u) = u^2', { scope });

    One binding per definition head is enough to make a definition parse against a predeclared name of a different arity — or against a builtin (N(x, m, s) = …) — with no mutation and no ordering requirement, and a binding for a subscripted spelling ({ theta_z: 'number' }) is what \theta_z resolves to instead of being declared. Trigger-spelled names now participate fully: a subscripted Greek-letter base consults the same joined-name resolution as ASCII names, so a function-typed binding (or resolveSymbol answer) for alpha_1 makes \alpha_1(x) parse as a function application — previously only ASCII bases could commit a joined name. The scope is caller-owned and readable: declarations() returns its entries with their post-inference types (as BoxedType, so entry.type.toString() is the canonical, fingerprintable spelling) and an inferred flag, sorted by name; narrowings() reports definitions in enclosing scopes that a contained parse narrowed (the one write an ephemeral scope cannot contain); dispose() releases the scope's definitions from configuration-change tracking. A definition harvested from one scope can seed the next — ce.createScope({ f: def }) installs the same object, preserving binding identity — and the scope's definitions are never auto-disposed, so a harvested definition outlives the call.

Issues Resolved​

  • Real, Imaginary and Argument are real-by-definition for the compile targets (Tycho item 147). The complexness analysis judged these heads by their operands, so Mod(Im(z), 1) over a declared-complex z tripped the GLSL real-only helper gate and failed closed — even though the projections always lower to a real scalar ((z).y, atan(z.y, z.x), .im) on every target. The analysis now short-circuits these heads as real-shaped regardless of their operand's type, so real projections of complex interiors (Mod(Im(b + a·ln(x+iy)), Im(w)), domain-coloring rows) compile again. Provably complex operands (Mod(√-2, 1), Mod(i·x, 1), Mod(Conjugate(z), 1)) still fail closed.

  • A function literal with an invalid explicit Block body no longer prints a bare internal TypeError while boxing (Tycho item 150). Canonicalizing ["Function", ["Block", ⟨body with an Error node⟩], …] dereferenced the invalid block's missing scope, and the caught Cannot read properties of undefined (reading 'bindings') was printed raw to console.error before recovering to a non-canonical literal. The block is now rebuilt so scope creation runs even over an invalid body: the literal boxes canonically (still isValid: false), with no console noise. Two siblings fixed alongside: the nullary form silently produced a canonical literal with an unscoped block, and an empty Block body threw the same TypeError (it now follows the annotated branch's convention: an empty body is Nothing). The two recovery catch sites in applyOperatorDefinition now attribute anything they print (ComputeEngine: error canonicalizing \op`: …`) instead of emitting a bare message.

  • Machine-precision .N() of a negative base to a rational power took the wrong branch. At machine precision, (-2)^{100/3} numericized to -1.08e10 (wrong sign — p = 100 is even), (-2)^{7/3} took the complex branch where the real root exists, and (-2)^{7/6} — a genuine even-q case — wrongly produced a real value. The exponent is numericized at the engine's 15-digit precision before the real-root convention applies, which put it far outside the branch decision's reconstruction tolerance. The tolerance now scales with the engine precision, threaded identically through the type handler and the compiled constant fold. A 288-cell rational sweep against an independent reference went from 53 mismatches to 0.

    Fixing this exposed a deeper, longstanding defect in the same fallback: every irrational's continued-fraction convergents eventually fall within any fixed tolerance, so a negative base raised to an irrational exponent could silently take the real branch — (-2)^{\sqrt2} and (-2)^{1/\pi} were wrong-real on both precision lanes, (-2)^e on the bignum lane, (-2)^\pi on the machine lane. The reconstruction now also requires a coincidence bound (the candidate rational must be identifiable as the value the double was rounded from, not merely a nearby convergent): π, e, √2, √5, ln 2 and plain non-rational floats now take the complex principal branch identically at every precision, and the compiled constant fold follows ((-2)^{\sqrt2} folded to a wrong real constant; it now folds to the complex value, matching .N()). Exact Rational exponents are unaffected by the bound. Known limitation: once an exponent has been numericized, a rational whose terms exceed what a 15–17-digit double preserves (denominator ≳ 3·10⁵) is indistinguishable from an irrational and takes the complex branch.

  • Symbols declared with a non-finite type now report their finiteness. A symbol declared non_finite_number answered isFinite/isInfinity with undefined; both predicates now decide from the declared type (isFinite is false, isInfinity is true), completing the type-consult work started for function expressions in 0.100.2. The workaround checks this blindness had required in the Add/Multiply/ Divide type handlers were retired after an instrumented full-suite run showed the getters now subsume them everywhere.

  • A parse or box under a partial canonical form no longer declares free symbols. ce.parse(s, { canonical: ['Number'] }) — any partial form — auto-declared every free symbol into the current scope, so containing the writes of an untrusted or out-of-order parse required pushScope/popScope discipline even though the result is not fully canonical. A partial form now follows the same symbol contract as the structural route: names resolve against the scope chain — an existing declaration still binds, and a holdUntil: 'never' constant still substitutes its value — but a name that resolves to nothing stays unbound instead of being declared. canonical: true, canonical: false and structural: true are unchanged.

  • A function declared with a scalar return type is now rejected when added to a tuple. scalar + tuple is an error when the scalar is provable, and a declared (non-inferred) numeric result counts as proof. That guard never fired for a head declared with a function type — ce.declare('f', '(number) -> number') — because such a declaration produces a value definition rather than an operator definition, and the guard consulted only the latter: f(x) + (1, 2) stayed a symbolic Add. It now falls back to the head's value definition. The inferred cases are unchanged and still stay symbolic, an inferred numeric type being retractable evidence rather than proof: a signature inferred from a := body, or a function type inferred from earlier use.

  • A built-in operator name used as a callback no longer compiles to a broken artifact. Map(xs, Sin), CountIf(xs, IsPrime) and the like compiled "successfully" and then threw _f is not a function at run time: the callback symbol fell through to a free-variable lookup instead of resolving to a function. Such a name is now eta-expanded into a shared emitted wrapper (const _fn_Sin = (_tv1) => Math.sin(_tv1)) — the same machinery a user-defined function callback already used — so the artifact runs and agrees with the interpreter. The expansion happens at the operator's required arity, so an operator with an OPTIONAL tail works too (Sum(Map(xs, Ln)): a callback site applies Ln unary and the optional base defaults), as does a unary operator with a target operator mapping (Negate), which the bare-operator-symbol path used to refuse outright. Where a built-in cannot be expanded at all — a variadic tail (Less), no required parameter (Random), or a wrapper body with no lowering on the target (IsPrime on JavaScript, any function value on the shader targets) — compilation now fails closed at compile time and the caller falls back to the interpreter, instead of producing an artifact that throws. (A bare single-uppercase-letter operator name such as D or N is exempt: the engine reads those as variables when they appear un-applied, so they keep their free-symbol reading.)

  • Six classes of LaTeX round-trip defects are fixed (found by the corpus round-trip lane, npm run check:roundtrip — 18 of its 37 recorded failures cleared):

    • A symbol naming an operator, used as a value, serialized to the empty string, silently deleting the operand: (A, +) — the tuple of a set and its operation — parsed to ["Tuple", "A", "Add"] but serialized as (A,). Such a symbol now falls back to \mathrm{Add}, which parses back to the same symbol.
    • A one-operand Tuple serialized as (x), which parses back as plain x. It now serializes with a trailing comma, (x,), a spelling the parser already accepted.
    • A product containing an ellipsis (ContinuationPlaceholder) serialized with mixed separators (ab\times\dots\times z), so the juxtaposed run regrouped into a nested Multiply when parsed back; and a product of rationals with an ellipsis was merged into a single \frac, moving factors across the ellipsis and folding them (3/2 · 6/5 · … · X came back with a spurious 9/5). An ellipsis product now joins every factor with an explicit multiplication sign and is never merged into one fraction.
    • Prime serialized its exponent unbraced (A^\prime), so a following letter was swallowed into the command name: \angle BA'C serialized back to \primeC, which does not parse. The exponent is now braced.
    • A quantifier body serialized without delimiters, so \forall k\ge0, a_0=9\land a_1=3 parsed back with the \land bound above the ForAll. The body is now parenthesized when its precedence requires it, and — with quantifier operands now canonical (see Breaking Changes) — the body round-trips structurally.
  • Find now types as the element, not the collection. Its static type was the whole collection's (Find([1,2,3], p) claimed list<integer>) while evaluation returns a single element or Nothing; it is now element | nothing.

  • BaseForm's declared result contradicted its behavior (-> string | nothing while echoing its numeric operand); it now echoes the operand type.

  • If without an else branch keeps the nothing arm in its type. A false condition yields Nothing, but the static type silently dropped that arm.

Improvements​

  • Structural-tier freeVariables, unknowns and references now derive bound variables from the operator's binding sites. On a structural tree (ce.box(…, { structural: true })) a binder's bound variable leaked as a free variable whenever its operand carried a raw parse spelling the free-variable walk did not know: ["Sum", ["Power", "n", 2], ["Tuple", "n", 1, 10]] reported n as free, while the canonical route reported nothing. The bound names now come from the operator definition's binding-site selectors, which read every spelling the binder accepts (Tuple, Element, Limits, a bare symbol, held or not), so the structural and canonical routes agree. Consumers that pattern-match raw binder spellings to build capture-avoidance sets can retire those collectors. A node with no operator definition (canonical: false) has no binding sites to consult and keeps the previous spelling-based recognition, and a binder whose variable survives into its result (D, Series) still reports that variable as free.

  • The bare-assign route now broadcasts like the value route. Assigning an annotated or generic function literal directly (ce.assign('f', …) with no prior declaration) installs an operator definition whose broadcastability is derived from its parameter types, so f([1,2,3]) with f: (x: number) -> number — or forall T: number. (T) -> T — maps over the list instead of rejecting, matching both the declare-then-assign route and what compiled code already did. Unbounded generic identities still return their operand whole, and an empty source answers [] on every route.

  • Re-assigning a function literal no longer changes its representation. Assigning the same annotated literal twice (the notebook re-run pattern) used to silently convert the operator definition into a value definition, losing the derived broadcast behavior; re-assignment now rebuilds the same representation as the first assignment.

  • Function-literal signature markers may reference user-declared types when serialized to Cortex, and anonymous literals carrying a signature marker round-trip losslessly instead of dropping the ascription.

  • A broadcast argument at a generic parameter now binds the element type. When a collection is admitted against a scalar-bounded type variable — the lift that makes Conjugate([1, 2, 3]) legal — the variable used to bind the whole argument, and the result was un-wrapped again only where it was the bare variable. A result that merely mentioned the variable then came out one rank too high: forall T. (T) -> tuple<T, T> over [1, 2] typed list<tuple<vector<…^2>, vector<…^2>>> against the value [(1, 1), (2, 2)]. The bound is still checked at the scalar base — admission is unchanged — but the variable now binds the argument's element type and the call site's ordinary broadcast wrap re-adds the rank, which gives one rule for every result shape: an echo (Chop, Conjugate) types exactly as before, and a result that mentions the variable is the per-element result with the argument's shape around it. Two static types change: a mixed-rank or union-typed argument now takes the broadcast wrapper's (coarser) shape answer, the same one its ground counterpart gets, and Remainder(M, 7)-shaped calls no longer widen to a union at all — matrix<finite_integer^(2x2)>, where the whole-argument bind gave list<finite_integer | vector<finite_integer^2>^(2x2)>. Only the kinds a broadcast actually maps are peeled: a set argument is admitted but never mapped (Conjugate(Set(1, 2)) stays a set), and a tuple stays atomic. In the same pass, a rank ≥ 2 argument to a generic function literal now maps to the scalar leaves on the value route too, matching the operator route and compiled code: with f: forall T. (x: T) -> tuple<T, T> assigned x |-> (x, x), a 2×2 argument evaluates to a 2×2 of pairs of scalars instead of applying the literal to whole rows.

  • A malformed type annotation no longer swallows the statement after it. Recovery from a bad annotation ran twice — once inside the type subparser and once in its caller — so a declaration such as let x: )bad( = 1 resynchronized one statement too far and discarded the line that followed. The subparser now only diagnoses, and each caller resynchronizes at the unit its own grammar uses: a statement boundary for a declaration, the next , or closing bracket for one element of a list. A malformed annotation in a function's parameter list, in a |-> parameter list or in a match tuple pattern therefore costs only its own annotation — the parameter survives untyped and the rest of the list still parses. The |-> case is the one that was silently wrong rather than merely noisy: the parameters after the malformed one were dropped, so the lambda went on to parse at a different arity. The resync honors <…> nesting, so an unclosed applied alias (f(x: Pair<integer, string)) no longer mints a bogus parameter out of the type's own argument list.

  • Generic values describe themselves, and an anonymous generic application is typed. A function literal assigned to a symbol declared with a polytype now carries that polytype as its own type on all three routes (ce.assign, the Assign operator, an annotated Cortex const/let), so the stored value reports forall T. (x: T) -> T instead of the arrow its erased body would infer — as long as every parameter of the clause mentions a quantified variable (a literal with a ground parameter still self-describes as its inferred arrow). Applying a generic literal anonymously — ["Apply", literal, arg], and the bare [literal, arg] it canonicalizes from — instantiates the clause too, so the application types finite_integer at 5 and string at "a" rather than falling back to unknown. And re-assigning an untyped literal over a signature that was itself derived from an earlier assignment now fully replaces it, arity included; a signature the author declared (any ce.declare form) stays sticky, as before.

  • Euler derivative notation and \gamma now yield to a declaration. Both spellings are claimed by a parselet that runs ahead of symbol resolution, so no declaration could reclaim them: D_x + 1 parsed as the derivative of the constant function x ↦ 1, and \gamma was always the Euler-Mascheroni constant. Each parselet now consults the symbol oracle first — the engine scope, supplemented by the resolveSymbol parse option. D_x reads as a symbol when the joined name D_x is declared (the same sibling-name rule subscripted spellings already use) or when D itself is shadowed by a non-function declaration; a function-typed D keeps the derivative reading. \gamma reads as the symbol gamma when gamma is declared. Left undeclared, both notations parse exactly as before. This gives a host embedding the engine a way to say "these are my variables" without having to replace the LaTeX dictionary.

  • Degenerate big operators (Σ_{i=a}^{a}, Π_{i=a}^{a}) now reduce. A big operator whose lower and upper bounds are structurally equal has a one-point domain, so it has exactly one term. Two reductions follow. First, at evaluation: a symbolic bound made the domain non-enumerable, so \sum_{i=x}^{x} i^2 stayed inert; one point needs no enumeration, and it now evaluates to x^2 (Product likewise). Second, at canonicalization: when the index does not occur in the body, the indexing set carries no information at all, so the "identity wrapper" spelling \sum_{i=d}^{d} f(x) folds to f(x) — the same generic-symbol fold family as x/x → 1. With several indexing sets only the degenerate, unused ones are dropped — and never one whose index a sibling indexing set's bounds reference. The canonicalization fold compares the bounds strictly syntactically: it never reads a symbol's assigned value (\sum_{i=a}^{5} with a := 5 keeps its Sum structure — values belong to evaluation, where the reduction does follow them). Bounds that are not provably a fixed one-point domain are unaffected: ±∞ and NaN bounds keep their previous behavior, impure or invalid bounds (two syntactically identical RandomInteger(1,6) draws) stay symbolic, and literal equal bounds with the index used (\sum_{i=5}^{5} i^2 → 25) still enumerate as before. The evaluate-path reduction substitutes the bound for the index under a capture guard; a body whose inner binder could capture it stays symbolic rather than being silently corrupted (and the guard's refusal is final — it does not fall through to the closed-form rewrites, which carry no such guard).

  • Compile-time CSE now merges repeated pure user-function calls everywhere, including named callbacks. The 0.100.0 admission of pure user-function applications applied only inside emitted definition bodies (where a repeated recursive self-call made compiled recursion exponential); a repeated call at the top level of the compiled expression — f(x+1) + f(x+1)^2 — still compiled to two calls. Both compiler harvest routes now admit them, behind the same transitive callee-body validation (each level's purity is re-derived against current bindings at compile time, so a callee that draws, writes, or splices caller-supplied source stays un-merged). In addition, a named callback that resolves to a validated pure function literal no longer blocks eligibility: two identical Map(xs, f) applications with a pure user-defined f now compile to one traversal, and the same applies to typed callbacks of eager operators such as CountIf (a drawing f still compiles to two — draw streams and call counts are preserved; a callback or callee name shadowed by an enclosing parameter is conservatively never merged). Callbacks naming built-in operators (Map(xs, Sin), CountIf(xs, IsPrime)) are now admitted as well: the compiler eta-expands them into a shared emitted wrapper, so what they do is the built-in's own deterministic, effect-free emission. They merge when the operator is pure, is the engine's own definition for that name, and has at least one required parameter and no variadic tail — a drawing built-in (Random), a variadic one (Add, Less), a name a user definition shadows, a name a caller vars entry maps, and a name a caller functions / operators mapping overrides all stay conservatively excluded. Opt out as before with compile(expr, { cse: false }).

  • FindFit/FindRoot now report a setup-phase deadline in band (Tycho item 118 addendum). A time budget consumed entirely before the solver started — evaluating the data operand, differentiating the model, compiling it — used to escape as a bare CancellationError the caller could only duck-type, which an interpreted model over a few hundred rows hits routinely under a tight ambient budget. Such an expiry now answers the same record shape a mid-solve expiry does, with timedOut: True and a new phase entry naming how far the call got: "setup" (nothing was fitted — iterations is 0, residualNorm is NaN, and the reported parameters are the starting guesses) or "solve" (genuine best-so-far, as before). Only an expired time budget converts: every other failure during setup — a malformed model, bad data, an abort signal — behaves exactly as it did. The keys appear only on a timed-out record, so a successful fit is unchanged.

Performance​

  • Symbolic equality of free-variable expressions now samples before it simplifies. The eq() free-variable branch ran expand+simplify on both sides to try a structural proof, and only then fell back to stochastic sampling — on large trees the symbolic pass costs hundreds of milliseconds and, whenever the sides genuinely differ, contributes nothing the sampler doesn't decide alone. The order is now reversed: sample first (a compile plus ~50 deterministic point evaluations), and run the expand+simplify proof only when sampling is uninformative (no compilable/finite sample points). Verdicts are unchanged: a sampled agreement was already accepted as true, a sampled disagreement already degraded to undefined under the truth-under-constraints contract, and an identity provable by simplify cannot genuinely disagree at a shared sample point. On a consumer's Voronoi document whose piecewise rows compare each broadcast element against min(⟨list⟩) (18 identical expand+simplify passes of the same min-expression), the document build drops from 14.6 s to 6.2 s — faster than releases that predate the 0.100.2 \bmod serialization fix, which had made the comparison trees honest (and bigger).

  • Compile-time complexness analysis is no longer quadratic on large expressions (Tycho item 148). The 0.100.2 operand-consulting fixes (items 144/143) made isComplexValued walk a node's whole subtree per query, and the GPU emitters query per node — a deeply nested expression (a textually inlined user-function chain) paid O(n²): a depth-6 nested chain spent 82% of its GLSL compile (1.5 million analysis calls) in the walk, roughly doubling large shader compiles relative to 0.100.1. The analysis is now memoized per compilation with a LAYERED memo that mirrors the context's lexical nesting: entering a block frame or binder mask pushes a fresh layer (an answer cached under a mask can never be reused outside it), and leaving restores the enclosing layer — so binder-dense bodies (nested Sum/Product) memoize too, instead of wiping the cache at every mask crossing. Compiled output verified byte-identical across targets. The depth-6 chain compiles 7× faster than unmemoized (and ~2× faster than 0.100.1, which did fewer, cheaper walks); scaling on the regressed class — deeply nested inlined expression chains — is near-linear in expression size again. (Deeply nested binder chains, Sum-in-Sum, keep a pre-existing superlinear analysis cost that 0.100.1 shares — measured, not part of this regression.)

0.100.2 2026-08-03​

New Features​

  • Repeat compiles on the JavaScript target. Repeat(7, 3) previously failed closed (interpreted fallback); it now lowers to a native array construction with interpreter parity at the edges: the value is evaluated exactly once and replicated (Repeat(Random(), 3) yields three copies of a single draw — and consumes its draw even when the count is ≤ 0, matching the interpreter), a zero or negative count yields [], and the 1-argument infinite form and a statically non-finite count still decline — the interpreter leaves Repeat(7, ∞) unevaluated, so a compiled [] would be a valid-looking value with the wrong meaning.

  • Binomial/Choose compile on the GPU targets. A literal k ∈ 0…8 unrolls to the falling-factorial form on GLSL and WGSL (Binomial(x+1, 2) → (((x + 1.0) * ((x + 1.0) - 1.0)) / 2.0)), matching the interpreter's generalized semantics for non-integer and negative first operands (Binomial(5.5, 2) = 12.375, Binomial(-1, 2) = 1). An impure (Random-family) operand is hoisted and drawn exactly once; Binomial(Random(), 0) declines rather than folding to 1.0 (the interpreter consumes that draw); a statically non-finite first operand declines (Binomial(∞, k) is NaN in the interpreter for every k, including k = 0); non-literal, negative, non-integer, or larger k fail closed.

Improvements​

  • Products and quotients with a provably non-finite real factor now type non_finite_number. New ratified rule: a provably non-finite real factor is implicitly nonzero — proven signs are required only of the finite factors. 2\ln(0) and \ln(0)/2 now type non_finite_number (previously the top type number); shapes admitting 0·∞, ∞/∞ or ∞·i keep the sound widen. Structurally, Ln(0) now reports isFinite === false and isInfinity === true from its static type (both were undefined), valueOf() projects a direction-proven infinity (Ln(0).valueOf() is -Infinity; ~oo is reserved for provably non-real values), \ln(0)/\pi canonicalizes to \ln(0) and 2/\ln(0) to 0, and an unfolded finite-real-over-±∞ quotient types finite_integer (the value is exactly 0). Compiled emissions are unchanged.

Resolved Issues​

  • .subs() on a structural expression now preserves the structural form. A structural receiver requested a CANONICAL rebuild, so substituting into one silently canonicalized it: the parse vocabulary structural form exists to preserve (Subtract, Divide, InvisibleOperator, Delimiter, operand order) was erased and exact literals were folded — substituting k+1 for x in a structural 2(x+1)-\frac{y}{3} returned Add(Multiply(2, Add(k, 2)), …) instead of the structural Subtract(InvisibleOperator(2, Delimiter(Add(Add(k, 1), 1))), Divide(y, 3)). The receiver's form is now preserved three ways (canonical → canonical, structural → structural, raw → raw); an explicit canonical option is unchanged. The binder-rebuild internals (rewriteWithBinders, used by the escaping-scope re-bind and by binding-keyed substitution) had the same conflation and are fixed the same way. .map() had the mirror-image defect — a structural receiver fell into the RAW rebuild arm, keeping the shape but silently losing the binding — and now preserves the receiver's form under the same three-way rule.

  • GLSL compile of Mod over wide-typed real expressions. The shader targets' real-only helper gate refused operands whose type merely could be complex: the complex type a Sqrt/Ln of unknown sign carries since 0.100.0 propagated to enclosing arithmetic (10^5·√(⌈x⌉²+⌈y⌉²)), through boolean nodes, and into piecewise conditions, so Mod expressions over plot variables failed closed (D6) where 0.99.0 compiled them. The complexness analysis now short-circuits boolean- and string-typed nodes and, for arithmetic heads that only propagate complexness (Add, Subtract, Multiply, Divide, Negate), consults the operands — honoring the unknown-sign Sqrt/Ln/Log real-kernel contract — instead of the widened type. Provably complex operands still fail closed.

  • Min/Max over a collection whose element type is unknown. With a base declared indexed_collection, Distance(S, p)'s result type degraded to scalar number (the broadcast arm was invisible through the elementless type), and Min then compiled to the variadic-scalar Math.min(...) — which returns NaN when handed the runtime array, silently, behind success: true. Distance now reports number | list<number> when an operand's collection element type is undecidable, and the Min/Max JavaScript lowering emits a runtime shape projection (reduce an array, pass a scalar through) for operands that could be collections, matching the interpreter both ways.

  • invisibleMultiply serialization option vs \bmod. With invisibleMultiply: '\\cdot', Mod(k·f, 1) serialized as k\cdot f\bmod1, which re-parses as k·Mod(f, 1) — the Mod serializer decided parenthesization assuming juxtaposition, which binds tighter than \bmod while an explicit \cdot binds looser. A Multiply operand of an infix \bmod is now parenthesized whenever the option is set (products that serialize as \frac remain unwrapped).

  • Ordering comparisons over a provably complex operand now fail closed at compile time. Less/LessEqual/Greater/GreaterEqual with a complex-valued operand (i·x < 0) compiled to a raw JavaScript comparison of a {re, im} object — a silent false behind success: true — while the interpreter correctly leaves the comparison symbolic (the complex numbers are not ordered). Such comparisons now decline with a clear diagnostic on every compile target. Equal/NotEqual keep their complex support, and real-kernel expressions of unknown sign (√x < 2) still compile.

  • Non-canonical trees are no longer restructured by pretty serialization. Serializing a canonical: false expression (toMathJson/toLatex with prettify) could rebuild a Multiply containing a symbolic Divide factor through the canonical product machinery: explicit Delimiter fences were dropped, factors reordered, and the round-trip changed the expression (Mod((k·f), 1)/n + y re-parsed with the dividend split). Pretty rewrites on a non-canonical tree are now shape-preserving — order-preserving numerator/denominator split, no factor sorting, fences kept. Canonical serialization is unchanged.

  • Impure operands spliced by multi-use compile templates drew more than once. A lowering that splices a compiled operand string into its emitted code more than once re-evaluates a Random-family operand at run time — a silent wrong value that also shifts every later draw. A full audit of the JavaScript, GLSL, WGSL, interval, and Python targets fixed twelve such sites: Equal/NotEqual over a complex operand and across n-ary chains, Range(start, stop, step) (which re-drew once per element), Round, odd-degree Root, Variance (12 draws where the interpreter makes 2), complex Argument/Conjugate, chained relations and element-wise selection masks, Match subjects, and a double-compile in the complex Add fallback that orphaned a hoisted draw. Impure operands are now bound to a temporary exactly once and in argument order — binding only the middle of Random() < Random() < 0.9 had executed the second draw first, inverting the comparison. Pure emissions are byte-identical throughout, and regression tests count draw sites in the emitted code.

  • ContrastingColor emitted invalid WGSL. Both the 1-argument and 3-argument forms lowered to a GLSL-only ?: ternary on every GPU language; WGSL now emits select(…) (the GLSL emission is byte-identical), and an impure (Random-family) color operand fails closed instead of being spliced twice.

0.100.1 2026-08-02​

Breaking Changes​

  • PointZ applied to a 2-D point is now a typed incompatible-dimensions error instead of the NaN absence marker — at type-check time when the operand's type statically proves 2-D (tuple<number, number>, or a list or set of such points, in either the tuple or coordinate-row spelling), and at evaluation time otherwise. The broadcast over a list of 2-D points errors identically, and the JavaScript compile of a statically 2-D operand declines. A statically-absent component is a type-level fact: the silent NaN masked upstream pipeline defects. PointX/PointY, all 3-D behavior, and the compiled runtime NaN marker for dynamically-shaped bases are unchanged.

New Features​

  • Multi-clause function definitions can now be declared before they are defined. declare("J", "(number, complex) -> complex") followed by clauses such as J(0, z) = z previously failed with incompatible-type (a literal-parameter clause is a narrowed arm of the declared signature, not a function subtype) — and failed silently, leaving the symbol undefined. Clauses are now checked arm-shaped against the declaration (parameters and result must be subtypes of the declared ones), a genuinely incompatible clause errors loudly without corrupting the definition, and the declared signature is preserved on the installed function — making declare-then-define usable for recursive clause sets (let fact: (number) -> number; fact(0) = 1; fact(n: integer) = n * fact(n-1)).

  • Complex-valued multi-clause function definitions now compile. A recursive clause set such as J(0, z: complex) = z; J(n: integer, z: complex) = J(n-1, z)^2 + z_0 evaluated correctly but declined the JavaScript compile target while its real-valued twin compiled; the clause dispatcher now carries the complex {re, im} convention through parameter guards, call-site coercion, and mixed real/complex clause bodies.

  • The LaTeX serialization style options (rootStyle, fractionStyle, indexStyle, powerStyle, logicStyle, numericSetStyle, groupStyle, applyFunctionStyle) can now be specified as a constant, in addition to a function of the expression and of its nesting level:

    ce.latexOptions = { rootStyle: 'solidus' };
    expr.toLatex({ fractionStyle: 'inline-solidus' });

    Previously, only the function form was supported, and a string value serialized to an empty string. A string that is not one of the values accepted for that option now throws when the option is set, instead of producing an empty serialization.

  • Distance broadcasts over point lists. Distance(S, p) (either argument order) where S is a list of points — spelled as tuples [(0,0),(3,4)] or as coordinate rows [[0,0],[3,4]] — returns the list of per-point distances, on both the interpreted and compiled routes, so min(Distance(S, p)) computes the nearest-point distance directly. Distance(S, T) over two point lists is pairwise with strict length matching. Scalar and string arguments are still rejected.

  • Point operators are aligned over point lists: PointX/Y/Z project a coordinate-row list (PointX([[10,11],[20,21]]) is now [10, 20], not the first row), and Norm over a list of point tuples returns per-point norms [0, 5, 10] on both routes, matching Abs. Norm over a plain matrix (list of lists) keeps its Frobenius meaning. Compiled Norm and Abs previously disagreed with the interpreter on these shapes (a flattened norm and componentwise values behind success: true).

Performance​

  • Registering many interdependent user functions (declare + assign chains, the shape a document importer produces) was quadratic in the number of functions: the type accessor's cache key evaluated the effect projection (isPure, made binding-aware in 0.100.0) before the cheap constant-operand short-circuit, forcing every stored function literal's full signature to re-derive down the call chain on each registration. Reordering the check and adding a per-generation fast path makes registration near-linear — 9–14× faster at 120 functions, 13.7× at 240 — with byte-identical isPure/effects/type answers. New benchmark: benchmarks/effects-registration.ts.

Resolved Issues​

  • isPure and .effects no longer claim a seed frame contains a lazy view that escapes it. WithRandomSeed(42, Map(xs, x => Random())) — and the [Random() for k = [1...6]] comprehension spelling of it — reported isPure: true with no effects, even though a lazy view draws at materialization, from whatever frame is active then, so the values were genuinely live. Such an expression now reports isPure: false and ["random"], including when the view leaves the frame as a cell of a returned List/Tuple/Pair or as a block's result. The runtime semantics are unchanged: materializing inside the frame (WithRandomSeed(42, ListFrom(Map(...))), an index, a reducer) still replays, and still reports pure — as do a view whose element body draws nothing and a seeded frame around an ordinary draw.

  • Two-sample bracket ranges with decimal anchors now compute their step exactly. [1.008, 1.016...5] previously differenced its anchors in binary floating point, baking the step 0.008000000000000007 into the range — 499 elements ending at ~4.992 instead of 500 landing on the 5 anchor. The step is now derived from the anchors' decimal digits in exact integer arithmetic (0.008), matching the compound-anchor spelling [1+0.008, 1+0.016...5]. Anchors with no short decimal form keep their float step.

  • Symbolic differentiation declines honestly instead of blowing up. The derivative of a deeply self-nested expression (e.g. a 12-deep √(x+√(x+…)) chain, whose second derivative is a 6.5-million-character expression taking 45+ seconds) doubles its tree per level; differentiation now tracks the size of what it builds and, past an internal budget (25,000 nodes, ~30× the largest derivative in the test corpus), aborts and leaves the D/Derivative inert — the established decline convention — in bounded time rather than hanging.

  • A divergent definite integral is no longer reported as a confident measurement. \int_0^1 \frac{1}{x}\,dx numericized to 709.08956571281 ± 0.00000000074 — the value at which the adaptive quadrature's refinement toward the singularity ran out of floating-point range, dressed up as a measured quantity. Numeric integration now checks the series of dyadic shells it sheds while refining toward each endpoint: a convergent improper integral's shells shrink geometrically, a divergent one's do not. Divergent integrals return NaN on all three numeric routes (.N(), iterated .N(), and compiled code), and detection also stops the refinement early, so \int_0^\infty x\,dx resolves in ~1 ms instead of ~560 ms. Legitimate improper integrals — \int_0^1 \frac{1}{\sqrt{x}}\,dx, \int_0^1 \ln x\,dx — are unaffected, as are proper integrals, whose results are bit-for-bit unchanged.

  • The order in which functions are defined no longer changes their meaning. A definition body that referenced a function before it was defined — for example g(t) \coloneq 2a(t) registered ahead of a(t) \coloneq [\cos t, \sin t] — froze the reference as a multiplication (2 \cdot a \cdot t), producing scalar results interpreted and null/NaN from compiled functions behind success: true. Such provisional readings are now re-derived when the referenced name later gains a function definition, so every registration order yields the same interpreted and compiled results. Genuinely scalar juxtaposition (2x(t+1) where x never becomes a function) is unchanged.

  • Function parameters whose bodies index them are now treated as collections. A definition such as h(v) \coloneq v_1 + v_2 with h declared (list<real>) -> real parsed the subscripts as unrelated symbols, an At-indexing body inferred a scalar parameter (so list arguments broadcast elementwise instead of applying), and the JavaScript compile target declined At over a declared list parameter. All three are fixed: parameters are bound at their declared types while the body is parsed, an indexing body infers a collection parameter, and the compile target admits indexing over such parameters with a runtime shape guard.

  • The derivative of trigonometric functions now honors angularUnit. In degree mode, D(Sin(x), x) evaluates to \frac{\pi}{180}\cos x (and inverse trigonometric derivatives are divided by the conversion factor), on both the interpreted and compiled routes, for both by-reference functions (f' where f := x \mapsto \sin x) and inline \frac{d}{dx} expressions. Previously the interpreted derivative and the compiled by-reference derivative returned radian-convention values, and compiled ND double-converted.

  • Sqrt no longer claims a complex result type for a radicand whose non-negativity only constant folding can establish. Machine floats are not folded at canonicalization, so \sqrt{1-0.2^2} reached the type handler with an undecided sign and typed as finite_complex, while the folded \sqrt{0.96} typed as finite_real. A radicand that is pure and has no unknowns is now folded to decide the sign. A negative radicand (\sqrt{0.2^2-1}) still types as complex, and a radicand with unknowns (\sqrt{x}) is unchanged.

  • Declared types are enforced for symbols named with a single uppercase letter. An argument-validation repair intended for bare standard-library operator symbols such as N and D used in value position also fired for any user-declared single-letter symbol that failed a parameter type check, silently skipping the declared-type check. The repair is now gated on the provenance of the shadow it rebinds to.

  • Arguments with free variables are no longer exempt from declared-type checking when their type is provably incompatible. Applying a function whose parameter is, e.g., tuple<number, number, number> to an unassigned symbol declared string (or any provably disjoint type) now reports incompatible-type instead of silently accepting the call. Arguments whose type could still turn out compatible — unknown, inferred symbols, unions with a matching arm, same-category collections — continue to defer to runtime exactly as before.

Performance​

  • GPU compilation no longer re-merges its function table on every compile. The table reached V8's fast-property limit, so the per-call object spread allocated ~45 KB of transient dictionary-mode garbage per compilation; the merged table is now memoized per target instance.

0.100.0 2026-08-02​

Breaking Changes​

  • Assignments now enforce declared types consistently. Declare-with-value, Assign, and ce.assign() all reject values incompatible with an explicit symbol type. Inferred types retain their widening behavior.

  • Purity and effects reporting is more precise. expr.isPure and expr.effects now account for which operands an operator evaluates and for the current bindings of referenced functions. Consequently, held expressions and seeded-random blocks can be pure, function literals do not inherit the effects of calling them, and named effectful callbacks are no longer reported as pure. Unresolved forward references conservatively have unknown effects.

  • Effects from partially applied functions occur only when all required arguments have been supplied. Partial application no longer evaluates an effectful body early or repeats its effects.

  • Contradictory operator declarations are rejected. In particular, pure: true cannot be combined with drawsRandom: true; inconsistent legacy flags, effect annotations, and effects: declarations also fail registration.

  • Errors now propagate through strict built-in operators and function application. An invalid operand generally produces the underlying Error value instead of leaving an inert expression. Collection constructors and error-observing or lazy operators can still contain or inspect errors. Assume returns string status values such as "ok" and "not-a-predicate".

  • Literal Nothing arguments are removed consistently. f(Nothing), Apply(f, Nothing), and Nothing |> f now all behave like f(). An expression that evaluates to Nothing is still passed as an argument. Invalid Pipe callees now return the error directly.

  • Positional collection operators require indexed collections. First, Second, Third, and Last now reject sets and other non-indexed collections. Empty indexed collections still return Missing.

  • Several elementary functions now report complex result types when their real-valued domain cannot be proven. This includes Sqrt, Ln, Log, Arcsec, and Arccsc. Provably in-domain operands retain a real type; provably negative operands use complex helpers when compiled.

New Features​

Functions, types, and effects​

  • Multi-clause function definitions can dispatch by arity, literal value, and parameter type. The most specific clause wins, declaration order breaks ties, and redefining the same parameter domain replaces that clause. Recursive clause sets compile to JavaScript. Partial application of a clause set is not supported.

  • Cortex programs can declare nominal types and structural aliases with type name = … and type alias name = …. Declarations provide checked constructors where appropriate; nominal values are opaque, support structural equality by tag and payload, and can expose named fields with value.field. Record-shaped nominal types can use a same-name constructor function for validation or normalization. Type tags are erased by compiled output.

  • Function signatures can declare effects. Types and Cortex definitions accept pure, any, or effect labels including random, scope, network, time, and file-system effects. Inferred effects follow the function body; explicit effects are checked contracts. Callback parameters can use effect annotations to restrict accepted functions.

  • Operator definitions accept an effects: field. The existing pure and drawsRandom properties remain supported as shorthand.

  • Expressions and function types expose effects directly through expr.effects and type.effects. Effect-discharging operators such as WithRandomSeed can absorb an effect, while Hold defers the effects of its contents until release.

Cortex language​

  • Function definitions accept literal parameters, including strings, booleans, finite numbers, NaN, and infinities. Unicode mathematical symbols such as π, ∞, ⅈ, and ℝ now resolve to their standard constants or sets. Non-finite literal names are reserved; verbatim identifiers remain available when those spellings are needed as names.

  • match supports inclusive numeric range patterns, including negative and infinite bounds. Range patterns can be combined with alternatives and guards and compile on every target.

  • Errors can be handled in Cortex. match can catch error values, and the new held predicate IsError(x) detects errors without propagating them. Propagated errors include a non-rendered trace available through MathJSON and the error-trace APIs.

  • The structural equality operator === now evaluates. It performs total, exact structural comparison, including for symbolic expressions, Missing, and NaN.

  • Count accepts either a value or a predicate, and Table accepts tuple iterator specifications. Tuple and brace iterator forms now behave consistently for Sum, Product, and Integrate.

  • Static type errors are reported by cortex check and before program evaluation. Checking canonicalizes without executing effects.

Compilation​

  • PointList with collection-valued components compiles to JavaScript. GPU coordinate accessors can also project compatible point-list components.

  • At compiles to GLSL and WGSL for statically sized numeric collections, including guarded dynamic scalar indexes and literal gathers or masks.

  • D, Derivative, and ND compile on all targets when they can be reduced to a compilable form.

  • Non-finite numeric literals compile to GLSL and WGSL.

  • Python compilation supports collection equality and inequality. Norm(matrix, 2) now compiles with the interpreter's Frobenius-norm semantics.

  • Added Cortex transition guides for Python and Mathematica users.

Improvements​

  • Unknown Wolfram Language and JavaScript collection-function names now suggest the corresponding Cortex operators.

  • Static result types are tighter for trigonometric, hyperbolic, extrema, Pochhammer, and collection-rank operations when the result can be proven real or finite.

  • User-declared type names now resolve consistently in annotations, signatures, type predicates, collection operators, and MathJSON declarations. Literal value types and bounded numeric refinements now accept their own values.

  • Range and Linspace can enumerate exact symbolic bounds and steps that have a numeric value, such as multiples of π. Bracket range syntax also recognizes more exact arithmetic progressions.

  • Sum and Product evaluate pure, closed bound expressions such as Length(P) while retaining symbolic behavior for genuinely free bounds.

  • Distance accepts the same point-or-point-list union types as related point operators and reports a clear error if a point list reaches scalar distance evaluation, in both interpreted and compiled code.

Performance​

  • Numeric integration retains a sufficiently accurate deterministic quadrature estimate instead of replacing it with a much slower Monte Carlo estimate. This substantially improves nested and difficult integrals.

  • Lazy function-applying collections, including Map, Filter, FlatMap, Scan, Tabulate, and Iterate, memoize evaluated elements per instance. Cache invalidation now follows actual symbol and configuration dependencies, so unrelated assignments no longer discard cached collection elements.

  • Exact evaluation of sufficiently large, bounded integer Map broadcasts can use compiled float64 arithmetic when exactness is statically guaranteed.

  • Compiled function bodies reuse repeated pure user-function calls, avoiding exponential work in recursive definitions. Common-subexpression elimination now also recognizes engine-provided compiled operators.

Issues Resolved​

Parsing and serialization​

  • Chained postfix indexes parse uniformly as nested At expressions for symbols, subscripted expressions, and literal collections.

  • Ranges with compound first anchors, such as n+1..n+10, [2n..3n], or x = m+n...m+n+4, bind the whole anchor expression instead of absorbing part of it into the surrounding addition, multiplication, or negation. This applies in brackets, bare expressions, and relations. A parenthesized range opts out: n+(1..10) remains a broadcast addition.

  • Dictionary-valued function results preserve parameter bindings through MathJSON round trips, including in recursive functions.

  • Roots serialized with solidus or quotient notation now delimit a Power base correctly.

  • Invalid multi-argument Exp and Exp2 expressions remain well-formed inert expressions with an arity error.

Evaluation and functions​

  • At reports incompatible dimensions when more indexes are supplied than a collection can consume.

  • match inside a function uses the current call's parameters rather than retaining values from the first call.

  • Seeded-random frames no longer incorrectly mark enclosing function literals or Map callbacks as random.

  • Purity and effect results for recursive functions are stable and no longer depend on which expression is queried first.

  • Compiled lambdas with unbound symbols, including symbols reached through assigned values or function bodies, decline cleanly instead of throwing a JavaScript ReferenceError. Interpreter fallback numericizes symbolic results and is also available for failed interval compilation.

  • Numeric use of a list-valued function no longer widens its inferred result to a scalar or causes compiled reductions to use scalar arithmetic on arrays.

  • FindFit and FindRoot observe ambient time limits during expensive iterations and return their best result with timedOut: True when possible.

  • Differentiating control-flow or binding operators such as Which, Sum, and Integrate stays symbolic instead of throwing or producing an invalid slot-wise derivative.

Collections​

  • Bounded Take expressions are recognized as finite even when the source length is unknown, allowing finite prefixes of infinite filtered collections to materialize.

  • Bare _ works as the identity function in function slots, and wildcard predicate shorthand is accepted consistently by eager and lazy collection operators.

  • Unary Iterate functions receive the accumulator, and indexed access now agrees with iteration about the first emitted value.

  • Collection operators resolve evaluable numeric arguments such as N-1. They remain symbolic, rather than using a default, when a required numeric argument is unresolved.

  • Partition distinguishes unresolved integer sizes from predicates and no longer throws on an unbound size.

  • Seeded comprehensions materialize consistently with other lazy collections.

Numerical evaluation and types​

  • Numeric roundoff cleanup is independent of ce.tolerance; comparison and explicit Chop operations continue to honor the configured tolerance. Compiled Chop now uses that tolerance, and exact operands remain exact.

  • The reported uncertainty of an iterated numeric integral includes inner-level quadrature error. Previously only the outermost level's own estimate was reported, which could present a result with inner error as exact.

  • Bignum Sin and Cos preserve small representable values near zero crossings.

  • Static types for non-finite, complex, and out-of-domain results were corrected across arithmetic, special functions, elliptic functions, inverse trigonometric functions, and complex component operators.

  • Domain boundaries are classified with exact comparisons for exact literals.

  • Non-radian angle conversion preserves the imaginary component of complex results.

  • LCM(0, 0) returns 0, and SigmaMinus1 preserves exact results under evaluate().

Compilers​

  • Compiled Mod, Remainder, division, and negation now parenthesize compound operands correctly across JavaScript, GPU, and Python targets.

  • Impure operands used by compiled remainder, modulus, and selected GPU functions are evaluated exactly once.

  • Broadcasts over literal lists emit target-appropriate code for JavaScript, Python, GLSL, and WGSL instead of leaking JavaScript syntax into other targets.

  • GPU compilation rejects unsupported alpha colour constructors, non-finite loop bounds, mismatched shapes, and scalar-only operations on vectors or matrices instead of emitting invalid or silently incorrect shaders.

  • GPU Max and Min over one collection reduce to a scalar instead of returning the input vector.

  • JavaScript compilation now broadcasts Sign, Arctan2, Hypot, and Sinc over lists.

  • Complex constant folding is consistent with realOnly: non-real constants produce NaN in real-only mode and principal complex values when complex compilation is supported.

  • Compiled InverseHaversine supports complex results on JavaScript and reports an appropriate complex static type for symbolic inputs.

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE 0.100.0CE 0.99.0SymPymath.jsMathematica
\pi^28.28.72011903.7
\sin 125232535995.8
\cos 124232406687.6
\ln 215153754,9394.1
e^{\pi}15152385,3755.1
\zeta(3)1,7071,716290—54
\Gamma(\tfrac13)915933382—233
\psi(\tfrac13)7977863,051—192

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case; ✓ means it solves a case Mathematica can't. Compare the CE 0.100.0 and CE 0.99.0 columns to see what is new this release (a — under 0.99.0 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE 0.100.0CE + R/FCE 0.99.0SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx3.5×1.8×2.8×0.4×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx6.4×1.0×5.7×0.08×—1×
\int\frac{1}{x^3+1}\,dx3.3×0.4×2.7×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—1.5×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—0.9×—0.009×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—0.8×—0.006×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.03×0.03×0.03×0.001×0.003×1×
Simplification
\sqrt{3+2\sqrt2}40×29×28×——1×
\sqrt6\,x+\sqrt2\,x73×38×51×3.1×16×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}36×17×34×3.0×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x4.9×3.6×4.7×2.2×—1×
\int_1^2\tfrac1x\,dx18356×20053×17734×310×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx258×110×251×2.6×—1×
Solving
x^4+x^2-1=00.3×0.2×0.3×0.06×—1×
x^3-x-1=01.4×1.6×1.3×0.04×—1×

Across the cases both solve, Compute Engine is a median 3.5× faster than Mathematica (up to 18356×) — in the browser, not a proprietary kernel.

Measured 2026-08-02 · Compute Engine 0.100.0 (current build @ 14fc06c2) · published 0.99.0 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.99.0 2026-07-30​

New Features​

  • User-defined functions now compile to GLSL and WGSL. A function declared in the engine (f(u,v) := …) and called from a GPU-compiled expression is emitted once as a real shader function and called by name — matching what the JavaScript and interval targets already did — instead of failing as an unknown operator and forcing callers to inline the body at every call site. Definitions arrive on the compilation result's preamble alongside the _gpu_* helpers, so existing shader assembly keeps working; when f calls g, g is declared first. Signatures are synthesized statically (declared parameter types, or the body's inferred shape: float, bool, vec2–vec4, complex as vec2); anything a shader cannot express fails closed with a diagnostic naming the parameter — including recursion (GLSL/WGSL forbid it; it still compiles on the JavaScript target), collection-valued arguments beyond the static vec2–vec4 shapes, and argument shapes that disagree with the declared parameter type. Undeclared parameters default to float. Caller-declared types on compileFunction parameters and on shader inputs/uniforms are authoritative for these checks, with element-aware matching across both languages' spellings (bvecN, ivecN, vec2<i32>, …) — a vec2<bool> argument no longer passes for a vec2<f32> parameter. A WGSL body referencing a declared input now correctly emits input.<name> (previously a bare, undeclared identifier).

Performance​

  • Compiled code now shares repeated subexpressions (common-subexpression elimination) on the javascript, interval-js, and python targets: a pure subtree occurring several times inside one compiled expression is bound to a temporary once and reused, instead of being recomputed at every site. Corpus-extreme shapes (a 500-node subtree repeated 128×) compile ~50% faster because the emitted source collapses, and run up to ~1.9× faster when the repeats involve runtime helpers the JS engine cannot eliminate itself. Sharing is conservative by construction: random draws, user-defined function applications, named callbacks, caller-supplied custom lowerings, and anything inside a conditionally-evaluated position (unselected Which/If arms, short-circuited And/Or tails) are never merged, so values, draw streams, and selection laziness are unchanged. Opt out per call with compile(expr, { cse: false }). Design notes: docs/plans/2026-07-28-compile-cse-design.md.

  • Compiled output is now byte-for-byte deterministic on every target: compiler-generated temporaries (chained-relation operand bindings, loop accumulators, complex power chains — and the new CSE temps) draw deterministic _tvN/_cseN names from a per-compilation counter instead of Math.random(), and the allocator avoids capture against every symbol in the expression. Two compilations of the same expression now emit identical source, on the GPU targets included.

  • Random draws are up to 100x faster when the engine runs inside a secondary JavaScript realm — a vm context, a sandboxed worker, or an embedder-supplied global. V8 compiles Math.imul(...) down to a single machine instruction only when Math is the host realm's; reached through another realm's global it becomes a property lookup plus a call, and the PCG3D hash behind every draw performs six of them. Binding the function once at module scope removes the lookup. Measured on node 22: 10 million draws went from ~880 ms to ~40 ms in a vm context (and from ~36 s to ~0.5 s for a 10-million-sample Monte-Carlo integral under Jest). Draw values are unchanged — the stability vectors are untouched.

Resolved Issues​

  • A Leibniz derivative no longer swallows the comparison that follows it. \frac{d}{dx}x^2 > 0 parsed as D(x^2 > 0, x) — the derivative of a boolean — which then evaluated to an incompatible-type error. The operand of \frac{d}{dx} / \frac{\partial}{\partial x} is now parsed as a term: it still takes a trailing sum (\frac{d}{dx}x^2 + 1, matching the \int … dx integrand convention), but stops before a relational, assignment or arrow operator, so the expression parses as D(x^2, x) > 0.

  • An undecidable comparison no longer discards its evaluated operands. x^2 + x^2 > 0 evaluated to 0 < x^2 + x^2 and \frac{d}{dx}x^2 > 0 to 0 < D(x^2, x): Equal, NotEqual, Less and LessEqual evaluate their own operands (they are lazy, so their canonical handlers can see raw operands for chain decomposition), then threw that work away when the comparison itself could not be decided. They now report the evaluated operands — 0 < 2x^2 and 0 < 2x — which is what the non-lazy relations (Approx, Tilde, Precedes…) already did. The comparison itself is unchanged: an undecidable one still stays inert, since x^2 = 4 is a condition rather than a falsity, and decidable ones still fold to True/False.

  • Monte-Carlo integration and stochastic equality now replay under WithRandomSeed(). Both sampled Math.random() directly, so a seeded block did not reproduce: WithRandomSeed(42, \int_0^1 \sin(1/x) dx) returned a different estimate on every evaluation, and a seeded isEqual() verdict could not be replayed at all.

    They now draw from a derived sub-stream — a private stream seeded from the ambient frame that consumes none of its indices. This matters because an integral takes up to 1e7 samples and the sampling loop is deadline-truncated: charging those to the frame would make adding an integral shift every later Random() draw in the block, and would make replay depend on wall-clock time. Adding or removing an integral now leaves sibling draws untouched, and the same integral samples the same points wherever it appears in the frame.

    Outside a frame both remain live, as before. monteCarloEstimate() (exported from @cortex-js/compute-engine/numerics) gains an optional trailing draw parameter defaulting to Math.random, so existing callers are unaffected.

    This applies to evaluation, not to compiled code. An integral inside a compiled function still samples live, even within a seed frame, and that is deliberate: the generated code emits one independent quadrature call per limit, so a sub-stream would restart at the same sample points on every outer node of a nested integral — trading reproducibility for a biased estimate. In practice a smooth integrand never reaches the stochastic estimator anyway (it folds to a constant at compile time, or converges under deterministic Gauss–Kronrod); only a pathological one does. Use .N() when a seeded integral has to reproduce. See docs/plans/2026-07-28-derived-substreams.md §5.1.

    An integral that cannot finish inside a seed frame — a bound or parameter is still unbound — now keeps the frame, so completing it later reproduces the estimate the frame would have produced. Operator definitions gain a readsRandomFrame flag for this: it means "reads the seed frame, consumes none of its indices", and is inferred for a user function whose body reaches an estimator. Unlike drawsRandom, it does not make the operator impure.

  • A collection operator now resolves an unevaluated numeric argument. Collection handlers are consulted on the canonical expression — .at(), .each() and .count are available on any canonical expression, and the broadcast that zips Add/Multiply operands runs before they are evaluated. An argument spelled N-1 was therefore still an unevaluated sum at that point, and each operator silently fell back to its default: with N assigned 6, RotateLeft(S, N-1) + RotateLeft(S, N-2) returned 2·RotateLeft(S, 1), and Take(S, N-2) + Take(S, N-2) returned nothing at all. Literal arguments were never affected. Fixed for Take, Drop, RotateLeft, RotateRight, Repeat, Fill, Partition, Tabulate, Insert, DeleteAt, ReplaceAt, Slice, Permutations and Combinations.

  • Take, Drop, RotateLeft, RotateRight and Slice now stay symbolic when their numeric argument has no value. An argument that is absent and one that is present but unresolved (a free variable) were treated alike, so each operator answered a collection it does not denote: Take(S, n) returned [], Drop(S, n) returned all of S, and RotateLeft(S, n) and RotateRight(S, n) rotated by one. They now report an unknown count and evaluate to themselves until n has a value — matching Repeat, Insert, DeleteAt, ReplaceAt, Permutations, Combinations, Tabulate, Chunk and Range, which already did. An omitted argument still takes the operator's default, so RotateLeft(S) still rotates by one; a NaN argument counts as unresolved, not omitted. Fill joins them: Fill(f, (n, 3)) used to produce an empty matrix and Fill(f, (2, n)) two empty rows. IsEmpty of such a Take over an infinite collection no longer answers False either — a zero count would make it empty.

    Membership is deliberately unaffected: Contains(RotateLeft(xs, n), x) still answers, because a rotation is a permutation.

  • Partition(xs, n) with an unbound n no longer throws. Partition accepts either a chunk size or a predicate, and chose the arm by whether the size read as an integer — so an argument that is typed integer but has no value yet was applied as a predicate, and the resulting exception escaped evaluate(). The arm is now chosen by type, and an unresolved predicate (a symbol declared function with no value) leaves the expression unevaluated as well. A predicate that does resolve, to something other than a boolean, still reports the error with its spelling hint.

  • WithRandomSeed(seed, [… for …]) draws again. A comprehension is a lazy view, like Map: its body draws per element when the collection is materialized. The rule that keeps the seed frame around a body that could not finish its draws (0.98.0) read the comprehension's unevaluated body as unfinished work, so the expression evaluated to itself — the collection was inert and yielded no elements, and nothing would ever complete it. A comprehension now follows the same convention as Map: materialize it inside the frame (WithRandomSeed(1, ListFrom([Random() for k = [1...6]]))) for reproducible draws. A comprehension whose clause still owes draws keeps the frame, as before.

  • A user-defined function now infers its pure and drawsRandom flags from its body. Previously every user function was born pure, so f() := Random() reported isConstant: true while drawing from the random stream on every call. Two things broke as a result: N(f() + \pi) consumed two draws where N(\mathrm{Random}() + \pi) consumed one, and a partially evaluated WithRandomSeed() body calling f lost its seed frame — silently resuming with live, unseeded draws.

    The flags are derived from the heads the body applies: a body reaching a known-impure head is impure, and one reaching a stream-drawing head also draws. A head with no definition at the point of definition — a higher-order parameter (f(g) := g()), or a callee defined after its caller — is still assumed pure; set the flag explicitly on the definition when that matters.

  • RandomPrime() is now reproducible under WithRandomSeed(). It declared drawsRandom: true, but its draws bypassed the seed frame, so WithRandomSeed(42, RandomPrime(1000)) returned a different prime on every evaluation. It now draws from the frame like the rest of the random family.

  • RandomExpression() is now declared impure. It was declared pure, which made isConstant true for a generator that returns a different expression on every call, and admitted it to common-subexpression elimination.

Improvements​

  • A loop-form Sum/Product can now be a sub-expression in GLSL and WGSL. A shader has no expression-level loop, so a Sum with a symbolic bound was emitted as statements — valid as a whole function body, but nothing more: \sum_{k=0}^{n}kx compiled while 1 + \sum_{k=0}^{n}kx and 0.03\sum_{k=0}^{n}kx failed closed, which demoted the whole class to the CPU path (corpus rows are almost never a bare sum). The loop is now hoisted ahead of the value it feeds and referenced through its accumulator, so these compose. Constant bounds still unroll to an expression as before, and a loop nested inside an unrolled term hoists alongside it. Nested sums hoist into their enclosing loop body, not out of it.

    A loop inside a conditionally-evaluated branch (an If/When/Which/ Match arm) still fails closed, with a diagnostic that says so. A shader conditional is an expression, not a statement, so hoisting the loop out of the branch would run it unconditionally — and because a compiled Random() advances a counter at run time, a loop stranded ahead of a branch it never feeds would change the value of every later draw.

    Loop and Block still fail closed as sub-expressions on these targets.

  • A compile decline now names its actual cause. Unknown operator \X` was reported for three different situations, so a failing compile band could not be triaged by message. They are now distinct: an operator whose compile handler declined a particular _operand shape_ says which operand and why (PointList: cannot compile — component 1 is collection-valued …); a head the engine knows but the target cannot lower says so (Integrate: cannot compile — the operator is known to the engine but target 'glsl' has no lowering for it.); and Unknown operatoris now reserved for a head with no operator definition at all. The reason reaches callers asCompilationResult.erroron thesuccess: false` paths, and as the thrown message on the direct-target path.

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE (current)CE 0.98.0SymPymath.jsMathematica
\pi^26.87.41731063.9
\sin 120212184425.3
\cos 120212195737.1
\ln 214143324,3443.8
e^{\pi}12132164,7514.7
\zeta(3)1,5111,547265—49
\Gamma(\tfrac13)831812346—213
\psi(\tfrac13)7127122,762—171

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case; ✓ means it solves a case Mathematica can't. Compare the CE (current) and CE 0.98.0 columns to see what is new this release (a — under 0.98.0 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE (current)CE + R/FCE 0.98.0SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx4.6×2.5×3.5×0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx8.3×1.5×6.6×0.09×—1×
\int\frac{1}{x^3+1}\,dx5.3×0.9×4.2×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—1.9×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—1.1×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—1.1×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.04×0.04×0.03×0.001×0.004×1×
Simplification
\sqrt{3+2\sqrt2}41×28×31×——1×
\sqrt6\,x+\sqrt2\,x79×45×48×3.1×19×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}39×16×34×3.0×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x5.2×4.0×6.9×2.1×—1×
\int_1^2\tfrac1x\,dx4458×4875×4193×77×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx316×128×274×2.5×—1×
Solving
x^4+x^2-1=00.3×0.3×0.3×0.07×—1×
x^3-x-1=01.6×1.8×1.4×0.04×—1×

Across the cases both solve, Compute Engine is a median 5.2× faster than Mathematica (up to 4458×) — in the browser, not a proprietary kernel.

Measured 2026-07-30 · Compute Engine 0.98.0 @ ef394659 (current build) · published 0.98.0 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.98.0 2026-07-28​

New Features​

  • Which and If now broadcast over list-valued conditions. Selection is performed element by element, with the first matching clause winning. Scalar conditions and results are broadcast as needed, and list-valued inputs must have the same length. Results are evaluated at most once and only when needed by at least one element. A position with no matching clause returns NaN.

    Which([3, 2, 1, 3] == 3, 1, True, 0) → [1, 0, 0, 1]

    JavaScript compilation supports the same behavior. GLSL and WGSL compilation support fixed-size vectors of two to four elements.

  • New operator-definition attribute drawsRandom marks operators that consume the engine's random stream (Random, RandomShuffle, …). It is narrower than pure: false, which also covers side-effecting operators such as Assign, and is used by WithRandomSeed to decide whether a partially evaluated body still owes draws to its seed frame.

Breaking Changes​

  • Multiplying lists of different lengths now returns an incompatible-dimensions error, consistent with other element-wise operations. Matrix multiplication is unchanged.

  • Assigning a value to a builtin operator name now creates a symbol in the current scope instead of replacing the builtin globally. For example, ce.assign("Sin", 30) changes the value of the symbol Sin, but Sin(0) continues to call the builtin function.

  • The unused BindingSite.shield field has been removed. Setting it had no effect.

Improvements​

  • JavaScript compilation now supports more collection-valued expressions: user-defined function calls, comparisons and logical operators, and indexed Sum/Product bodies all broadcast element-wise. Length mismatches compile to NaN.

  • Unsupported collection-valued conditions and vector operations now fail compilation with a diagnostic instead of producing incorrect Python, interval-js, GLSL, or WGSL code.

  • RandomChoice now infers more precise result types, including finite_real for finite intervals and finite_integer for integer ranges.

  • Repeated compilation with the same external target is now deterministic and produces identical generated code.

  • Evaluating chained broadcast operations over lazy collections is faster: approximately 3× faster with evaluate() and 4× faster with .N() at default precision in the benchmark used for this release.

Issues Resolved​

  • Partially evaluated WithRandomSeed expressions now retain their seed, so later substitutions produce the same result as substituting before evaluation.

  • Nested N and Evaluate expressions now preserve numeric approximation and precision. For example, N(Evaluate(pi)) now returns a numeric value instead of the symbolic pi.

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE (current)CE 0.97.0SymPymath.jsMathematica
\pi^27.98.52032884.5
\sin 123232535986.0
\cos 121222537818.1
\ln 216163865,4714.7
e^{\pi}15152737,4775.9
\zeta(3)1,7381,702337—56
\Gamma(\tfrac13)924913407—268
\psi(\tfrac13)7817903,496—199

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case; ✓ means it solves a case Mathematica can't. Compare the CE (current) and CE 0.97.0 columns to see what is new this release (a — under 0.97.0 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE (current)CE + R/FCE 0.97.0SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx4.5×2.2×3.5×0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx8.1×1.6×6.8×0.08×—1×
\int\frac{1}{x^3+1}\,dx4.9×0.6×3.7×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—1.7×—0.08×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—1.0×—0.008×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—1.0×—0.006×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.04×0.03×0.03×0.001×0.004×1×
Simplification
\sqrt{3+2\sqrt2}37×27×31×——1×
\sqrt6\,x+\sqrt2\,x74×42×44×3.1×17×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}41×17×38×2.9×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x7.2×4.6×6.8×1.9×—1×
\int_1^2\tfrac1x\,dx5778×6836×5520×107×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx352×146×298×2.7×—1×
Solving
x^4+x^2-1=00.3×0.3×0.3×0.05×—1×
x^3-x-1=02.2×2.4×1.9×0.05×—1×

Across the cases both solve, Compute Engine is a median 4.9× faster than Mathematica (up to 5778×) — in the browser, not a proprietary kernel.

Measured 2026-07-28 · Compute Engine 0.97.0 @ bff3c3b1 (current build) · published 0.97.0 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.97.0 2026-07-27​

Breaking Changes​

  • A broadcast operand is evaluated ONCE. Broadcasting is an operation on values (the NumPy/Julia/R model), so an operand that is lifted into every cell is evaluated a single time and the operation then maps over the cells:

    L < Random() // ONE draw, compared against every element of L
    Map(L, l ↦ l < Random()) // a draw per element, written explicitly

    Comparisons and logical connectives used to re-evaluate a lifted operand per element, so [0.5, 0.5, 0.5] < Random() could answer [True, False, True]. Arithmetic (L + Random()) already drew once; the disagreement was an implementation artifact of the element-wise zip, not a semantic. Only impure scalar operands under a broadcast are affected — an impure operand that IS the traversed collection ([Random(), Random()] < 0.5) still draws per cell, because those draws are the cells.

  • A length mismatch in a broadcast is an error, not a truncation. Zipping to the shortest operand silently discarded the tail of the longer one:

    [1,2,3] < [2,2] // was: [True, False] now: incompatible-dimensions

    Add already answered incompatible-dimensions for this shape, so the engine disagreed with itself depending on the head. One check now governs every broadcast path — the eager zip, the arithmetic broadcast, and the lazy Map form — so the ordering relations, the logical connectives, Divide/Power/Mod, Add/Multiply, and ElementMax/ElementMin/ Clamp all answer alike. In particular the SIZE of a collection no longer decides the semantics (a mismatch used to error below the eager threshold and truncate above it), and neither does the shape of the source: Add(Filter(…), L) used to truncate where Less on the same operands errored.

    An unbounded operand against a finite one is a mismatch too (count is Infinity, which agrees with no finite length). A scalar operand is a LIFT, not a participant, so it never mismatches; an operand whose length is not yet known is not compared, since there is nothing to compare until it resolves. An empty operand alongside a non-empty one is a mismatch, while a lone empty operand still broadcasts to Nothing (Not([])).

    PointList is deliberately unaffected: it ZIPS components rather than broadcasting an operator over them, and its shortest-zip (PointList([1,2,3],[10,20]) → two points) is an existing consumer contract. The general rule: an operator LIFTED over collections requires length agreement, while an explicit PAIRING constructor (Zip, the variadic Map, PointList) defines its length as the shortest input. See docs/BROADCAST-MODEL.md for the full policy.

New Features​

  • Compiled comparisons and logical connectives broadcast element-wise. The ordering relations (<, <=, >, >=) and the connectives (And, Or, Not) compile over collection-valued operands on the JavaScript target instead of failing closed, so the Desmos filter form compiles rather than falling back to the interpreter:

    compile(ce.parse('[10,20,30][|[1...3]-k|>0]')).run(); // [10, 30]

    These heads lower to raw JS infix operators, which are silently wrong on an array (0 < [1,0,1] stringifies it; an array is truthy, so m1 && m2 returns a whole operand). They now wrap the head's own scalar codegen in the _SYS.bcast runtime helper, which recurses per POSITION — an empty or mismatched position projects to NaN without poisoning its siblings (Not([[], [True]]) → [NaN, [false]], matching the interpreter's [Nothing, [False]]).

    The scalar path is untouched: x < 3 with an unknown-typed plot variable still emits _.x < 3, with no runtime guard. Three shapes deliberately keep failing closed, because the compiled answer would disagree with interpretation: a CHAINED ordering (0 < xs < 5, whose pairwise && is sound only over scalars); Equal/NotEqual over two collections (whole- collection equality, which keeps its _SYS.eq dispatch and stays element-wise for the list-vs-scalar case); and an operand that types as a list but does not COMPILE to one — notably a user-function application over a collection argument (q(L) < y), since a user function's body compiles as scalar code and returns NaN on an array, which a comparison would turn into a plausible false.

Issues Resolved​

  • Covariance/Correlation report a length mismatch as incompatible-dimensions. They were already strict — a ragged pair of data collections errored — but with their own unexpected-argument ("collections differ in length") rather than the incompatible-dimensions error every broadcast path answers. One error tag now covers every length-mismatch diagnosis (see docs/BROADCAST-MODEL.md). The other argument errors (at least 2 data points required, the shape error, zero variance) are unchanged.

  • Multiply over operands of mixed collection kinds is element-wise again. A List literal packs as a tensor value while a Range/Filter/Take/ Reverse result does not, so the non-tensor operand was classified as a SCALAR factor — and applying a scalar multiplies every CELL by it, turning each cell into a list:

    [1...3] · [4,5,6] // was: [[4,8,12],[5,10,15],[6,12,18]] now: [4,10,18]
    Range(1,3) · [1,2,3] // was: [[1,2,3],[2,4,6],[3,6,9]] now: [1,4,9]
    Range(1,3) + [1,2,3] // [2,4,6] — `Add` was always element-wise

    Not a length problem: it misfired at matched lengths, and same-kind pairs (List×List, Range×Range) were always element-wise, because neither operand reached the scalar bucket. Add avoids it by declining its tensor kernel when fewer than two operands pack; Multiply now declines when a non-tensor collection would land among the scalars, and falls through to the same element-wise broadcast — which also brings mixed-kind mismatches under the length ruling above. Scalar×list scaling, matrix products, and component-wise tuple scaling are unchanged.

  • A symbol bound to a derived collection serializes as its name again. Regression in 0.96.0. .latex and toString() materialize a lazy collection before serializing, and a symbol whose assigned value is a derived collection (the result of evaluating a Join, a comprehension, …) delegates isLazyCollection to that value — so the symbol serialized as its materialized value while .json still answered the symbol's name:

    ce.assign('L', ce.box(['List', 1, 2, 3]));
    ce.assign('L', ce.box(['Join', 'L', ['List', 4]]).evaluate());
    ce.symbol('L').json; // 'L'
    ce.symbol('L').latex; // was: '\bigl\lbrack1, 2, 3, 4\bigr\rbrack' now: 'L'

    A literal List value (eager, not lazy) never triggered it, which is what made the failure value-provenance-dependent and hard to spot: a consumer that serialized a symbol to persist a document wrote the value where the name belonged. A symbol now never takes the materialize-before-serialize path — a name's spelling does not depend on what the name currently holds. Lazy collection expressions (Range, Map, comprehensions) serialize as before. Reported by the Tycho team.

  • Add and Multiply fold a Measurement operand on the first .N(). Every unary/binary arithmetic head already folded a quadrature result (Power, Divide, Sqrt, Sin, Negate, Abs of a Measurement yield another Measurement), but the two n-ary heads checked for Measurement operands against the plainly evaluated operands. A quadrature operand only becomes a Measurement under numeric approximation — \int_0^1 \sin x\,dx evaluates to the exact 1 - \cos 1 — so the check saw nothing, and the first .N() returned an inert Add/Multiply whose .re was NaN (the same dead numeric read fixed for Measurement itself in 0.96.0, one level up). A second .N() folded it:

    const once = ce.parse('1 + \\int_0^1 \\sin(x) dx').N();
    once.re; // was: NaN now: 1.4596976941318605
    once.operator // was: 'Add' now: 'Measurement'

    The handlers now re-dispatch the Measurement fold on the numericized result, so the parse route agrees with the (already correct) ce.box(['Add', <measurement>, 1]).N() route exactly — value and error bar. The sibling Quantity check in the same handlers had the identical structural gap (an operand that only becomes a Quantity under numeric approximation was invisible to it); it is closed the same way. evaluate() is unaffected and still returns the exact symbolic form. Reported by the Tycho team.

  • Compiling a definite integral that cannot close symbolically is now bounded. The JavaScript compilation target first attempts to resolve an Integrate to a closed form (so a plotted ∫₀ˣ f costs ~µs per sample instead of a quadrature); that attempt ran under whatever deadline the caller had armed — and under none by default. An integrand with a symbolic exponent (y^{k/2-1} with k a free document parameter) sent the integration-by-parts search into a cycle with no shrinking measure: bounded in depth but not in cost, it did not return in over 5 minutes, synchronously, with no way to interrupt it:

    // χ² tail with shape parameter k left free: >5 min → ~2 s, success: true
    ce.getCompilationTarget('javascript')
    .compile(ce.parse('\\int_x^\\infty \\frac{e^{-y/2} y^{k/2-1}}{(k/2-1)!\\, 2^{k/2}} dy',
    {strict: false}), {realOnly: true});

    The antiderivative-first attempt now arms its own 2-second span (an enclosing caller span still tightens it, per the timeout model's min() nesting — it can only shorten, never extend, a caller's bound) and degrades to the GK15 quadrature emitter on expiry, so the compiled integral samples numerically with the parameter bound at run time — which is the desired behavior for a non-elementary integrand. Note that ce.timeLimit was retired in 0.89.0: to bound evaluate() itself, use ce.withTimeLimit({ms, label}, () => …), which this shape honors to the millisecond. Reported by the Tycho team.

  • Compiled Factorial of a non-integer computes Γ(x+1) instead of NaN. The interpreter has always extended the factorial to the reals ((\frac12-1)! → Γ(\frac12) = √π), but the compiled runtime used the integer-only helper, so the same expression compiled successfully and returned NaN — silently zeroing out, e.g., a χ² density's normalizing constant (k/2-1)! at odd k:

    const f = ce.getCompilationTarget('javascript')
    .compile(ce.parse('(\\frac{1}{2}-1)!'));
    f.run({}); // was: NaN now: 1.7724538509055159 (= √π, identical to .N())

    Non-integer arguments route through the same gamma the interpreter uses, so compiled and interpreted values are bit-identical; the non-negative integer fast path is unchanged, and a negative integer stays at the Γ pole (NaN, the real projection of the interpreter's ComplexInfinity). The Python target likewise now emits scipy.special.gamma(x + 1) instead of scipy.special.factorial (which answers 0 for a negative non-integer). The GPU targets already extended via Γ; the interval target remains deliberately integer-only. Reported by the Tycho team.

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE (current)CE 0.96.0SymPymath.jsMathematica
\pi^27.37.51801103.9
\sin 120212184395.2
\cos 120202214567.1
\ln 214143474,4063.7
e^{\pi}13132144,6554.5
\zeta(3)1,5341,562272—49
\Gamma(\tfrac13)840834350—213
\psi(\tfrac13)7247202,776—174

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case; ✓ means it solves a case Mathematica can't. Compare the CE (current) and CE 0.96.0 columns to see what is new this release (a — under 0.96.0 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE (current)CE + R/FCE 0.96.0SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx4.6×2.3×3.4×0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx7.9×1.5×6.1×0.08×—1×
\int\frac{1}{x^3+1}\,dx5.2×0.8×4.0×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—1.9×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—1.1×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—1.1×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.04×0.03×0.03×0.001×0.003×1×
Simplification
\sqrt{3+2\sqrt2}42×29×29×——1×
\sqrt6\,x+\sqrt2\,x77×45×51×3.0×18×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}15×16×33×3.1×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x7.3×4.9×6.8×2.2×—1×
\int_1^2\tfrac1x\,dx4819×4740×4259×80×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx293×124×250×2.6×—1×
Solving
x^4+x^2-1=00.3×0.3×0.3×0.06×—1×
x^3-x-1=01.7×1.9×1.5×0.04×—1×

Across the cases both solve, Compute Engine is a median 5.2× faster than Mathematica (up to 4819×) — in the browser, not a proprietary kernel.

Measured 2026-07-27 · Compute Engine 0.96.0 @ 9eb6538a (current build) · published 0.96.0 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.96.0 2026-07-26​

Breaking Changes​

  • The 0.95.0 random-family tombstones are deleted, completing the one-release migration window. In 0.95.0, evaluating a removed head (RandomInteger, RandomList, RandomSeed, Sample, Shuffle) threw an operator-removed error naming its replacement, and ce.randomSeed was a throwing accessor. Those guards are now gone: the removed heads behave like any other unrecognized operator — a valid, inert expression — and randomSeed is no longer a property of the engine. Migrate on 0.95.0 (where every legacy call site fails loudly with its replacement named) before adopting this release; the migration table is in the 0.95.0 notes below.

  • evaluate() on a symbol now resolves the free symbols of its stored value. A symbolic value was returned verbatim, so a symbol assigned after it was stored never reached it:

    let d = 3x^2 + 1
    let x = 2
    d // was: 3x^2 + 1 now: 13
    N(d) // 13 — unchanged

    N() and compile() already resolved it, so plain evaluate() was the outlier and disagreed with both; a second evaluate() used to resolve one more level ("one-evaluate-late"). Assignment remains eager, so declaration order still decides what a value snapshots: let x = 2; let d = 3x^2 + 1; x = 3; N(d) is 13, while let d = 3x^2 + 1; let x = 2; x = 3; N(d) is 28. The residual for a cyclic binding is unchanged (s = s + 1; s → s + 1).

  • A stored value's free symbols are no longer captured by a same-named parameter. They now denote the binding they were canonicalized against, not whatever an inner scope calls that name:

    let a = x + 1
    g(x) = a
    g(5) // was: 6 now: x + 1
    N(g(5)) // was: 6 now: x + 1

    With a global x = 100, g(5) is 101 — the lexically correct binding — where it used to be 6. This closes the same defect on three paths that disagreed with each other: the parameter substitution applied after a call, the constant dereference path, and the numeric (N) re-evaluation inside a call frame. A dictionary-valued symbol is covered too. Behavior that was already correct is unchanged: a block-local let does not leak into a stored value, and a renamed parameter never captured.

  • Only a value SHIELD hides a stored value's own binding. Dereferencing a symbol's stored value deferred to the ambient lookup whenever ANY valueless shadow of a free symbol's name was in scope. That was a proxy for the shield idiom — Solve, D, Integrate, Limit and simplify() all hide a symbol's value by shadow-declaring it valueless — and it swept in ordinary declarations, which shield nothing:

    a = x + 1 // x still valueless: a captures the global x
    x = 100
    a + 5 // 106 — unchanged
    Block(Declare(x, "real"), a + 5) // was: x + 6 now: 106

    a captured a binding; the inner Declare created a different variable, and a different variable has no business intercepting. The asymmetry that made the old rule indefensible: give that shadow a value — Block(Declare(x, "real"), Assign(x, 7), a + 5) — and it already did not intercept, evaluating to 106 before and after. The genuine shields are unaffected: with a global x = 100, Solve(x + 1 = 0, x) is still [-1], and simplify() remains value-blind.

  • A union type is assignable only to a target that covers EVERY arm. isSubtype() (and therefore type.matches()) used the any-branch rule when the target was a composite type and the all-branch rule when it was a primitive, so the answer depended on the shape of the target: list<tuple<number,number,number>> | tuple<number,number,number> matched tuple<number, number, number>, while number | list<number> matched neither number nor collection. Assignability is now all-branch throughout — the dual of the intersection rule, where A & B is assignable as soon as one arm is. A union still matches any target covering all its arms (integer | real ⊑ real, number | list<number> ⊑ collection | number).

    Code that asked unionType.matches(T) to mean "could this be a T" was asking the wrong question and now gets false; ask it the other way round (ce.type(T).matches(unionType) — "would a T satisfy this"), which is how the matrix-inference repair gate on union-typed parameters such as LinearSolve's matrix | vector is now spelled.

Issues Resolved​

  • Adaptive quadrature no longer reports a sharply-peaked integral as a converged zero. The adaptive loop started from a single 15-node panel over the whole interval, and could never recover from a first panel that read as zero: with every node returning ~0, the Gauss/Kronrod difference also vanished, met the absolute tolerance, and the integral "converged" after 15 evaluations. The witness is a peak far narrower than the interval whose weight vanishes at the center node:

    ce.parse('\\int_{-50}^{50} x^2 \\frac{1}{\\sqrt{2\\pi}} e^{-x^2/2} dx').N();
    // was: 3.2e-21 ± 5.1e-21 (true value: 1)
    // now: 1.0000000000000002

    The whole Gaussian moment family failed this way (moments 1, 2, 4 and 6 were each 100% wrong while claiming an error bar around 1e-21); the bare ∫φ over the same interval was correct, because φ(0) is sampled. Quadrature now starts from 16 equal panels, which fixes the family exactly and brings the comb ∫₋₁₅¹⁵ φ(x)³⁵⁰ to 0.14% relative error. This is a mitigation, not a guarantee — a peak narrower than a starting panel can still be missed, and the error estimate still cannot report it — a peak-discovery strategy (shifted or low-discrepancy probes) would close the family rather than move the threshold, and is not attempted here. Iterated integrals reduce the per-level count so the floor applies to the whole integral rather than multiplying per dimension. The cost falls on integrals that used to converge on the first panel: ∫₀¹ sin x goes from 15 to 240 evaluations, so a compiled integral called per plotted sample goes from ~40 µs to ~130 µs. Reported by the Tycho team.

  • A definite integral whose integrand is identically non-finite now fails fast instead of burning the full Monte-Carlo budget. Adaptive quadrature can never converge on a NaN integrand, so it fell through to the Monte-Carlo estimator, which spent 1e7 samples — 250–450 ms per call — to return the NaN that was decidable up front. The usual trigger is an unbound variable reaching a compiled artifact as undefined; a plotted curve then froze the thread and drew nothing. monteCarloEstimate now probes a few samples and returns NaN immediately when they are all non-finite. Measured: 100 calls on such an integrand went from 30.5 s to 22 ms. Integrands with a genuine endpoint singularity are unaffected — the bail requires every probe to be non-finite. Reported by the Tycho team.

  • .N() of a definite integral with a free symbol in the integrand no longer throws a raw ReferenceError out of generated code. The integrand was handed to the implicit compiler regardless, and the generated body read the free symbol from a scope slot the numeric caller never supplies:

    ce.parse('\\int_0^1 (x+q)\\,dx').N();
    // was: throws ReferenceError: _ is not defined
    // now: stays symbolic

    A parameter with no value leaves nothing to integrate numerically, so the expression now stays symbolic; it evaluates as before once the symbol has a value. Both the single-limit and the iterated multi-limit paths are covered.

  • A Measurement now answers the numeric accessors. Measurement(value, error) types as its nominal's scalar type, so Integrate(…).N() reports finite_real and isNumber === true — but every numeric read was dead, which silently poisoned any consumer reading .re, and propagated, since Measurement + 1 is another Measurement:

    const n = ce.parse('\\int_0^1 \\sin(x) dx').N();
    n.re; // was NaN, now 0.45969769413186023
    n.numericValue; // was undefined, now the nominal
    n.valueOf(); // was the string '0.4596… ± 0.0000…', now the number
    n.sgn; // was undefined, now 'positive'

    .re, .im, .bignumRe, .bignumIm, .sgn and valueOf() project the nominal — all of them on the public Expression type, so .re is the channel and no cast is needed. The uncertainty stays reachable through the MathJSON, or through op2 after narrowing with isFunction(), and toString() still shows ±.

    Three members are deliberately NOT projected, all for the same reason: a Measurement is a function expression, and the number-literal surface belongs behind the isNumber() guard, which narrows on expression kind. numericValue stays undefined — it is declared only on NumberLiteralInterface, and projecting it would advertise an exact numeric representation that a quadrature result does not have; isNumberLiteral and the isNumber() guard stay false; and .value stays undefined (it is the expression for a literal and undefined for a symbolic expression). .re/.im are sufficient for a Measurement precisely because its nominal is never an exact number. Reported by the Tycho team.

  • A shorthand-lambda placeholder in a pipeline stage no longer picks up a same-named global. Pipe is lazy, so it canonicalizes its right operand in the caller's scope — before the operand is wrapped into the implicit lambda it denotes. A global _1 holding a value therefore captured the placeholder, and the stage silently failed to canonicalize:

    ce.box(['Assign', '_1', 7]).evaluate();
    ce.parse('[1,2,3] \\rhd \\mathrm{Map}(\\_1, k \\mapsto k^2)').evaluate();
    // was: Map([1,2,3], (k) |-> k^2) now: [1,4,9]

    The placeholders (_, _1…_9) mentioned by the stage are now bound to fresh, valueless locals for the duration of that canonicalization. A genuine free variable in the stage still resolves — and auto-declares — in the caller's scope, unchanged.

  • The empty list is now a member of every list type. [] typed list<nothing>, which made [] <: list<integer> false — an empty list satisfied no list type at all:

    ce.parse('[]').type.toString(); // was: "list<nothing>" now: "list<never>"
    ce.parse('[]').type.matches('list<integer>'); // was: false now: true

    The cause was in the type lattice rather than in lists: widen() (a join) returned nothing for an empty input, where the join of no types is the bottom type. nothing is the unit type of the value Nothing, not the bottom type — never is. With never, covariance does the rest, since never <: X gives list<never> <: list<X>. narrow() had the mirror bug and now returns the top type for an empty input.

    Visible effects: the rendered type of an empty collection changes (list<nothing> → list<never>, list<list<nothing>> → list<list<never>>), and an empty list now satisfies a list-typed parameter of any element type. Note that couldMatch() deliberately ignores the empty list as a witness — list<integer>.couldMatch('list<string>') stays false, since that question is about element shape.

  • nothing and missing were reported as disjoint from any union containing them — nothing vs boolean | nothing claimed disjointness, refuted by the value Nothing, which inhabits both. The unit-type short-circuit ran before the union was examined and compared a type name against a composite type object. This surfaced as an unsound nothing <: !(boolean | nothing).

  • A quantifier's variable is no longer captured by a same-named global value. ForAll, Exists, NotExists, ExistsUnique and NotForAll declared a local scope that was created and then stayed empty, so the quantified variable was bound wherever the caller had it. With x assigned, the bound occurrence resolved the assigned value and the proposition was discharged from it:

    x = 5
    \forall x, x > 4 // was: True now: ForAll(x, x > 4)
    \exists x, x > 4 // was: True now: Exists(x, x > 4)

    Quantification over a finite domain (\forall x \in \{1,2,3\}, x > 0) is unchanged, and the global x is untouched in both cases. The quantifiers now use the sanctioned binder mechanism (scoped: limitsIndexSites(0)).

  • Integrate's integration variable is bound by the integral. The index of a Limits operand was bound nowhere: it was left raw on the parse route and carried the caller's binding on the ce.function route, so the same integral written two ways did not compare equal.

    const parsed = ce.parse('\\int_0^1 x^2 \\,dx');
    const built = ce.function('Integrate', [
    ce.parse('x^2'),
    ce.function('Limits', [ce.symbol('x'), ce.number(0), ce.number(1)]),
    ]);
    parsed.isSame(built); // was: false now: true

    Integrate now uses the sanctioned binder mechanism (scoped: indexingSetSites(1)), so expr.localScope reports the integration variable(s), and an indefinite integral's open result is re-bound to the enclosing scope on the way out.

  • D's differentiation variables are bound by the derivative. D declared a local scope that was minted and then never populated, so its variable operands were bound wherever the caller had them and the same derivative written two ways did not compare equal:

    const parsed = ce.parse('\\frac{d}{dx} x^2');
    const built = ce.function('D', [ce.parse('x^2'), ce.symbol('x')]);
    parsed.isSame(built); // was: false now: true

    D now uses the sanctioned binder mechanism, with a new variadic selector (scoped: operandsFrom(1)) for an operator whose bound variables are a trailing list of arbitrary length. expr.localScope reports every differentiation variable, and a derivative — an open expression in that variable — is re-bound to the enclosing scope on the way out.

    Visible consequence: a body handed to D already boxed is re-bound to the derivative's own scope, so it stops comparing equal to the expression it was built from — ce.box(['D', body, 'x']).op1.isSame(body) is now false, as it has been for a Sum's index since that operator was migrated. Code that lifts a differentiand back OUT of a D node into the ambient scope has to re-bind it; expr.explain('D'), which presents the differentiand as a free-standing expression, now does.

  • A Function literal's parameter operand denotes the literal's own parameter. The body Block's binding was already the authority for occurrences in the body, but the parameter operand itself was left raw on the parse and ce.box routes and carried the CALLER's binding on the ce.function route — the same route disagreement Series and Integrate were migrated to fix. A canonical literal now has exactly one binding per parameter, referenced from both the parameter operand and the body.

  • A bound variable named after a library constant (Pi, e, i, ...) is now bound like any other. A binder's variable was resolved by NAME, and a name owned by a constant short-circuits to the interned constant before the scope chain is consulted — so the binder declared a binding that nothing referenced. With an already-canonical body, the parameter was silently lost:

    const f = ce.function('Function', [ce.parse('\\pi + 1'), ce.symbol('Pi')]);
    ce.box(['Apply', f, 10]).evaluate(); // was: 1 + pi now: 11

    The parse and ce.box routes gave 11 throughout, so this was also a route disagreement. Bound variables are now built from the binder scope's own binding, which fixes the binding identity for every binder that accepts a bare symbol — including D(Pi^2, Pi), whose value was already right while the binding underneath was the constant's.

  • Interval now survives a LaTeX round-trip. A closed interval serialized as \lbrack a, b\rbrack, which the parser reads as a two-element List, and a fully-open one as \lparen a, b\rparen, read back as a parenthesized Sequence. The substitution was silent and could be destructive: under RandomChoice — the documented migration target for the removed RandomList(n) — RandomChoice(Interval(0, 1), n) came back as RandomChoice(List(0, 1), n), demoting a uniform real draw to a Bernoulli pick of the two values 0 and 1, with nothing downstream able to detect it.

    Serialization now depends on whether the position disambiguates the interval:

    • In a set position of a set operator — the right side of \in/\notin, either side of \cup, \cap, \setminus, \subset, \subseteq, \supset, \supseteq — the conventional bracket notation is kept (x\in\lbrack0, 1\rbrack), because the operator forces the set reading when it is parsed back. This is unchanged, and stays the common display case.
    • Anywhere else the serialization has to stand on its own: half-open intervals use the unambiguous American spellings (\lbrack a, b\rparen, \lparen a, b\rbrack), an open interval uses ISO reversed brackets (\rbrack a, b\lbrack), and a closed interval uses the function form \mathrm{Interval}(a, b) — [a, b] is also how a two-element list is written, so no bracket spelling is available for it.

    Consumers storing a uniform draw as LaTeX can now use the closed RandomChoice(Interval(0, 1), n) from the migration table directly. The half-open Interval(0, Open(1)) remains the more precise spelling for what RandomList(n) meant (upper-exclusive) and also round-trips.

  • ListFrom now compiles. It had no compile handler, so the one eager materializer that works over an arbitrary collection body could not be used in compiled code. That matters under WithRandomSeed: a frame around a lazy comprehension does not make it replayable, because the view materializes after the frame has exited and the draws escape it. ListFrom inside the frame makes it eager, and the compiled form now agrees with the interpreter draw-for-draw:

    WithRandomSeed(12345, ListFrom([Random() for k=[1...6]])) // replays, compiles

    On the JavaScript and Python targets the splice is decided at runtime, per operand, so an operand typing as unknown — a free symbol in a compiled body — works. The GPU targets have no runtime splice (their lists are fixed-size vecN/array literals), so an all-scalar ListFrom compiles exactly like the equivalent List and anything with a provably collection operand fails closed. Note that Repeat(Random(), n) is eager and round-trips but draws once, yielding n copies of a single value — it is not a uniform batch.

  • A cycle between symbol values no longer overflows the stack. A pair of bindings such as a := b with b := a is individually well-formed — each value mentions no symbol of its own name — so the self-reference guard never fired and any query that resolves a symbol's value and delegates to it recursed until the stack blew. This reached far more than the reported isFiniteCollection: count, at, each, N(), isEqual, isSame, and the scalar predicates (sgn, isFinite, isNaN, re/im) all crashed on a cyclic binding. Such a query now fails closed — undefined/false, never a throw. An enumeration that traverses a cycle yields the elements it gathered before closing it, matching what a direct self-reference (d := Append(d, 1) → [1]) has always produced.

  • Comparison, ordering and rationalization no longer numericize an argument they are about to reject. isEqual/Equal, Sort, assume, ApproxEqual, Rationalize and the .N() trig path each called .N() on an operand and then discarded the result when it turned out not to be a number literal. An operand with unknowns can never become one, and over nested applications of a user function that discarded walk is exponential in the nesting depth: at depth 12, isEqual against such a chain took ~1.8 s and Sort of five of them far longer; both are now milliseconds. Arguments that can numericize — including partially numericizable symbolic ones such as sin(2) + x — are unaffected. (Same class as the elementwise-Sin fix below; that one was the exact path, these are the .N() path.)

  • Round/Floor/Ceil/Truncate of a symbolic term keeps its integer type. Round(4Q) typed number while both the less informative Round(Q) and the fully known Round(4.7) typed finite_integer: an operand typed finite_number reports isReal === false, which means "not provably real", and the handler read it as "provably complex". Non-realness is now proven from a number literal or from a type that excludes the reals, so an operand of unknown realness keeps the generic-point convention. A wrap asserting integrality (RandomChoice(Interval(0,1), Round(4N)) with N unbound) now type-checks.

  • sin/cos/tan… of a deeply nested symbolic argument no longer blows up. The constructible-value lookup called .N() on every argument, including one with free symbols that can never numericize; over nested applications of a user function that wasted traversal re-walked shared sub-chains and grew exponentially with the nesting depth. Evaluating sin(πR/8) over a 17-element list whose element k is a user function applied k times took 44 s and now takes ~0.1 s. Arguments that can numericize — including a symbol with an assigned value — reduce exactly as before.

  • A function literal built around an already-canonical body now binds its named parameters. Canonicalizing an expression that is already canonical is a no-op, so a body constructed before the literal existed kept the bindings it was built with — and its parameter occurrences went on denoting the enclosing scope's variable of the same name instead of the literal's own parameter. The repair previously covered only the anonymous placeholders (_, _1, …) produced by the pipe/shorthand desugaring; it now covers named parameters as well, so anything keyed on a symbol's binding — the post-application substitution of a partially-symbolic result, symbol equality — sees the parameter for what it is:

    const body = ce.box(['Add', 'y', 1]); // canonical: `y` is the caller's
    const f = ce.function('Function', [body, ce.symbol('y', { canonical: false })]);
    // the body's `y` is now this literal's parameter, not the caller's `y`

    Values are unchanged; what changes is which variable an occurrence refers to.

  • An antiderivative is expressed in the caller's symbols. Integrate binds its integration variable, and an integrand's free coefficients are declared alongside it — but the antiderivative machinery (and the Rubi rule driver installed by loadIntegrationRules) works on the bare integrand and creates its own occurrences of those names in the caller's scope. The two sets of occurrences denoted different variables, so the result could compare unequal to the same expression written by hand. The integrand is now re-bound as it is lifted, matching how a Jacobian's body is lifted from its literal.

New Features​

  • An operator definition can now declare its bound variables. The scoped flag accepts a binding-site selector in addition to true:

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

    ce.declare('MyBinder', {
    lazy: true,
    scoped: operandSites(1), // operand 1 is my bound variable
    signature: '(expression, symbol) -> number',
    });

    A selector implies a scope, so scoped remains the complete inventory of scope-creating operators. When one is given, the engine declares each site's symbol in the operator's own scope before the canonical handler runs, and binds every occurrence of those names to that scope afterwards — so the parse, ce.box() and ce.function() routes agree about which binding a bound variable denotes, whichever route built the expression. The prebuilt selectors (operandSites, indexingSetSites, limitsIndexSites, lambdaParamSites) and the BindingSite/BindingSiteSelector types are exported from @cortex-js/compute-engine. Sum, Product, Loop, Comprehension, Series and NDSolveFunction now use it in place of six hand-rolled conventions.

    An indexing-set selector marks its sites clauseLocal: later clauses see earlier bindings, but an earlier clause's collection resolves a name a later clause binds in the enclosing scope (Comprehension(…, Element(i, [j, j+1]), Element(j, […])) draws i from the ambient j).

  • BoxedType.couldMatch() — "could a value of this type be a target?", the predicate for classifying a value by shape. matches() answers the other question — "is every value of this type a target" — so it reports false for a union whose members include exactly the shape being asked about, which is the steady state for a variable declared with more than one admissible shape:

    const t = ce.type('tuple<number, number> | list<tuple<number, number>>');
    t.matches('list<tuple<number, number>>'); // false
    t.couldMatch('list<tuple<number, number>>'); // true

    Unions are distributed at every depth, so a union nested inside a parameter is handled too: list<integer | tuple<number, number>> could be a list<tuple<number, number>> — witness [(1,2)].

    The relation is symmetric and decisive for the composite shapes it models: a tuple<number, number> could not be a list<tuple<number, number>>, list<integer> could not be a list<string>, and a set<T> could not be a list<T>. List dimensions and tuple arity and element names are compared. Shapes it does not model fall back to assignability in either direction, so the answer is never narrower than matches() — with one deliberate exception: never is uninhabited, so nothing could be a never. unknown could be anything; consumers that treat an inconclusive type as "no" should check isUnknown themselves.

  • BoxedType.unionMembers — the members of a union type, each boxed, or [this] for any other type. Lets a consumer reason arm-by-arm without reading the raw Type AST. It does not reach a union nested inside a parameter; couldMatch() covers that case directly.

  • BoxedType.isDisjointFrom() — a type-overlap predicate for consumers that classify an expression by comparing its type against a set of candidates. matches() answers subtyping (this <: other), so two types that share values without either containing the other look unrelated in both directions:

    const a = ce.type('integer | string');
    const b = ce.type('integer | boolean');
    a.matches(b); // false
    b.matches(a); // false — yet they share `integer`
    a.isDisjointFrom(b); // false: they may overlap

    The predicate is conservative in the safe direction: when disjointness cannot be established the answer is false ("may overlap"), never a false claim of disjointness. unknown — the type of an undeclared symbol — therefore overlaps everything. It accepts a Type, a type string, or a BoxedType, and throws on a string that is not a valid type.

Improvements​

  • Disjointness now distributes over unions, so integer | string is recognized as disjoint from boolean instead of falling through to "may overlap". This also makes a union a subtype of a negation when no member meets the negated type (integer | boolean <: !string).

  • Disjointness is now decided by comparing the primitive categories of the two types, so composite types are separated from each other and from primitives instead of falling through to "may overlap": a list<integer> is not a string, a tuple<number, number> is not a list<tuple<number, number>>, a set is not a list, and a record is not a dictionary. Broad categories still contain the narrow ones, so integer and value, or list<integer> and collection, correctly report "may overlap". This generalizes and replaces the narrower numeric-vs-non-numeric rule.

    Two same-category composites whose parameters cannot coincide (list<integer> vs list<string>) are deliberately not claimed disjoint: list<never> is a subtype of both, so the claim would rest on how the empty list is typed rather than on the type lattice. couldMatch() answers that question decisively.

0.95.0 2026-07-25​

Breaking Changes​

  • The random family is redesigned around block-scoped seeding. Seeding moves out of argument lists and engine state entirely: there is no seed argument anywhere in the family, and no ambient seed to set. A WithRandomSeed(seed, body) frame makes every draw inside it — including draws in user-function calls (dynamic scoping) and in compiled code — deterministic and replayable, while draws outside any frame are live.

    ce.box(['WithRandomSeed', 42, ['List', ['Random'], ['Random']]]).evaluate();
    // → two DIFFERENT values, and the same two values on every re-evaluation

    The n-th draw of a frame is hash(seed, n) — PCG3D, a pure function of the seed and the draw index, computed identically by the interpreter, the JavaScript target, and (as f32) the GPU targets. Draws are IEEE float64 regardless of the engine's precision mode, and the seed→stream mapping is a cross-version contract pinned by published test vectors. Frames nest (innermost wins) with independent per-frame counters, so one document cell's frame cannot perturb another's. See Random Numbers (and docs/RANDOMNESS-MODEL.md in the repository) for the full contract, including the draw-consumption table and the rule that only evaluation consumes draw indices (an untaken branch, an unmaterialized lazy view, or a canonicalized-away wrapper consumes none).

    The surface changes:

    • Random is domain-only. Random() draws a real in [0, 1); Random(Interval(a, b)) a real in [a, b); Random(Range(…)) an element of the (normalized, inclusive) range; Random(xs) an element of a finite collection. The old Random(seed)/Random(m, n) forms — where the first argument meant a seed or a bound depending on its numeric type — are rejected by the signature.
    • Sample → RandomSample, Shuffle → RandomShuffle — renamed, seedless. RandomSample's domain must be an indexed collection (a Set or an Interval is now invalid), and k < 0 or k > n is now an out-of-range error rather than undefined. Without-replacement remains over positions, not values: sampling a multiset can repeat a value.
    • RandomInteger, RandomList, and RandomSeed are removed, along with the ce.randomSeed property. For one release, evaluating a removed head throws an operator-removed error naming its replacement, and ce.randomSeed is a throwing accessor — nothing fails silently.

    Migration: Random(seed) → WithRandomSeed(seed, Random()); Random(n)/Random(m, n) → Random(Range(0, n-1))/Random(Range(m, n-1)) (for the non-degenerate ranges — the old bounds were upper-exclusive); RandomInteger(a, b) → Random(Range(a, b)); RandomList(n[, seed]) → RandomChoice(Interval(0, 1), n), framed if seeded; Shuffle(xs[, seed])/Sample(xs, k[, seed]) → RandomShuffle(xs)/RandomSample(xs, k), framed if seeded; RandomSeed(s)/ce.randomSeed = s → WithRandomSeed(s, …) around the work.

New Features​

  • RandomChoice(domain, k) — k independent draws with replacement, the twin of RandomSample (without replacement). The domain may be a bounded Interval, a Range, or any finite collection, and is never materialized: RandomChoice(Range(1, 10^9), 5) is O(k). The count is typed number (a computed count need not be pre-rounded; it is rounded on evaluation), and k may exceed the domain size — that is what replacement means.

  • Random draws now compile — including inside auto-compiled Map bodies and in shaders. Every compiled draw goes through the same engine primitive as the interpreter, deciding framed-vs-unframed at call time, so a function compiled outside any frame is deterministic when later called inside one, bit-identical to the interpreter. On the GPU, WithRandomSeed frames compile lexically (per-invocation counters): per-pixel seeding is WithRandomSeed(perPixelSeed, Random()). Unsupported forms fail closed at compile time rather than drawing silently.

  • Overload sets: an intersection of function signatures is now resolved at the call site. A function that can be called in several different ways is declared with &, and the arm whose parameters accept the arguments is selected. When several arms accept them the most specific one wins; incomparable arms are tried in declaration order.

    ce.declare('Draw', {
    signature: '((set<real>) -> real) & ((collection) -> any)',
    evaluate: (ops) => {
    /* dispatch on ops at run time */
    },
    });

    ce.box(['Draw', ['Interval', 0, 1]]).type; // → "real" (set<real> is the more specific arm)
    ce.box(['Draw', ['List', 1, 2, 3]]).type; // → "any"
    ce.box(['Draw', 5]).isValid; // → false

    Previously such a signature parsed but was inert: applications were never arity- or type-checked and always typed unknown. Argument validation, result typing and type inference now all understand overload sets, on the operator definition and the symbol declaration routes alike.

    When an argument's type is not yet known, it is inferred as the union of the parameters the surviving arms accept at that position — the constraint the call actually carries. Above, an unknown x in Draw(x) is inferred collection, not set<real>: assuming the more specific arm would wrongly reject a later list.

    Note that -> binds looser than &, so each arm must be parenthesized: (number) -> real & string is a single signature returning real & string. See Overload Sets.

Issues Resolved​

  • .N() evaluated the operands of Add and Multiply twice. The numeric path evaluated every operand exactly, discarded the results, and re-evaluated them numerically — observably wrong for impure operands (a framed Random() consumed two draw indices under N() and one under evaluate()) and a 2× evaluation tax otherwise. Impure operands now evaluate exactly once; pure operands keep the substitute-once-guarded path.

  • Sample materialized its whole source to draw a few elements. Sample(Range(1, 1000000), 3) allocated a million boxed numbers and ran a full Fisher-Yates (~300 ms) to return three; large sources were an uncatchable OOM. RandomSample now runs a sparse Fisher-Yates over the index space — O(k) time and memory — and RandomShuffle refuses sources past the element cap instead of exhausting the heap.

  • Compiled random draws bypassed the engine's stream. A compiled Random() emitted a bare Math.random(), so a compiled Map silently stopped being reproducible under a seed. Compiled and interpreted draws now share one code path (and the auto-compile gate that excluded impure bodies from Map compilation is gone — per-sample-point draws stay in the hot path).

  • compileShader emitted shaders that referenced undefined helpers. A shader whose body used any _gpu_* helper (Gamma, the fractal helpers, and now the random draw) compiled in CE but failed at GPU shader-compile time, because the helper preamble was never spliced into the emitted source. compileShader now derives the preamble from the compiled body and inserts it ahead of the entry point, on both GLSL and WGSL.

  • A function signature nested in a union or an intersection lost its parentheses when serialized, and re-parsed as a structurally different type with an identical string. ((number) -> real) & ((string) -> boolean) came back as the single signature (number) -> (real & ((string) -> boolean)). Type serialization now parenthesizes a signature wherever it is a member of a union, intersection or negation.

  • An intersection was not a subtype of its own members when the members were composite types. ((number) -> real) & ((string) -> boolean) did not match (number) -> real. A & B is now a subtype of R whenever any arm is, matching the behavior that already applied to primitive types.

  • ce.assume() threw a TypeError when applied to an operator whose signature had no single result type.

  • A function literal assigned to a symbol declared with an overload set was not arity-checked, so a two-parameter literal could be stored against one-argument arms and every declared call would silently partial-apply.

  • Shuffle and Sample were treated as pure functions. Neither declared pure: false, so isPure — and therefore isConstant — was true for a random permutation or sample of a literal collection, and Sample was not gated out of Map auto-compilation. Both are now declared impure.

  • A masked GPU branch could draw. The When/Which fall-through NaN compiled to 0.0 / 0.0, whose value is implementation-defined in GLSL: a driver may fold it to a finite, renderable value. The _gpu_nan() helper now returns intBitsToFloat(0x7FC00000), a guaranteed quiet-NaN bit pattern.

  • Desktop GLSL 4.x shaders were emitted with GLSL ES 1.00 syntax. compileShader chose in/out over attribute/varying by testing whether the version string began with 3, so 450 core fell through to the ES 1.00 keywords. The version is now parsed rather than prefix-matched, and a version below 300 is rejected: the emitted code uses ES 3.00 constructs throughout, so a lower #version header could not have compiled.

Improvements​

  • The result type of the bare function type is now reported as unknown rather than any. function is shorthand for (any*) -> unknown and carries no information about its result, so an application of an undeclared function types unknown. This is visible in derived types — [h(x)] for an undeclared h now types list<unknown> instead of list<any>.

  • The result type of a union or intersection of function signatures is now the union of the arms' result types, instead of being undetermined.

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE (current)CE 0.92.1SymPymath.jsMathematica
\pi^26.16.51751013.8
\sin 119202194835.2
\cos 120202185677.0
\ln 214143404,5583.8
e^{\pi}12122264,7704.5
\zeta(3)1,5191,542268—49
\Gamma(\tfrac13)831827348—212
\psi(\tfrac13)7177132,778—169

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case; ✓ means it solves a case Mathematica can't. Compare the CE (current) and CE 0.92.1 columns to see what is new this release (a — under 0.92.1 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE (current)CE + R/FCE 0.92.1SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx6.2×2.9×5.6×0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx11×1.6×8.8×0.1×—1×
\int\frac{1}{x^3+1}\,dx6.1×0.9×4.7×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—2.0×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—1.2×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—1.2×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.04×0.04×0.04×0.0009×0.003×1×
Simplification
\sqrt{3+2\sqrt2}41×29×36×——1×
\sqrt6\,x+\sqrt2\,x83×45×58×3.2×15×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}44×18×49×3.1×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x8.5×5.2×8.5×2.1×—1×
\int_1^2\tfrac1x\,dx6294×6380×6457×92×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx388×143×389×2.4×—1×
Solving
x^4+x^2-1=00.3×0.3×0.3×0.06×—1×
x^3-x-1=01.7×1.8×1.5×0.04×—1×

Across the cases both solve, Compute Engine is a median 6.2× faster than Mathematica (up to 6294×) — in the browser, not a proprietary kernel.

Measured 2026-07-26 · Compute Engine 0.95.0 @ 6c188402 (current build) · published 0.92.1 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.94.0 2026-07-24​

Breaking Changes​

  • Nothing now erases inside collections, as it already did inside operator argument lists. Nothing is the ERASURE marker — an empty-sequence splice — so a Nothing element is spliced out of a List, Set or Tuple literal instead of being retained. Length, arity, type and indexing all follow:

    ce.box(['List', 12, 'Nothing', 34]); // → [12, 34] (length 2, was length 3)
    ce.box(['Set', 1, 'Nothing', 3]); // → Set(1, 3)
    ce.box(['Tuple', 1, 'Nothing', 3]); // → (1, 3)
    ce.parse('(a,,b)'); // → (a, b) (an empty slot is `Nothing`)

    A key–value pair tuple is a NON-erasing position, so a dictionary/record entry whose value is Nothing is dropped as a whole entry, but a caller that needs a fixed-arity positional pair whose slot may hold an absent value must build it with ce._fn('Tuple', …) and use Missing (below) for the hole. This also applies to lazy iteration: an element that evaluates to Nothing is dropped (Map(xs, _ ↦ Nothing) is the empty collection — the mapMaybe idiom).

  • New Missing marker and missing type for an absent-but-positioned value. Missing is the complement of Nothing: "a position exists, its value is absent" (Julia missing, R NA). It is never erased — [1, Missing, 3] is a 3-element list<integer | missing> — and missing is a primitive unit type (a subtype only of itself and any, mirroring nothing), reachable as ce.Missing.

    Absence is domain-normalized at value construction: absence flowing through an operator into a NUMERIC result cell becomes NaN (the numeric absent element), while a non-numeric result cell keeps Missing. So a numeric operator ABSORBS a Missing operand into NaN rather than carrying a missing arm:

    ce.box(['Add', 'Missing', 1]).evaluate(); // → NaN (Add(Missing, 1) : number)
    ce.box(['Sin', 'Missing']).evaluate(); // → NaN
    ce.box(['Sin', ['List', 1, 'Missing', 3]]).evaluate(); // → [Sin(1), NaN, Sin(3)]
  • Out-of-band access preserves position instead of yielding Nothing or dropping the entry. An out-of-range index, or a dictionary key that is not present, now yields a position-preserving marker chosen by the collection's element domain: NaN when the elements are numeric, Missing otherwise.

    ce.box(['At', ['List', 10, 20, 30], 9]).evaluate(); // → NaN (numeric)
    ce.box(['At', ['List', 'a', 'b'], 9]).evaluate(); // → Missing (non-numeric)

    Gather is now length-preserving — At([a, b], [1, 9, 2]) is [a, hole, b] (length 3), where the baseline dropped the out-of-range entry — and a boolean mask whose length differs from the collection is now an error, where the baseline silently applied the prefix.

  • The 15 data-consuming aggregates return NaN on an absent datum or empty input. Mean, Variance, PopulationVariance, StandardDeviation, PopulationStandardDeviation, Kurtosis, Skewness, Median, InterquartileRange, Quartiles, Max, Min, Supremum, Infimum, and Mode — over both call shapes (Max(1, Missing, 3) and Max([1, Missing, 3])) — now evaluate to NaN when any datum is absent (Missing or NaN) or the input is empty (Quartiles → (NaN, NaN, NaN)). Max([])/Min([]) are therefore NaN (was ∓∞), and these operators now type as number rather than finite_real, since their result may be NaN.

    ce.box(['Max', 1, 'Missing', 3]).evaluate(); // → NaN
    ce.box(['Mean', ['List']]).evaluate(); // → NaN
  • Comparisons follow IEEE 754 for NaN and Kleene for the Missing symbol, across the whole relational family (Equal, NotEqual, Less, LessEqual, Greater, GreaterEqual). This is the Julia model:

    • The Missing symbol is Kleene — a comparison with a Missing operand is itself Missing: Equal(x, Missing) = Missing, NotEqual(Missing, x) = Missing, Less(Missing, 1) = Missing. (An ordering with a Missing operand previously stayed symbolically unevaluated.)
    • NaN follows IEEE — NaN is unequal to everything (including itself) and unordered: Equal(NaN, NaN) = False, NotEqual(NaN, x) = True, and every ordering with a NaN operand is False. The Equal/NotEqual results match native float ==/!=; the ordering comparisons with NaN previously stayed symbolic (NaN < 1 was inert), so they now resolve to False.
    ce.box(['Equal', 'NaN', 'NaN']).evaluate(); // → False (IEEE)
    ce.box(['Less', 'NaN', 1]).evaluate(); // → False (IEEE unordered)
    ce.box(['Equal', 2, 'Missing']).evaluate(); // → Missing (Kleene)
    ce.box(['Less', 'Missing', 1]).evaluate(); // → Missing (Kleene)
    ce.box(['Equal', 2, 2]).evaluate(); // → True (unchanged)

    Absence for discharge (IsMissing, Coalesce) and aggregates (Max, Mean, …) is unaffected — a NaN is still absent there (IsMissing(NaN) = True, Coalesce(NaN, d) = d, Max(1, NaN, 3) = NaN). Broadcast comparisons apply the rule per cell. Because NaN follows IEEE, compiled and interpreted comparisons now agree by construction on numeric operands (plain == is the IEEE semantics — no guard is emitted, and NaN == NaN compiles to false). A numeric-domain missing arm (number | missing) does not widen a comparison's result type — that slot's absence value is NaN, so the result is a plain boolean, a Missing value read through such a slot compares as NaN (IEEE), and the comparison compiles on float-only targets (GLSL/WGSL). A missing-arm operand over an object domain (e.g. string | missing) still types boolean | missing and lowers via the guarded form (isAbsent(a) || isAbsent(b) ? null : a == b) so a Missing becomes the target null. A scalar If/Which condition that evaluates to Missing yields a catchable error expression (The condition is absent…) rather than crashing evaluate() — absence is a runtime data state, so it must be renderable and catchable; discharge with Coalesce/IsMissing to branch on possibly-absent data. (A condition that is not boolean at all, e.g. If(3, …), keeps the existing spell-check throw.) A NaN-comparison condition yields a plain boolean (IEEE) and branches normally.

  • Compiled Max([]) / Min([]) now return NaN, matching the interpreter (previously -Infinity / +Infinity from the identity-seeded reduce). Non-empty folds are unchanged.

New Features​

  • cortex check — validate a program without evaluating it. Parses the source (a file, --eval, or stdin) and reports diagnostics; exit status is 0 when there are no errors. With --json it emits a machine-readable envelope ({ ok, diagnostics } with severities, codes, messages, 0-based source offsets, 1-based line/column, and fix-its). The same structured diagnostics are available during evaluation with --diagnostics json.

  • cortex doc — library documentation from the terminal. cortex doc Sin shows a definition's kind, signature or type, description, keywords, and (for constants) value; a non-name argument searches the library by identifier, description, curated keywords, and LaTeX commands (cortex doc greatest common divisor → GCD, …). --limit <n> controls the number of matches and --json emits a structured { query, matches } envelope.

  • Cortex for AI Agents language card. A condensed, machine-verified reference for LLMs and coding agents writing Cortex (/cortex/for-agents/): core semantics, an operator-precedence summary, a table of Python/JavaScript reflexes that don't transfer, and verified idioms. Every example on the page is executed by the documentation test suite, so the card cannot drift from the implementation.

  • cortex mcp — a Model Context Protocol server for Cortex. Starts an MCP server on stdio (the default) or native Streamable HTTP, giving AI agents structured access to the same operations as the CLI: an evaluate tool (each call runs a complete, self-contained program in a fresh session and returns the value as display text, Cortex source and MathJSON, plus diagnostics), check, doc, parse and serialize tools, and the Cortex for AI Agents language card as the cortex://docs/for-agents resource. Register the stdio transport with, e.g., claude mcp add cortex -- npx -y @cortex-js/compute-engine mcp, or start the URL endpoint with cortex mcp --transport streamable-http. The protocol implementation is self-contained: the package gains no new dependencies.

  • Spread arguments — f(...t) splices a tuple into a call's arguments. New Cortex prefix syntax ... (call argument lists only) and engine Spread marker: the elements of a tuple become ordinary positional arguments, so a point can be fed to a component function without manual indexing — F(p) = (a(...p), b(...p), c(...p)) instead of a(p[1], p[2], p[3]). Several spreads splice in order (g(...p, ...q)), variadic built-ins accept them (Max(...t)), and the syntax round-trips through the Cortex serializer. A literal tuple splices at canonicalization; a symbolic argument defers — argument validation and the operator's canonical handler wait — until evaluation resolves the tuple and re-validates the real arguments. Tuples only: spreading a List or a scalar is an incompatible-type error, and an unresolved argument leaves the call symbolic.

    Spread also compiles, on every target: a literal tuple splices directly, and a tuple-typed argument (p: tuple<number, number>) is rewritten statically to positional accesses (f(At(p,1), At(p,2))). An argument whose tuple arity is not statically known fails closed — a dynamic JS/Python spread would silently mis-bind on an arity mismatch instead of erroring like the interpreter.

    ce.box(['Add', ['Spread', ['Tuple', 1, 2, 3]]]).evaluate(); // → 6
  • Destructuring declarations — let (q, r) = divmod(17, 5). A Cortex let/const may bind the components of a tuple in one statement. Patterns are irrefutable in form — bare symbols, _ to skip a position, nested tuple patterns; no literals or pins (use match for conditional destructuring) — and require an initializer. The value is evaluated once; a shape mismatch (wrong length, or not a tuple) yields an incompatible-type error value and binds nothing. const (x, y) = … makes each binding constant. Lowers to the Declare primitive with a Tuple pattern in the name position, which now accepts it on all routes.

    In compiled code, a destructuring declare with a literal tuple value desugars to per-leaf declares (each element bound once, in order); a non-literal value or a shape mismatch fails closed so the interpreter takes over. (This also fixes a silent divergence: the pattern previously compiled as a single let _ = …, and every pattern name read as NaN behind success: true.)

    ce.box(['Declare', ['Tuple', 'x', 'y'], d]).evaluate(); // d = {value: (3, 4)}
  • IsMissing and Coalesce — absence testing and discharge. IsMissing(x) is True when x is absent (the Missing symbol OR a NaN, R’s is.na); IsNaN remains a NaN-specific test. Coalesce(a, b, …) returns the first non-absent operand, evaluated left-to-right with short-circuit; if every operand is absent it returns the last one verbatim. Both discharge primitives work identically in the interpreter and compiled (JS/Python) — a numeric hole (NaN) and an object hole (Missing/null) are handled uniformly. On a target that cannot observe its absent element (a GPU shader, where fast-math may not preserve isnan), IsMissing/Coalesce fail closed with a compile error; propagation still works natively.

    ce.box(['Coalesce', ['At', ['List', 10, 20], 9], 0]).evaluate(); // → 0
    ce.box(['IsMissing', 'NaN']).evaluate(); // → True
  • HoldValues(body) — the value-blind evaluation route from the operator surface. A binder that shields the assigned free symbols of its body: for the duration of the evaluation each such symbol becomes a pure symbol (its declared type and in-scope assumptions apply, its assigned value does not), analogous to Mathematica's Block[{x}, …]. Now that the Simplify operator evaluates its argument first, HoldValues is the only value-blind route reachable from Cortex (the .simplify() method is not exposed there). Shield every assigned symbol, or a listed subset with a second List/Set/Tuple or single-symbol operand. Constants are never shielded, in-scope assumptions survive the shield, and the global values are intact afterwards.

    ce.assign('x', 5);
    ce.assign('a', 3);
    ce.box([
    'HoldValues',
    ['Together', ['Add', ['Divide', 1, 'x'], ['Divide', 'a', ['Power', 'x', 2]]]],
    ]).evaluate(); // → (a + x) / x² (without the wrapper: 8/25)
    ce.box(['HoldValues', ['Add', ['Power', 'x', 2], 'a'], ['List', 'a']]).evaluate();
    // → 25 + a (x resolves, a shielded)

Improvements​

  • The Cortex serializer reconstructs let/const syntax. A Declare node now serializes back to its statement form — let x = 5, const c = 6.28, let x: real, and destructuring patterns let (x, y) = p — instead of the generic Declare(x, {value -> 5}) function spelling, so declarations round-trip source → MathJSON → source. Shapes with no let spelling (a holdUntil attribute, a computed name) keep the generic form.

  • Compiled comparisons and connectives look through provably-scalar user functions. A helper declared with an open signature ((unknown) -> unknown, the shape that keeps list-broadcasting working) no longer makes a scalar comparison uncompilable: q(x) < y with q(t) = n·t+1 compiles when every argument is scalar and the function's body provably maps scalar parameters to a scalar result (arithmetic/transcendental operators, scalar-typed captured symbols, and nested user helpers — analyzed recursively, with self-recursion declining). A call whose argument may be a collection (q(L) < y) still fails closed, so the sound half of the 0.93.0 rule is preserved — and unlike a -> number return annotation, the look-through never mis-compiles the broadcast call. Element-wise compiled comparisons over collections remain a separate roadmap item.

  • declare() accepts inferredSignature: true, to vouch that a name is an operator without pinning its types. Declaring a signature normally makes it a contract, so a wide placeholder such as (unknown) -> unknown keeps every call typed unknown even after a function literal is assigned. That is the right default for a fixed API, but not for a name that must be declared before its body exists — most often so that f(x) parses as an application rather than a multiplication:

    ce.declare('q', { signature: '(unknown) -> unknown', inferredSignature: true });
    ce.assign('q', ce.parse('t \\mapsto 2t+1'));
    // signature is now `(unknown) -> finite_number`
    // `q(x) < y` types `boolean` and compiles;
    // `q(L) < y` over a list `L` types `list<boolean>` and still fails closed

    The flag was already honored at run time and is now part of the OperatorDefinition type, so it no longer needs a cast. A declaration that omits signature entirely behaves the same way.

  • An unapplied Derivative(f) now evaluates to a named-parameter function literal. Derivative(Sin) evaluates to x ↦ cos(x) (["Function", ["Cos","x"],"x"]) instead of the hole-form cos(_), which was typed finite_number — so a stored derivative is now callable: let g = Derivative(f); g(2) works instead of erroring with incompatible-type. Results with no closed form stay symbolic, and the multivariate mixed-partial form no longer throws Function body must be a scoped Block expression when applied.

  • The Cortex CLI's --json output materializes finite lazy collections. Range, Map/Filter results, and loop-built Join chains now serialize as their elements (["List", 1, 2, …], up to 10,000) instead of their unevaluated recipe; infinite collections keep the structural form.

  • Cortex trap lints and better "did you mean" suggestions. Three common cross-language reflexes that previously failed silently now produce an advisory warning (the parse and value are unchanged): = inside a call argument (Solve(x^2 = 4, x) is assignment, not an equation — use ==), a literal index 0 (indexing is 1-based; xs[0] is NaN), and a // comment that reads as floor division (7 // 2 is 7 followed by a comment; use Floor(a / b)). Calling print (or println, printf, puts, echo) now explains that a program's output is the value of its last statement. The "did you mean" matcher gained a curated cross-language tier: split → StringSplit, push → Append, ceiling → Ceil. The agent language card gained a verified library quick-roster, output-rendering notes (quoted booleans, list preview elision), and binder-variable semantics for D/Integrate.

  • Pipe/Apply reject or defer a non-function right operand more sensibly. x |> f (Pipe) now returns an incompatible-type error when f is a number, string, or boolean literal (which can never be applied), instead of staying silently inert; a symbol or unevaluated f still defers (definitions may arrive later). Apply and Pipe no longer throw an uncaught Invalid function literal when given a string function operand — they decline gracefully. Applying a function-valued expression such as InverseFunction(f) now stays symbolic (Apply(InverseFunction(f), 2)) instead of misinterpreting it as a lambda body and substituting the argument for f.

  • Rubi integrator: Euler-substitution lever for √(quadratic)-nested radicals. The experimental Rubi integrator now closes nested radicals whose inner radical is a square root of a quadratic with a positive leading coefficient — e.g. ∫ 1/(√(x+√(x²+1))+1) dx (Bondarenko #9) — via an Euler I substitution t = √a·x + √Q that rationalizes √Q and reduces the integrand to a form the existing linear-radical machinery closes. This raises the Bondarenko benchmark to CE+R/F 21/35.

  • simplify() is value-blind for a symbol's sign and parity. .simplify() no longer reads an assigned symbol's value when applying a sign- or parity-driven rewrite: with w := 5, |w|.simplify() now stays |w| and √(w²).simplify() is |w| (previously both collapsed to w, silently baking in w ≥ 0 and evaluating wrong after a later w := -3). Sign and parity are taken only from a symbol's declared type and in-scope assumptions — so assume(w > 0) still licenses |w| → w — never from its assigned value. .evaluate() and .N() are unchanged — they still substitute the value.

  • The Simplify operator now evaluates its argument before simplifying. Simplify(expr) is now evaluate-then-simplify — the operator counterpart of the expr.evaluate().simplify() recipe — so it computes handler-driven results the value-blind .simplify() method never touches: Simplify(Max(3, 5)) → 5, Simplify(D(x²+ax, x)) → a + 2x, Simplify(∫x² dx) → x³/3. Because evaluation substitutes assigned symbol values, the change is visible from Cortex: let x = 5; Simplify(x^2 + x) now gives 30. The other transformers (Expand, Factor, Together, Distribute) are unchanged — they keep reduce-not-evaluate.

  • The .simplify() method no longer evaluates structural operators. Determinant, Trace, Transpose and Length are no longer reduced by the .simplify() method (an unreleased whitelist added earlier in this cycle): the method is rule-driven and value-blind, and running an operator's evaluate handler is .evaluate()'s job. Use expr.evaluate() (or the Simplify operator, which now evaluates) to reduce them — e.g. Determinant([[a,b],[c,d]]).evaluate() → a·d − b·c.

0.93.0 2026-07-23​

New Features​

  • Cortex CLI and interactive REPL. Installing @cortex-js/compute-engine now provides a cortex executable. It evaluates inline source (cortex -e '1 + 2'), .cx/.cortex files, or a program read from standard input; with no input argument in a terminal it starts a stateful REPL.

    The REPL retains declarations across inputs, supports multiline programs and persistent history, and adds .load, .clear, .ast, and .time commands alongside Node's standard REPL commands. Non-interactive output can be emitted as text, MathJSON (--json), or Cortex source (--cortex). Diagnostics include source locations and are written to standard error. Evaluations have a 10-second deadline by default; --time-limit changes it and --time-limit 0 disables it.

  • JacobianMatrix(fs, vars) — the matrix of partial derivatives ∂fᵢ/∂xⱼ, one row per function and one column per variable.

    ce.box(['JacobianMatrix', ['List', fs…], ['List', 'x', 'y', 'z']]).evaluate();
    // → [[∂f₁/∂x, ∂f₁/∂y, ∂f₁/∂z], …]
    • vars may be omitted: the free variables of fs are used, in lexicographic order, so the column order is predictable.
    • A single (non-list) fs is the gradient case and yields a flat vector [∂f/∂x₁, …, ∂f/∂xₙ], directly usable as one.
    • JacobianMatrix(F) accepts a bare function reference: its body is the system and its parameters are the differentiation variables, in declared order (JacobianMatrix((z,y,x) ↦ …) has columns z, y, x — an order free-variable inference could not preserve). Explicit variables then rename the parameters.
    • A square system composes with Determinant, which now also reduces under simplify() — so JacobianDeterminant is one composition away and is not provided separately.

    Beyond brevity this removes a real trap: the obvious hand-rolled form Map([x,y,z], v |-> D(f, v)) silently returns zeros, because the lambda parameter shadows and D differentiates with respect to v.

    The operands are held — evaluating the variable list would replace a symbol carrying a value (x := 5) by that value, leaving nothing to differentiate against. Whether fs is a system or a single function is decided on what the operand denotes, not its syntax, so a user-defined function returning a list and a symbol bound to a list are both treated as systems.

Improvements​

  • The LaTeX/ASCII-math pipeline operators now preserve Pipe. A bare pipeline stage such as x |> f previously lowered immediately to ["Apply", "f", "x"]; it now produces ["Pipe", "x", "f"], matching the Cortex parser and retaining Pipe's held-operand semantics. Chained stages remain left-associated, prefix stages use Function(Pipe(_, f), _), and a programmatic Pipe serializes with \rhd. Pipeline topic markers remain direct substitutions, so x |> f(\square) still becomes f(x).

  • simplify() reaches a distributed form when it is cheaper. Rules tagged purpose: 'expand' are excluded from its scan, because expansion usually grows an expression — but that left simplify() unable to reach a strictly cheaper result. It was not cost-rejecting the better form; it never generated the candidate.

    const f = ce.parse('(1 + x y)^3 z + y^2 (1 + x y) (4 + 3 x y)');
    f.subs({ z: ce.parse('-\\frac{3y}{x} + \\frac{2}{x^2}') }).simplify();
    // Before → unchanged (cost 76) Now → y^2 + 3y/x + 2/x^2 (cost 27)

    simplify() now tries expansion once, at the fixpoint, and keeps the result only when the cost function says it is strictly cheaper. That cannot cycle (the inner call has the trial disabled) and cannot blow up (the cost gate is the acceptance test), so a factored form survives: (x+1)^5, (a+b)(c+d) and x(y+z) are all left alone. A structural pre-check keeps the trial off expressions with no product or power for expansion to act on.

  • simplify() now evaluates structural operators. It is rule-driven and ran no operator evaluate handler at all, so an operator whose result comes from a handler rather than a rule was handed straight back.

    ce.parse('\\det\\begin{bmatrix} a & b \\\\ c & d \\end{bmatrix}').simplify();
    // Before → Determinant(Matrix([[a,b],[c,d]])) Now → a * d - b * c

    The members are a closed list — Determinant, Trace, Transpose, Length — chosen by a membership rule: the handler reduces its operands to a closed form determined by their structure (a matrix to a scalar, a collection to a measure) rather than rewriting the expression, and the head carries no simplification rule of its own. Max/Min deliberately fail that rule and are not members: they reduce their operands' values, which is evaluation's job.

    simplify() remains value-blind. With a := 5, (a + 2).simplify() is still a + 2. A structural head whose operands mention a symbol carrying a value is left alone rather than substituting it.

    Operators outside the list are unchanged, so the ordering rule still stands: evaluate() then simplify(); simplify() alone is not a superset.

  • The Simplify operator resolves symbols bound to a value. An operator normally evaluates its arguments; the transformers are lazy only to keep the operand's structure from being rewritten early, not to keep its values symbolic. This applies to Expand, ExpandAll, Factor, Together and Distribute as well.

    ce.assign('v', ce.parse('\\frac{x^2-1}{x-1}'));
    ce.box(['Simplify', 'v']).evaluate();
    // Before → v Now → x + 1

    This is the operator, not the method: ce.symbol('v').simplify() is still v, because .simplify() is value-blind.

  • Together reduces its result to lowest terms. It folds the terms over the product of the denominators, which is correct but not reduced; the result is now divided through by the numerator/denominator GCD. This also yields the least common denominator, since product / gcd is the LCD.

    ce.box(['Together', ce.parse('-\\frac{3y}{x}+\\frac{2}{x^2}')]).evaluate();
    // Before → (-3y * x^2 + 2x) / (x * x^2) Now → (-3x * y + 2) / x^2

    The multivariate case needs Brown's algorithm: the univariate Euclidean GCD treats y as an opaque coefficient and reports 1 for that pair. cancelCommonFactors now falls back to the multivariate GCD when the univariate one comes back trivial and more than one unknown is present. The order matters — the cheap path runs first, so the common case is unaffected.

    The same-denominator simplification rule still uses the unreduced fold: it runs inside the simplify() fixpoint, where output stability and cost matter more than presentation.

  • CircularIntegrate (\oint) gained an operator definition. It previously had only a parser entry, so it typed as any and its limits stayed a raw Tuple. It now types as number and canonicalizes its limits into Limits expressions, matching Integrate so a limits-consuming caller sees a uniform shape. CircularIntegrate remains inert — there is still no contour- integration evaluation.

    ce.parse('\\oint_C f').json;
    // Before → ["CircularIntegrate", "f", ["Tuple", "Nothing", "C", "Nothing"]] (type: any)
    // Now → ["CircularIntegrate", "f", ["Limits", "Nothing", "C", "Nothing"]] (type: number)
  • RandomList now compiles on the JavaScript target. RandomList(n) draws fresh values on every call of the compiled function, matching evaluate(). For draws that stay the same from call to call, use the explicit-seed form RandomList(n, seed). A count that is negative, non-finite, or above the 1,000,000 cap makes the compiled function throw, rather than silently clamping or returning NaN. (evaluate() reports an out-of-range error expression for the same input.)

  • declare() accepts a spread of an existing operator definition, so you can override one handler and keep the rest:

    ce.declare('At', { ...ce.lookupDefinition('At').operator, evaluate });

    The built-in's other handlers (type, signature, canonical, …) are preserved. Overriding with a bare { signature, evaluate } instead drops them — prefer the spread.

  • Compiled At supports a collection-valued index. An index that is a list of indices or a boolean mask now works when compiled, matching evaluate():

    const p = ['List', 10, 20, 30];

    ce.box(['At', p, ['List', 3, 1]]); // → [30, 10]
    ce.box(['At', p, ['List', 'False', 'True', 'True']]); // → [20, 30]

    ce.assign('p', ce.box(p));
    ce.assign('X', ce.box(['List', 1, 2, 3]));
    ce.parse('p_{X}'); // → [10, 20, 30]

    Negative indices count from the end and out-of-range entries are dropped, so a gather may be shorter than its index list. Previously these returned undefined, a wrongly-shaped scalar (p[[2]] gave 20 rather than [20]), or threw.

    An At with a collection-valued index now has type list<T> rather than the element type T. If you dispatch on .type, expect the new value. This is what lets surrounding operations compose: At(p, I) + 1 broadcasts element-wise, and Length(At(p, I)) compiles.

Resolved Issues​

  • Exact integer tensors stay exact. A shadowed branch in the tensor dtype classifier routed every integer literal into a float64 buffer, which silently disabled the exact-arithmetic path for integer matrices. With the repair, Inverse([[1,2],[3,4]]) evaluates to exact rationals ([[-2,1],[3/2,-1/2]]) and .N() still produces the float form; an integer outside the float-safe range stays expression-backed instead of rounding.

  • assume() after assign() now records the assumption. The predicate was evaluated through the symbol's assigned value before the assumption system saw it, so with w := 5, assume(w > 0) folded to True, returned 'tautology', and recorded nothing — the assumption was silently discarded on arrival (only the assume-before-assign order worked). Predicates mentioning assigned symbols are now recorded value-blind, as facts about the symbol: assume(w > 0) after w := 5 returns 'ok' and |w| then simplifies to w. A predicate that contradicts the current value (e.g. assume(w > 0) with w := -2) still returns 'contradiction' and is rejected. 'tautology' now means tautological relative to types and existing assumptions, never relative to an assigned value — re-asserting an equality a symbol's value already satisfies returns 'ok', not 'tautology'.

  • .N() on a multi-limit Integrate no longer drops all but the first limit. The numeric-approximation branch read only the first Limits operand, so Integrate(f, Limits(x,0,3), Limits(y,0,2)) numericized as a single-variable integral — ∫∫ 1 over [0,3]×[0,2] gave 3 (a wrong value, not a decline) and a multivariate integrand gave NaN. Multiple limits now perform iterated adaptive Gauss–Kronrod quadrature (Monte-Carlo fallback per level, as for a single limit), following the Mathematica iterator convention the symbolic path already used: the FIRST limit is the OUTERMOST integral, so an inner bound may reference the outer variables — Integrate(1, Limits(x,0,1), Limits(y,0,x)) (the triangle) numericizes to ½. A bound that references an inner integration variable or a foreign free symbol declines (the integral stays inert) rather than integrating wrongly. The same fix applies to compile(): a multi-limit integral that does not close symbolically now compiles to nested quadrature calls — dependent bounds included — where it previously truncated to the first limit.

  • Transpose/ConjugateTranspose report the transposed static type. They had no type handler, so an unevaluated Transpose(m) typed as the generic value and was rejected by matrix arithmetic: Multiply(Transpose(J), J) for J = JacobianMatrix(…) — the Gram-matrix idiom JᵀJ — errored with incompatible-type unless J was evaluated first. Both now preserve the element type and swap the shape's axes (matrix<T^(2x3)> → matrix<T^(3x2)>).

  • simplify() reaches trig identities inside a quotient. Recursion into Divide operands is deliberately withheld to preserve factored structure for common-factor cancellation, but that also blocked operand-local trig reductions: (r·cosθ)/(r·sin²θ + r·cos²θ) didn't simplify even though the denominator alone reduces to r. Trig-bearing operands of a Divide now get the full recursion (the same carve-out Add/Multiply already made), so it simplifies to cos(θ); factored-polynomial cancellation is unaffected.

  • Value-resolution overreach in the lazy-operand fixes. The machinery that lets Solve/Simplify/JacobianMatrix see through a held operand resolved bound symbols too aggressively. Fixed:

    • resolveBoundSymbols is now binder-aware: it no longer resolves a variable bound by a Function, Block, Sum, … to a same-named global value (Simplify(x ↦ x+1) no longer corrupts the body's bound x).
    • A transformer nested in a Solve equation could substitute an unknown that also carries a value — Solve(Simplify(x-2)=0, x) with x:=5 returned []. The unknown is now shielded across transformer reduction.
    • JacobianMatrix differentiated a system in which a diff variable had already been replaced by its global value (JacobianMatrix(g,[x,y]) with x:=5, g:=[x²y,x+y] gave a wrong matrix). It now resolves the operand's shape without substituting values, and differentiates against a fresh symbol when a diff variable carries a value.
    • simplify() evaluated a structural head's whole operand tree, running an impure descendant — simplify(Transpose([[Random()]])) drew a random number. It now declines when the expression is impure.
  • D no longer evaluates its result at the differentiation variable's assigned value. With x := 5, D(x², x) is now 2x (was 10). Under the ratified binding convention (ARCHITECTURE.md, "Bound variables, free symbols, and assigned values"), the variable a binder owns is a pure symbol — its declared type and assumptions apply, its assigned value never does, INCLUDING in the result. A caller who wants the value evaluates the result again or substitutes explicitly. Free symbols are unaffected: with a := 3 too, D(a·x², x) is 6x.

  • Integrate, Limit, and bundled Solve shield a value-bound bound variable. The same convention: a same-named global assignment no longer leaks into these binders or their results. With x := 5, Integrate(x², x) is x³/3 (was 125/3), ∫₀¹ x² dx is 1/3 (was 0), and Limit(Simplify(x²), x, 0) is 0 (was 25, on both the box and parse routes). A bundled Solve(\{Simplify(9 − w²) = 8, w ∈ -3..3\}) with w := 9 now returns [1, -1] (was []): the value-bound bundled unknown is now discovered before the shield is computed. A shared withValueShield helper implements the shield and replaces JacobianMatrix's per-variable rename.

  • Distribute fold, numeratorDenominator, Together, transformers, and toString() grouping — as previously listed.

  • Beta-reduction completeness. A finite self-composition (g(g(x)) for a non-recursive g) only inlined one level, so Solve(g(g(x))=0, x) returned []; the recursion guard is now a total-count budget that still terminates genuine recursion. Typed function parameters ((x: real) ↦ …) are unwrapped, so a typed function inlines like a bare one.

  • Pipe (|>) declines a non-function right-hand side (5 |> 3 stays inert) and its handler now delegates to apply instead of duplicating its named-operator path.

  • Pipe (|>) holds its operands, so x |> f behaves exactly like f(x). It evaluated the topic eagerly, regardless of f — which broke a chain whose right-hand side is a lazy operator that needs its argument unevaluated. A bare function reference was the sharp case: F |> JacobianMatrix passed an evaluated F, stripped of its definition, so the Jacobian could not see the map's body.

    // F |> JacobianMatrix |> Determinant |> Simplify
    ce.assign('F', /* the counterexample map */);
    // Before → Pipe(Pipe(F, JacobianMatrix), Determinant) Now → -2

    f now decides whether the topic is evaluated (a lazy f receives it unevaluated). A chained topic — the inner pipe of a |> g |> f — is plumbing whose value flows on, so it is evaluated before reaching f. Eager stages, lambda right-hand sides, N(), and numeric chains are unaffected.

  • Indexing into a computed list hid the unknown from Solve. A held equation containing At(List(…), k) was opaque to the solver — it saw no unknown and answered [], which by contract means "proven no solutions". This is the last of the lazy-operand family: At is now projected structurally in a held operand.

    ce.box(['Solve', ['Equal', ['At', ['List', 'Y', 2], 1], 5], 'Y']).evaluate();
    // Before → [] Now → [5]

    Projection, never evaluation: with Y := 99, At([Y, 2], 1).evaluate() is 99, which inside a held equation would replace the unknown being solved for. Only a literal List with a literal in-range index is projected (negative indices count from the end); a symbolic list or index is left alone. The transformers (Simplify, Expand, …) get the same treatment.

  • Solve returned [] for an equation it could not see into. A lazy operator holds its equation and takes only .canonical, which binds structure without resolving values, so two kinds of operand stayed opaque: a call to a user-defined function, and a symbol whose value contains the unknown. Because [] means "proven no solutions", these were silent wrong answers rather than visible inertness.

    ce.assign('g', ce.parse('t \\mapsto t^2 - 4'));
    ce.box(['Solve', ['Equal', ['g', 'x'], 0], 'x']).evaluate();
    // Before → [] Now → [2, -2]

    ce.assign('s', ce.parse('\\frac{9-w^2}{4}')); // `s = 2` has no `w` in it
    ce.box(['Solve', ['Equal', 's', 2], 'w']).evaluate();
    // Before → [] Now → [1, -1]

    A transformer nested inside the equation (rather than at its root) is now reduced too, so Solve(Simplify(u) = 2, w) works.

    The unknown is never substituted, even when it has a value: the reduction is structural — .subs on the lambda body, .value on a binding — never .evaluate(). With x assigned 5, Solve(g(x) = 0, x) still returns [2, -2]. A recursive definition expands one level and stops.

  • Expression transformers ignored a user-defined function in their operand. Same root cause: Simplify(g(a)), Expand, Factor, Together and Distribute returned g(a) unchanged, and Integrate(g(t), t) stayed inert.

    ce.assign('g', ce.parse('t \\mapsto t^2 - 4'));
    ce.box(['Factor', ['g', 'a']]).evaluate();
    // Before → g(a) Now → (a - 2) * (a + 2)

    Beta-reduction substitutes the function body, so an assigned value for a symbol elsewhere in the operand is still left alone.

  • Distribute returned a product where it should return a sum. The helper recombined the branches of a distributed sum with Multiply instead of Add, so (a + b)·c became (a·c)·(b·c). Every input the operator acted on came back with a different value. The operator had no test coverage, which is why this survived; it is now covered by a numeric oracle.

    ce.box(['Distribute', ce.parse('(a+b)c')]).evaluate();
    // Before → a * b * c * c Now → a * c + b * c
  • numeratorDenominator reported a denominator of 1 for a bare negative power. Canonical form writes 1/x^2 as Power(x, -2), and the Power branch never moved a negative exponent into the denominator. The same factor inside a Multiply (y/x^2) routes through Product.asNumeratorDenominator, which splits on exponent sign and was already correct — so the two disagreed. This also affected the NumeratorDenominator operator and .denominator. A symbolic exponent is still left alone: its sign is not decidable there.

    ce.parse('\\frac{1}{x^2}').numeratorDenominator;
    // Before → [x^(-2), 1] Now → [1, x^2]
  • Together dropped denominators written as negative powers. It treated only a Divide node as carrying a denominator, so such terms were folded into the numerator and the combined fraction kept negative powers.

    ce.box(['Together', ce.parse('\\frac{1}{x}+\\frac{1}{x^2}')]).evaluate();
    // Before → (x * x^(-2) + 1) / x Now → (x + 1) / x^2
  • Expression transformers ignored a ReplaceAll in their operand. Expand, ExpandAll, Factor, Together, Distribute and Simplify are lazy and took only .canonical of their held operand, so a ReplaceAll reached them as an unevaluated call with no polynomial structure and was silently returned unchanged. The reduction is recursive, so a producer head nested inside the operand is handled too.

    ce.parse('\\mathrm{Expand}(\\mathrm{ReplaceAll}(x^2+x, x \\to a+1))').evaluate();
    // Before → ReplaceAll(x^2 + x, To(x, a + 1)) Now → a^2 + 3a + 2

    Note this is deliberately a different set from the transformer heads reduced by Solve/Integrate/Limit: ReplaceAll ends in .evaluate() and so substitutes assigned symbol values, which those algorithms must avoid.

  • toString() dropped the parentheses around a product-of-sums denominator, producing text that reads back as a different expression. The MathJSON and LaTeX serializations were correct throughout; only the ASCII form was affected. The grouping check treated any string starting with ( and ending with ) as already parenthesized, which (x + 1) * (x^2 - 1) satisfies without being a single group.

    ce.box(['Divide', 1, ['Multiply', ['Add', 'x', 1], ['Add', ['Power','x',2], -1]]]).toString();
    // Before → 1 / (x + 1) * (x^2 - 1) Now → 1 / ((x + 1) * (x^2 - 1))
  • Map now evaluates over a source that only becomes a collection when evaluated. Map(X - 1, f) stayed in its unevaluated lazy form while Map(X, f) and Map([0, 1, 2], f) both evaluated. The trigger was any source whose collection-ness is not visible before evaluation — a broadcast arithmetic result over a list, or an eager collection operator such as UnicodeScalars.

    ce.assign('X', ce.box(['List', 0, 1, 2]));
    ce.box(['Map', ['Subtract', 'X', 1], sq]).evaluate();
    // Before → Map(X - 1, (x) |-> x^2) Now → [1, 0, 1]

    ce.box(['Map', ['UnicodeScalars', { str: 'ab' }], sq]).evaluate();
    // Before → Map(UnicodeScalars("ab"), …) Now → [9409, 9604]

    Applies to the zipWith (multi-source) form as well, and to .at() — which previously returned undefined for such a source, so a result longer than the materialization head was silently rendered head-only instead of head-and-tail. Every other collection operator (Filter, Take, Sort, Reverse, First, …) already accepted these sources. Expressions that are genuinely not collections are unchanged: Map(5, f) still stays symbolic.

  • Compiled comparisons and logical connectives no longer return a wrong answer for a list operand. <, <=, >, >=, And, Or and Not could produce a scalar false (or one of their operands) where evaluate() returns an element-wise list of booleans — a wrong result from a successful compile. Such expressions now decline to compile and fall back to interpretation, which gives the correct answer.

    Mostly affects a filter whose condition is computed, such as L[|[1...n]-k|>0]: it now evaluates correctly but is no longer compiled, so expect interpreter performance for that shape. Scalar comparisons and connectives, and list literals such as Not([True, False]), still compile.

  • Complex values are handled correctly when compiled. At(p, 1+2i) and RandomList(n, 7+3i) now agree with evaluate(), which uses the real part of a complex index or seed. Previously the compiled forms returned NaN and a different random sequence respectively.

    A compiled scalar comparison against a complex value is still wrong (it returns false rather than declining); this is unchanged and tracked in ROADMAP.md.

  • At with several indices reports the correct type. For a 2×3 matrix M, At(M, 1, [1,2]) is the 2-element list [1,2] and At(M, 1, 2) is a single element — both previously reported the type of a whole matrix row.

0.92.1 2026-07-22​

Breaking Changes​

  • Join appends a tuple operand as a single element instead of splicing its components. A tuple is an indexed_collection, so Join used to iterate it and concatenate its components. But a tuple is a value — a point or vector — and the engine treats it as one everywhere else (Abs(point) → Norm, component-wise point arithmetic). Splicing silently degraded the point-list accumulation idiom L → Join(L, P): the result grew by 2 instead of 1 and stopped matching list<point>.

    // Before
    ce.box(['Join', pointList, ['Tuple', 2, 5]]).evaluate();
    // → ["List", …, 2, 5] length +2, heterogeneous `number` tail
    // After
    // → ["List", …, ["Pair", 2, 5]] length +1, still a list of points

    Join of collections is unchanged (Join([1,2],[3,4]) → [1,2,3,4]; Join([(0,3)],[(2,5)]) → [(0,3),(2,5)]). The one reversal is a tuple in operand position where it was previously flattened: Join((1,2),(3,4)) was [1,2,3,4] and is now [(1,2),(3,4)]. Join now agrees with Append on a tuple element.

  • Join now reports the joined ELEMENT type instead of a bare list. Its type handler returned list whatever it was given, so a joined point list did not match list<tuple<…>> and type-directed dispatch downstream stopped recognizing it. Each operand now contributes either its own type (an atomic tuple, which becomes one element) or its element type (a collection, which is spliced), widened into the result:

    Join(pointList, point); // was: list now: list<tuple<number, number>>
    Join([1, 2], [3, 4]); // was: list now: list<finite_integer>

    An operand whose element type is unknown still yields the bare list. This is a type-surface change: code pinning the exact string 'list' for a Join result sees the narrowed type instead — but matches()-based queries only gain precision.

Issues Resolved​

  • evaluateAsync() no longer tears down a scoped operator's local scope while the operator is still running. An evaluateAsync handler returns at its first suspension point, not at completion, so the dispatcher popped the operator's local evaluation context too early; everything the resumed handler did then ran against the enclosing scope. A big operator whose reduction outlived one time slice (roughly, more than ~16ms of work) assigned its loop index globally — leaking it, overwriting an outer binding of the same name, and throwing Cannot assign a value to the constant "i" for the commonest index spelling of all, since global i is ImaginaryUnit:

    await ce.parse('\\sum_{i=1}^{200000} i').evaluateAsync();
    // Before: throws `Cannot assign a value to the constant "i"`
    // After: 20000100000 (matches the synchronous lane)

    ce.assign('n', 7);
    await ce.parse('\\sum_{n=1}^{200000} n').evaluateAsync();
    ce.box('n'); // Before: 200000 (clobbered). After: 7

    The synchronous lane was fixed in 0.87.1; this brings the asynchronous lane — the cancellation path withTimeLimit documents — into line with it. Cancellation behavior is unchanged.

    Because the context is now held across the await, a scoped operator's frame is no longer necessarily on top of the evaluation-context stack when it unwinds — a second evaluation started while the first is suspended pushes above it. The frame is therefore removed by identity rather than popped, so an unwinding evaluation cannot discard (and dispose the bindings of) one that is still running.

    One known limitation remains, unchanged by this release: while an async evaluation is suspended, its scope is the engine's current one, so code that enters the same engine in that window can see the operator's local bindings (e.g. a loop index). Enclosing bindings still resolve correctly through the scope's parent chain, and the local scope is gone once the evaluation settles, but a name that collides with an in-flight index resolves to that index. Removing this needs per-evaluation (task-local) context propagation; until then, use one engine per concurrent evaluation.

0.92.0 2026-07-21​

Breaking Changes​

  • The parser's three symbol hooks are replaced by one oracle: resolveSymbol. The ParseLatexOptions handlers getSymbolType and hasSubscriptEvaluate (and the internal isSymbolDeclared) are replaced by a single optional handler:

    resolveSymbol?: (symbol) => { type, subscriptEvaluate? } | undefined

    Return undefined for a symbol the handler does not know; return a record (with a BoxedType or type-string type) for one it does. Declaration is the presence of the record — { type: 'unknown' } is a declared symbol of unknown type — so the previously inexpressible distinction between "undeclared" and "declared, type unknown" is now first-class, and inconsistent answers (a typed-but-undeclared symbol) are unrepresentable.

    Semantics also changed from replace to supplement: through ce.parse() the handler is consulted first and any symbol it does not resolve falls back to the engine scope's definitions. Handlers no longer need to re-implement scope delegation (previously required boilerplate with getSymbolType):

    // Before: vouch + hand-written scope delegation
    getSymbolType: (id) => {
    if (vouched(id)) return 'function';
    const def = ce.lookupDefinition(id); // boilerplate, easy to forget
    /* ... map def to a type ... */
    };
    // After: vouch only; unresolved symbols fall back to the scope
    resolveSymbol: (id) => (vouched(id) ? { type: 'function' } : undefined);

    Similarly, the Parser interface (custom LaTeX dictionary entries) replaces getSymbolType()/hasSubscriptEvaluate() with parser.resolveSymbol(), e.g. parser.getSymbolType(id).matches('function') becomes parser.resolveSymbol(id)?.type.matches('function').

Improvements​

  • A declared name now outranks subscript-index capture. Once a symbol B is bound to an indexed-collection value (a point, list, tuple…), the parser reads B_{2} as indexing (At(B, 2)), which made every subscripted sibling name (B_2, B_3, … alongside the point B) unspellable — and, since B_{2} and B[2] produce identical trees, unrecoverable after the parse. A subscripted spelling whose joined name is declared or assigned in scope now parses as that symbol; index capture applies only to undeclared joins, and bracket indexing (B[2]) is unaffected. Note that with the non-default indexStyle: 'subscript' serialization, At(B, 2) serializes as B_{2}, which re-parses as the symbol B_2 when such a declaration exists.

  • Rubi integration (experimental) — nested-radical substitution fallback (R31). loadIntegrationRules now closes nested-radical and sum-of-two-radical integrands the bundled algebraic rules leave inert. A nested radical (√(x+√(x+1))/x², √(1/x+√(1/x+1)), the double-radical √(x+1)/(x+√(√(x+1)+1))) is rationalized by iteratively substituting u = (a+b·x)^{1/k} (or the Laurent (a+b/x)^{1/k}) at the innermost radical linear in x, keeping the resulting rational's denominator factored so the bundled partial-fraction rules close it; the conjugate shape (√(x+1)+√(1−x))⁻² is rationalized by its conjugate. Each result is accepted only after a domain-aware numeric derivative check against the integrand, so out-of-scope shapes stay cleanly unsolved. On the Bondarenko benchmark this lifts CE+Rubi from 12/35 to 20/35 (closing 8 previously unsolved nested-radical integrals; a ninth, #16, closes only under the production bundle's compiled rule set). Structurally inert off its family, and disableable with RUBI_NO_R31.

0.91.0 2026-07-21​

New Features​

  • FindFit — general nonlinear least-squares fitting. FindFit(data, model, params, vars) fits an arbitrary model expression to data by Levenberg–Marquardt, returning a record {parameters, converged, residualNorm, iterations}. Unlike the closed-form LinearRegression/PolynomialFit, the model may be any composition (a·e^{b·x} + c, Gaussians, power laws, cosines, …). data is a list of (x…, y) tuples or a plain list of y values (x = 1, 2, …). Each parameter spec is a bare symbol (start 1, unbounded), (a, a0) (explicit start), or (a, a0, lo, hi) (start plus a box constraint, with ±∞ allowed for one-sided bounds). Convergence is first-class: non-convergence within the iteration budget is reported as converged: False with the best-so-far values, never a silent wrong answer. Jacobians are analytic (via D), with a per-column forward finite-difference fallback for components that cannot be differentiated symbolically. A joint form fits several models to several datasets sharing parameters (a list of models paired with a list of datasets, residuals stacked).

  • FindRoot — numerical equation solving. FindRoot(equations, params) finds parameter values that zero one or more residuals, sharing the same parameter-spec grammar, box constraints, and result record as FindFit (root-finding is the zero-residual case of the same Levenberg–Marquardt core). equations is an equation (lhs == rhs), a bare residual expression (read as = 0), or a list of either.

Resolved Issues​

  • Applying a declared-then-assigned function to a large collection is now lazy, matching the hybrid-laziness contract. Since 0.84.0, element-wise operations over collections of more than 100 elements (or of unknown length) evaluate to a lazy Map — but a function registered via ce.declare('g', '(number) -> number') + ce.assign('g', x ↦ …) resolved through a value definition whose application-site broadcast still zipped eagerly, walking the whole collection at evaluate() time. The value-definition application path now goes through the same laziness gate as parse-assigned functions (g(x) := …) and built-in broadcasts: past the eager threshold g(X) returns a lazy Map, results of ≤100 known-finite elements are byte-identical to before, collection-typed parameters still bind their argument whole, and tuples stay atomic. (Not a recent regression: this path had been eager on every release since laziness shipped in 0.84.0.)

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE (current)CE 0.86.1SymPymath.jsMathematica
\pi^26.27.01751733.9
\sin 120202205065.2
\cos 120212196126.9
\ln 214143484,6164.0
e^{\pi}12122215,0864.6
\zeta(3)1,5801,619261—49
\Gamma(\tfrac13)842839345—211
\psi(\tfrac13)7287202,806—168

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case; ✓ means it solves a case Mathematica can't. Compare the CE (current) and CE 0.86.1 columns to see what is new this release (a — under 0.86.1 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE (current)CE + R/FCE 0.86.1SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx6.7×3.1×5.2×0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx10×1.6×8.4×0.08×—1×
\int\frac{1}{x^3+1}\,dx6.3×0.9×4.4×0.4×—1×
\int\frac{\sqrt x}{1+x}\,dx—2.0×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—1.4×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—1.3×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.1×0.1×0.06×0.003×0.01×1×
Simplification
\sqrt{3+2\sqrt2}41×31×32×——1×
\sqrt6\,x+\sqrt2\,x104×54×59×3.3×16×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}58×29×47×3.0×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x9.3×5.6×8.0×2.1×—1×
\int_1^2\tfrac1x\,dx7405×6951×6437×77×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx423×161×363×2.6×—1×
Solving
x^4+x^2-1=00.3×0.3×0.2×0.06×—1×
x^3-x-1=01.7×2.0×1.4×0.04×—1×

Across the cases both solve, Compute Engine is a median 6.7× faster than Mathematica (up to 7405×) — in the browser, not a proprietary kernel.

Measured 2026-07-21 · Compute Engine 0.90.0 @ 8740998f (current build) · published 0.86.1 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.90.0 2026-07-21​

New Features​

  • RandomList(n) — an eagerly-materialized list of n independent uniform reals in [0, 1). Draws come from the engine's random stream (honoring ce.randomSeed for reproducibility); the two-argument form RandomList(n, seed) produces a deterministic list from an explicit seed, independent of the engine stream. Eagerness is deliberate: the result is a concrete List, so every reference to it sees the same draws (a lazy collection of Random() calls re-draws on each traversal). With a literal count the length is part of the type (RandomList(5) types vector<finite_real^5>). The count is capped at 10⁶ elements; a larger count — or a negative one — returns an out-of-range error rather than silently misbehaving.

Improvements and Resolved Issues​

  • Abs of a fixed-arity point is now the Euclidean norm. |(3, 4)| evaluates to 5 — the single-bar spelling of the vector magnitude, consistent with the \lVert…\rVert (Norm) parse and with tuple arithmetic treating points as vectors in ℝⁿ. Previously the expression stayed inert under evaluate(), and compiled to garbage: the JavaScript target returned the bare component array (which concatenated into a string in downstream arithmetic), and the shader targets emitted invalid code. All targets now compile it as the norm (_SYS.norm on the js target, length() on GLSL/WGSL for 2–4 components; other arities and points with a broadcasting component fail closed to interpretation). Detection is type-based, so a tuple-typed symbol or parameter routes as a point too. Abs over a List is unchanged and still broadcasts elementwise. Hypot with a point argument now squares through the norm as well: Hypot((3,4), 1) = √26 (previously an inert Power of a tuple). And Norm/Abs of a point now honor the exactness contract: evaluate() keeps the exact √2, .N() numericizes.

  • Norm compiles on the interval-js target for fixed-arity points (default L2 norm; hypot-based in 2-D for a tighter enclosure). Implicit curves and line series expressed with \lVert…\rVert or |…| now get interval-arithmetic break detection instead of silently degrading to point sampling.

  • Norm/Abs of a point with a broadcasting component reports an honest list<number> type. ‖(x+[0.5,1], y, z+2)‖ evaluates to a List (one norm per zipped element); its static type now says so instead of number, matching the equivalent \sqrt{(x+[0.5,1])^2+…} spelling.

  • A Comprehension serializes with bracket delimiters so it survives its own round trip in every position: H=[k^2 \operatorname{for} k=[1...4]] now serializes as H=\left[k^2 \operatorname{for} k = 1..4\right] (previously the unfenced form re-parsed with the assignment swallowed into the comprehension body, and under an Add the trailing term was absorbed into the iteration range). The fence is unconditional — [body for …] parses back to the same Comprehension, so it is lossless. (Non-canonical serialization-shape callout, same class as the 0.83 big-operator body fence.)

  • Expression.isValid is now O(1) after the first query. Validity is a structural property of an immutable expression, so it is computed once and cached; previously every query re-walked the whole subtree (and a parent's query re-entered each child's), which dominated large-document workloads — one profiled 75-row import spent 77% of its time in repeated isValid walks.

0.89.0 2026-07-21​

Breaking Changes​

  • ComputeEngine.timeLimit is removed, completing the deprecation begun in 0.88.0. There is no implicit ambient deadline anymore: an evaluate() or simplify() outside a span runs unbounded, and ce.withTimeLimit(ms, fn) / ce.withTimeLimit({ ms, label }, fn) spans are the only way to arm a deadline. If you relied on the old 2000 ms ambient default, wrap your evaluation entry point in a span:

    const r = ce.withTimeLimit({ ms: 2000, label: 'my-app:eval' }, () =>
    expr.evaluate()
    );

    The engine.timeLimit:<operator> attribution synthesized for ambient timeouts is gone with it — every CancellationError.attribution now names a span you created (or is undefined for an unlabelled span).

  • The BoxedTensor class is removed. Tensor values (vectors, matrices) are now ordinary canonical List expressions; "tensor-ness" is a property (shape-regularity), not a distinct representation. The BoxedTensor type export and the TensorInterface.tensor accessor (which exposed the internal packed Tensor object) are gone. The isTensor() guard remains and now answers the representation-independent question "is this a shape-regular list?"; .shape and .rank remain and now report honestly on every tensor-shaped list — including broadcast results, which previously reported []/0. Code that used expr.tensor should operate on expr.ops (the elements) or use the public linear-algebra operators.

  • Lists carry honest, shape-aware types. A shape-regular list's type reports its actual element type and dimensions: [1, 2, 3] types vector<finite_integer^3> (previously vector<3>, i.e. number elements), [[1, 2], [3.5, 4.5]] types matrix<finite_real^(2x2)>, and a list of non-numeric values is no longer mistyped as a numeric vector — [rgb(1,0,0), rgb(0,1,0)] types list<color^2>, not vector<2>. The new types are strict subtypes of the old ones, so type.matches('vector<3>') and similar queries continue to answer true; only code comparing exact type strings is affected. An evaluated broadcast result now carries its shape too (Sin([0,1]).evaluate() types vector<2>), and the declared type of a broadcast expression is always a sound upper bound of its evaluated value's type.

  • Signature validation of collection arguments is two-stage. An operand whose static type could still conform to a collection parameter (a symbol declared plain list, list<unknown>, a broadcastable<…> intermediate) is accepted at canonicalization and checked against its actual value when the operator evaluates. Provably-wrong operands (Determinant("abc"), Determinant(v) with v: list<number> — a flat vector can never be a matrix) still error immediately. Consequently some incompatible-type errors that used to appear at parse/canonicalization time now surface at evaluation time instead (as the operator's specific error, e.g. expected-square-matrix, or as an inert expression).

Improvements​

  • Matrix operations work on computed matrices. The static type of a broadcast application now mirrors its operand's shape (Sqrt(M) with M a 2×2 matrix types as a 2×2 matrix), so expressions like Determinant(Sqrt(M)), MatrixMultiply(Sqrt(M), Sqrt(M)), or Inverse(A + B) — which used to fail with incompatible-type at canonicalization — now evaluate. Symbols declared list participate the same way once a conforming value is assigned.

  • Structural matrix operations work on any cell type. Transpose, ConjugateTranspose, Reshape, Flatten, and the shape predicates (IsSquareMatrix, IsSymmetric, IsDiagonal) operate on any shape-regular list — a matrix of colors, tuples, or unevaluated function applications — not just numeric ones. Numeric kernels (Determinant, Inverse, …) still require numeric cells and decline others gracefully.

  • Exact integer matrices stay exact. Linear-algebra kernels over exact integer matrices now use exact arithmetic under evaluate(): Determinant([[9007199254740991, 0], [0, 3]]) returns the exact 27021597764222973 (previously rounded through float arithmetic). Under .N() results are floated, as before (Inverse([[2,1],[1,3]]).N() → [[0.6, -0.2], [-0.2, 0.4]]).

  • List equality is tolerant and NaN-aware in all cases. [1,2,3].isEqual([1,2,3+1e-11]) is true (engine tolerance), [x,2].isEqual([y,2]) is undefined (symbolic), and a list containing NaN is never isEqual to itself (mirroring scalar NaN ≠ NaN) — uniformly for every list, whatever produced it. Ordering comparisons (isLessEqual, …) between equal tensors of any cell type now answer true instead of undefined.

  • Faster broadcast arithmetic. Removing the eager tensor construction from the boxing path makes broadcast-heavy evaluation measurably faster (~30% on elementwise matrix expressions), with no per-expression packing cost until a numeric kernel actually runs.

Issues Resolved​

  • A function call over a collection-valued expression keeps its broadcast type. With h a function over numbers (declared or inferred) and L a list, h(L) already typed as a list — but h(L + 1) or h(2L) typed as a scalar while still evaluating to a list. Both now type as the shaped vector (h(L+1) → vector<3> for a 3-element L), and a declared scalar signature no longer rejects a collection argument with incompatible-type — scalar-parameter functions are threadable, so the argument broadcasts, matching what evaluation always did. Scalar applications and genuinely invalid arguments (h("abc")) are unchanged.

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE (current)CE 0.86.1SymPymath.jsMathematica
\pi^26.77.51761374.1
\sin 121212204445.1
\cos 120202245366.9
\ln 216163525,7543.8
e^{\pi}14152154,7654.5
\zeta(3)1,7341,790282—51
\Gamma(\tfrac13)957950356—209
\psi(\tfrac13)8067952,818—170

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case; ✓ means it solves a case Mathematica can't. Compare the CE (current) and CE 0.86.1 columns to see what is new this release (a — under 0.86.1 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE (current)CE + R/FCE 0.86.1SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx6.6×3.1×5.4×0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx8.0×1.3×6.8×0.09×—1×
\int\frac{1}{x^3+1}\,dx5.6×0.8×4.3×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—1.7×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—1.2×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—1.2×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.04×0.04×0.02×0.001×0.003×1×
Simplification
\sqrt{3+2\sqrt2}44×34×34×——1×
\sqrt6\,x+\sqrt2\,x131×67×72×5.0×23×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}53×25×43×3.0×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x8.7×5.1×7.2×2.1×—1×
\int_1^2\tfrac1x\,dx6954×6552×5758×93×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx382×133×341×2.6×—1×
Solving
x^4+x^2-1=00.2×0.3×0.2×0.06×—1×
x^3-x-1=01.5×1.7×1.4×0.04×—1×

Across the cases both solve, Compute Engine is a median 6.6× faster than Mathematica (up to 6954×) — in the browser, not a proprietary kernel.

Measured 2026-07-21 · Compute Engine 0.88.1 @ afde4f88 (current build) · published 0.86.1 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.88.1 2026-07-20​

Issues Resolved​

  • Color converters (AsRgb, AsHsv, AsHsl, AsOklab, AsOklch) broadcast over lists, like the color constructors already did: AsRgb([Hsv(120,1,1), Hsv(0,1,1)]) → [Rgb(0,1,0), Rgb(1,0,0)] instead of an incompatible-type error. A non-color element produces a per-element error rather than rejecting the whole call. Out-of-range channels continue to pass through unchanged — they can represent valid out-of-sRGB-gamut colors, so constructors and converters neither clamp nor error.

  • ce.box() no longer throws on malformed MathJSON input. A non-MathJSON plain object (ce.box({foo: 1})) or an array whose head is not a symbol used to throw a JavaScript Error; both now return a boxed ["Error", "'unexpected-mathjson'", …] expression (with the offending input as context), consistent with how every other input problem is reported. The engine remains fully usable afterward.

0.88.0 2026-07-20​

Deprecations​

  • ComputeEngine.timeLimit is deprecated in favor of ComputeEngine.withTimeLimit(). timeLimit arms a hard-to-scope implicit deadline around each evaluate()/simplify(); wrap the work you want bounded in a span instead:

    // Before
    ce.timeLimit = 500;
    const r = expr.evaluate();

    // After
    const r = ce.withTimeLimit({ ms: 500, label: 'my-app:eval' }, () =>
    expr.evaluate()
    );

    timeLimit still functions exactly as before in this release; it will be removed in a future minor version.

Improvements​

  • ComputeEngine.withTimeLimit() accepts an attribution label. In addition to the numeric form withTimeLimit(ms, fn), an object form withTimeLimit({ ms, label }, fn) (preferred for new code) records a label on the span. Nesting still composes as min() — a labelled inner span can only shorten the effective deadline, never extend it past an enclosing one.

  • CancellationError now carries attribution and spans. When a timeout fires, attribution is the label of the span that owns the deadline that fired (so a caller can compare it against the label it passed to distinguish "my sub-budget expired" from "my caller's budget expired"), and spans lists all active span labels, outermost first. Timeouts armed by the deprecated ambient ce.timeLimit are attributed as engine.timeLimit:<operator> (e.g. engine.timeLimit:Integrate).

  • Divisor functions are now O(√n) instead of O(n). Sigma0, Sigma1, SigmaMinus1, IsPerfect, and Totient compute from the prime factorization rather than trial iteration: Sigma1(1000000007) went from ~14s to ~1ms, and 11+-digit inputs that previously ran essentially forever return instantly.

  • Integer factorization is interruptible. The Pollard-rho factorizer now honors the active deadline (a withTimeLimit span interrupts a hard semiprime factorization on time, with attribution) and is backstopped by an iteration budget (cause: 'iteration-limit-exceeded') so it terminates even with no time limit set.

  • Symbolic integration no longer swallows a caller's timeout. If a withTimeLimit span enclosing an integration expires mid-attempt, the CancellationError now propagates to the caller (identified via attribution) instead of being silently converted into "no antiderivative found". Rubi's own internal sub-budgets still degrade gracefully.

Fixes​

  • Unary D applications no longer serialize to a bare D. An arity-1 (or otherwise unrecognized) D application — e.g. a document-defined function named D — used to serialize with its argument silently dropped (["D","w"] → "D"). It now serializes as \operatorname{D}(w), which round-trips exactly. Recognized derivative shapes (D(f,x) → Leibniz notation) are unchanged.

  • A known-value uppercase symbol before a parenthesized group now parses as multiplication. The predicate-notation heuristic (a single uppercase letter before (...) reads as a function application, e.g. P(x)) applied even when the scope knew the symbol was a value: with K assigned -32.3, K(2-0.1) parsed as a call of K and evaluated to an incompatible-type error. The heuristic now consults the scope — a symbol with a known non-function type falls through to multiplication (K(2-0.1) → -61.37). Unknown and function-typed symbols are unchanged.

  • Juxtaposed-multiply serialization is round-trip safe for single-uppercase factors. Multiply(K, group) serialized as K(group), which re-parses as a function call of K — corrupting the expression when K is a numeric symbol. A single-uppercase-letter factor directly against a parenthesized group now emits an explicit \times (K\times(…)). Other factor shapes (x(y+z), 2(x+1), \mathrm{abc}(x+1)) already round-tripped and are untouched.

  • ce.box() now accepts native bigint values. bigint was declared in ExpressionInput but unhandled by the boxing dispatch, so ce.box(123n) — bare or as an operand in ce.box(['Add', 10n, 5]) — silently became the Undefined symbol. Bigints now box as exact integer literals, preserving exactness at any magnitude (never routed through a float), identical to the ce.number(bigint) path.

  • Unbounded collection walks over infinite lazy sources. First/At on a Filter or TakeWhile whose predicate never (or eventually never) matches, and any walk over a Dedup of a source with infinitely repeating duplicates (e.g. Dedup(Cycle([1,1]))), could previously spin until the ambient time limit fired — or forever with timeLimit = 0. These walks are now bounded by iterationLimit and degrade gracefully (Nothing/undefined), consistent with the documented lazy-collection contract. Note: as a consequence, Count(Dedup(...)) over a finite source larger than iterationLimit now returns undefined (unknown) instead of walking the whole source, matching Filter's existing count behavior.

0.87.2 2026-07-20​

Breaking Changes​

  • Add no longer widens an unreachable scalar arm into a broadcast collection type. A sum mixing a scalar with a list-shaped operand typed as a union — matrix + 1 was finite_integer | matrix, 2·[1,2,3] + a was number | vector<3> — even though the value ALWAYS broadcasts elementwise and can never be a scalar ([[1,2],[3,4]] + 1 → [[2,3],[4,5]]). These now type as the collection: matrix, vector<3>. The behavior was inconsistent as well as imprecise — a dimensionless list<number> + 1 was already repaired to list<number> downstream, so only dimensioned shapes carried the artifact.

    This is a type-surface change: code pinning the union spelling will see the narrowed type instead. It is a strict improvement for consumers that dispatch on the type, and that is the motivation — union matching is all-members, so type.matches('collection') returned a confident false on a value that is always a collection, silently routing list-valued rows down scalar paths (reported by Tycho as item 67). It also unblocks expressions CE itself rejected: MatrixMultiply([[x, y]], aM₁ + M₂) failed signature validation on the union operand and now evaluates.

    Generic collection/set-typed operands are deliberately unchanged and keep the honest union — a non-indexed collection is never broadcast by the value path, so a scalar outcome stays reachable there.

Resolved Issues​

  • isValid now honors its documented contract through a list/tensor. isValid is specified as false if the expression "or any of its subexpressions" is an ["Error"], but BoxedTensor.isValid returned true unconditionally — so a List whose every element was an Error reported isValid: true. (1,2)+[3,4] broadcasts to a list of incompatible-type errors and passed the gate. An embedded Error element now poisons the enclosing expression, matching what BoxedFunction.isValid already did.

    Behavior change worth noting even though the contract is unchanged: consumers using isValid as an admission gate before compiling or plotting will now correctly reject expressions they previously admitted.

    Only expression-dtype tensors are scanned; float64/complex128/bool fields cannot hold an Error by construction and keep the O(1) answer, so this does not add a per-element walk to large numeric tensors.

    The isValid documentation has been expanded to spell out the contract: the check is deep (including list elements and held operands), and it tests well-formedness rather than meaningfulness — free symbols, undeclared functions, NaN and ±∞ are all valid.

0.87.1 2026-07-20​

Breaking Changes​

  • compile(..., { realOnly: true }) now rejects a complex-valued tuple/list component. realOnly coerced only the top-level result, so a { re, im } object sitting in a component slot passed through untouched and reached the caller in a number slot — (t, i t) compiled successfully and returned [0.5, { re: 0, im: 0.5 }]. A complex component now fails the compile with a diagnostic naming the component, matching what the GPU targets already do and the existing Sqrt(-1) "no real value" error. The component's type cannot decide this — with t undeclared, (t, i t) and (t, t²) both infer finite_number — so the check uses the same isComplexValued analysis the GPU targets fail closed on, and every target now rejects the same shapes. Real-valued tuples are unaffected, and the runtime realOnly coercion now also recurses into array results for values that only become complex when called ((t, √t) at t = -4 → [-4, NaN]). The check follows only positions that can produce the compiled result, so a complex value consumed by an operation with a real result still compiles (At([i, 2], 2) → 2).

Resolved Issues​

  • A When (restriction) whose value was a COLLECTION did not expose the collection interface. isCollection was false, count undefined and each() yielded nothing, even though .type already reported vector<N> / list<tuple<…>> — the type system and the collection interface disagreed about the same value. Only a LIST-valued condition broadcast; the common case of one scalar restriction over a whole list left an opaque wrapper in place. A collection-valued When now behaves as [When(L₁,c), …, When(Lₙ,c)]: at or below MAX_SIZE_EAGER_COLLECTION it distributes into a List, and above the threshold it stays a held When that is nonetheless fully enumerable (count/each()/at()), following the same hybrid-lazy convention as PointList. A scalar When still reports as a scalar, and a Tuple-valued one is not split into its components so a restricted point stays a point. Operators whose collection-ness depends on their operands can now declare it with the new optional BaseCollectionHandlers.isCollection predicate. Every handler-backed collection API honors that opt-out — count, each(), at(), get(), indexWhere(), subsetOf(), contains(), isLazyCollection and isIndexedCollection — so none of them can report a collection answer for a value that says it is not one. The opt-out is deliberately narrower than isCollection === false: an eager collection operator such as UnicodeScalars has no collection handlers at all until it is evaluated, and still goes through the materialize-then-iterate path.

  • A Sum/Product over three or more indexing sets silently dropped every index after the second. The cartesian product of the indexing sets was built by a fold that returned tuples of the wrong length — for a 2×2×2 product, eight tuples of length 2, with pairs duplicated — and since the reducer reads the tuple positionally, the third and later loop indexes were assigned undefined. Any triple sum or product was therefore wrong, without a diagnostic. The full n-dimensional product is now iterated, with the last index varying fastest. One and two-index big-ops are unaffected.

  • A Sum/Product with a large finite bound exhausted the heap before it could be interrupted. The whole index product was materialized up front, so Σ_{i=1}^{10⁸} allocated 10⁸ one-element arrays before the reducer ran a single step — the process died before run()/runAsync() or any deadline could cancel it. Index tuples are now streamed one at a time, keeping allocation proportional to the number of indexes, and the engine deadline is checked between terms. Bounds whose magnitude exceeds Number.MAX_SAFE_INTEGER over a non-degenerate range can no longer be enumerated faithfully at number precision — adding one to such a value does not change it — and now evaluate to an ["Error", "out-of-range"] rather than silently truncating to a single term. A degenerate range (lower === upper) still yields exactly one term.

  • A List element whose container type was revealed only by canonicalization was flattened into a numeric tensor. Tensor eligibility is decided on raw operands so nested Lists stay visible, but a wrapper such as a parsed Delimiter, If, Which, When or Hold reports its tuple/set/ dictionary/record type only after it is canonicalized. Such elements were taken for scalar tensor components, so a list of tuples collapsed into a vector<N> and lost its structure. Container-valued elements are now recognized both by operator name and by type — primitive and structured alike, including as a member of a union — and keep the expression a List.

  • A Sum/Product index named i was read as ImaginaryUnit by the compiler's complex-valuedness analysis, silently corrupting the enclosing arithmetic on every target. The binder's bound name reached the engine-value fallback as if it were a free symbol, so the analysis complex-tainted the sibling operand of any enclosing arithmetic — the Sum itself emitted correctly. \sum_{i=0}^{2}\cos(it)+2.5 compiled (success: true) to NaN, and \sin(\sum_{i=0}^{2}\cos(it))+2.5 to a silently wrong 2.5; the interpreter was correct throughout. The index need not appear in the body, and \prod was affected identically. A binder's bound names are no longer analyzed as free symbols; loop/summation indices are treated as the integer counters they are, while function parameters keep their declared types (a complex parameter stays complex).

  • A per-evaluate ce.timeLimit was silently inert inside a ce.withTimeLimit(ms, fn) span. The span deadline replaced, rather than min-ed with, every inner clamp, so a pipeline wrapped in a span lost its inner bounds — a 500 ms clamp ran the full 60 s span. The effective deadline is now min(ambient, now + timeLimit). Plain nested evaluations are unaffected. Note this applies to synchronous evaluate(): a span's deadline is still restored when its callback returns, so evaluateAsync() under a span remains unbounded.

  • GPU targets emitted invalid shader source for vector-valued block locals and over-wide vecN constructors. A tuple/point-valued block local was declared float while being assigned a vecN/array, vector width did not propagate through an aliased local (q := p), and vecN constructor arity was chosen from the argument count when it is a component count — so a complex or nested-tuple element overflowed the constructor. Locals now declare the matching type, width propagates through aliases, and an aggregate-valued component fails closed with a diagnostic instead of emitting source no driver accepts. Failing closed now also covers a matrix-valued component, an empty tuple/list (neither language has a zero-length array type), and a block local bound to values of disagreeing shapes within one block (a shader local has a single declared type, and there is no declaration a scalar and a vecN assignment both satisfy).

  • A delimited \mapsto body was read as a statement Block rather than a Tuple, so a point-valued lambda silently dropped all but its last component. t \mapsto (\cos t, \sin t) applied at t = 0.5 returned 0.479… — just sin 0.5 — on every target, js included; the equivalent g(t) := (\cos t, \sin t) was already correct. A delimited lambda body is now data (a Tuple) whatever its separator; a genuine statement block is built by the ; infix parser when the sequence contains an Assign and reaches the lambda parser already formed, so (x := 1; x+1) is unchanged.

0.87.0 2026-07-19​

Resolved Issues​

  • Exponential blowup evaluating float-carrying symbolic bodies inside function applications. The "inexact operand numericizes a closed-constant sum/product" rule (0.5 + π → 3.64…) decided "closed constant" by resolving symbols through the dynamic scope chain, so inside a function application a bound-but-symbolic parameter counted as known: a body term like z² + 0.3 fired a full-subtree N() walk that could make no progress, at every nested level, mutually recursive with evaluate — ~×7.5 work per nesting level. The canonical victim was interpreted evaluation of a recursive function over a symbolic argument (Q(n, z) = Q(n-1, z)² + 0.3, the iterated-map shape): depth 7 took ~8 s and depth 8+ hit the time limit, where the same recursion with an exact constant (3/10) unwound in milliseconds. The gate is now the lexical isConstant (every symbol a constant binding) — depth 7 drops ~500× to ~15 ms, float and exact now cost the same, and 0.5 + π, 0.5 + √2, and 0.5 + x all behave exactly as before. Two neighboring sites sharing the wrong predicate returned flat-wrong values inside applications and are fixed the same way: KroneckerDelta(w) over a bound symbolic parameter returned 0 (now stays symbolic), and Degree(w²) returned 0 (now 2).

    Relatedly, many non-lazy evaluate handlers re-evaluated operands the evaluation driver had already evaluated. Each such call re-descends the whole operand subtree, so under nesting the waste compounded — a residual ×2-per-level re-walk on top of the bug above. All library handlers now follow the handler contract (a lazy operator's handler owns its operands' single evaluation; a non-lazy handler receives them already evaluated and must not re-evaluate): Power, Sqrt, Root, Divide, Ln, Log, Negate in arithmetic; the linear-algebra operators (Transpose, Determinant, Inverse, MatrixMultiply, Norm, the eigen/decomposition family, matrix constructors and predicates, ~30 sites); the statistics reducers (Mean/Median/Variance/… — 11 sites); and Text. Symbolic recursive unwinding is now linear: depth 80 unwinds in ~95 ms where depth ~20 previously hit the time limit.

  • Timing now measures the actual evaluation. Timing was a non-lazy operator, so the engine evaluated its argument before the handler ran and the handler then timed a redundant second walk of the already-evaluated result — reported times measured cache-warm re-walks, not the computation. Timing is now lazy: the handler receives the raw argument, canonicalizes it outside the timed region, and times the real evaluation.

  • One-time cache builds are no longer charged against the time limit. The engine builds some internal tables lazily on first use (constructible trig values on the first sin(π/6)-style evaluation, the standard simplification rule set, etc.). Previously this warm-up ran inside the caller's timeLimit/withTimeLimit() budget, so a tight deadline on a fresh engine could lose a large fraction of its budget — or fire mid-build — on the very first call. The deadline is now suspended while a cache builds and then pushed back by the build's duration, so a time budget measures only the caller's own evaluation. Relatedly, a timeout that did fire during a cache build was swallowed and resurfaced as an unrelated TypeError; an interruption now propagates as the CancellationError it is, leaving the cache unbuilt so a later call retries.

  • Compiled real/complex convention mismatch in branch arms (js target). A provably-real branch arm (If/Which/When) alongside a complex-valued arm compiled to a plain number while consumers of the branch read { re, im } slots — so a constant base-case arm in a complex-ascribed recursive function (M(0, z) = 0, the canonical base-case shape) returned NaN at every point, including points that never left the base clause. Real arms are now coerced to the complex convention when any arm is complex (the no-match default likewise emits { re: NaN, im: NaN }); wide-typed pass-through arms (a z slot declared number carrying a complex value at run time) stay bare. The same coercion now applies at the two sibling seams: a Typed complex ascription over a provably-real operand (previously silently inert in compiled code — an all-real body under a declared -> complex return), and a provably-real call-site argument bound to a complex-typed parameter of a user-defined function (M(10, 0) — Complex(0, 0) canonicalizes to the real literal 0).

New Features​

  • New engine flag ce.jit: 'auto' | 'off' governing every implicit compilation path — the new lazy-Map auto-compilation (below) and the pre-existing compiled numeric kernels (NIntegrate/ND/NLimit, the Integrate/Limit numeric fallbacks, NDSolve right-hand sides, the solve-domain enumeration sieve, the stochastic-equality probes, the compiled Reduce fast path). Default 'auto': attempts run, and on the first environment-level EvalError (a strict-CSP host refusing dynamic code) the engine latches to 'off' engine-wide, capping CSP violation reports at one. Set 'off' up front on strict-CSP pages, MV3 extensions, or hardened runtimes — or as a diagnostic kill switch. Explicit compile() is exempt and keeps failing loudly. Implicit compile failures now fall back to the interpreter silently (previously some of these paths logged a Compilation fallback warning).

Performance​

  • Lazy-Map element lambdas auto-compile on numeric drains. Draining a lazy broadcast (f(Range(1, 10^5)).N(), a PointList sweep, an addN/mulN broadcast) whose element lambda applies interpreted user-defined functions previously paid the full symbolic pipeline per element (~ms/element). At machine precision (the gate: the default engine precision is bignum and never triggers this), such drains now compile the element lambda once per logical Map — eligibility-gated (pure bodies, literal-bounded loops, no unbound free symbols, ambient-scope captures only) — and serve elements from the compiled function, ~30–2500× faster with digit parity against the machine-precision interpreter. The compiled function is validated before every invocation against the same two-axis mutation keys as the comprehension memo, so reassigning a captured symbol (even mid-drain) recompiles, while unrelated assignments don't thrash the cache. Per-element fallback to the interpreter is silent and exact: non-numeric rows, NaN results (re-checked through the interpreter so √x over a sign-crossing source still yields complex values), and ineligible bodies (e.g. containing Random) behave exactly as before.

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE (current)CE 0.86.1SymPymath.jsMathematica
\pi^27.28.11791023.9
\sin 123242244425.2
\cos 123232245717.0
\ln 215153444,3923.7
e^{\pi}13132124,9304.5
\zeta(3)1,7321,733265—48
\Gamma(\tfrac13)928942347—219
\psi(\tfrac13)7778142,967—196

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case; ✓ means it solves a case Mathematica can't. Compare the CE (current) and CE 0.86.1 columns to see what is new this release (a — under 0.86.1 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE (current)CE + R/FCE 0.86.1SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx5.4×2.4×4.3×0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx8.2×1.1×7.0×0.09×—1×
\int\frac{1}{x^3+1}\,dx4.3×0.5×3.3×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—1.4×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—1.1×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—1.1×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.03×0.03×0.02×0.001×0.003×1×
Simplification
\sqrt{3+2\sqrt2}33×19×25×——1×
\sqrt6\,x+\sqrt2\,x88×48×63×3.4×16×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}64×31×60×3.3×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x8.5×5.1×8.0×1.9×—1×
\int_1^2\tfrac1x\,dx6388×6536×6482×75×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx375×144×356×2.2×—1×
Solving
x^4+x^2-1=00.4×0.3×0.3×0.08×—1×
x^3-x-1=01.6×1.7×1.6×0.04×—1×

Across the cases both solve, Compute Engine is a median 5.4× faster than Mathematica (up to 6388×) — in the browser, not a proprietary kernel.

Measured 2026-07-20 · Compute Engine 0.87.0 @ 0158397a (current build) · published 0.86.1 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.86.3 2026-07-19​

New Features​

  • Recursive user-defined functions now compile. Self- and mutually recursive functions (fact(n) := n ≤ 1 ? 1 : n · fact(n-1)) compile on the javascript and interval-js targets to true recursion, instead of failing closed. Termination is the caller's contract, matching compiled unbounded Loop: on the javascript target runaway recursion throws a catchable RangeError; on interval-js the runner converts runtime errors to the entire interval ("cannot bound"), per that target's error philosophy. (The interpreter throws CancellationError on its time limit instead.) A complex-valued recursive function needs a Typed complex return ascription on the function literal so the self-call types as a scalar — without it the application types broadcastable<number> and complex arithmetic over it does not compile. GPU targets (GLSL/WGSL) are unchanged: shaders cannot recurse, so recursion stays fail-closed there. Measured on a depth-10 iterated Julia map, the recursive form runs ~0.18 µs/pt — about an order of magnitude faster than the equivalent hand-unrolled closed form compiled before this release.

  • Function literals accept a signature-string shorthand. ["Function", body, "'(n: integer, z: number) -> complex'"] desugars at canonicalization into the structural Typed form (typed parameters plus a return-type ascription) — one compact string instead of nested Typed wrappers, reusing the full type grammar. Signatures must name every parameter; optional/variadic markers are not yet supported and fall through to the standard parameter validation error.

Performance​

  • Small literal integer powers of complex values compile to inline multiply chains. z^k for literal k = 2…8 with a complex-valued z emitted the general polar-form power helper (hypot/atan2/exp/…) per evaluation; it is now an inline square-and-multiply chain, with the base bound to a const exactly once. Iterated-map workloads speed up ~9× (depth-10 Julia closed form: 1.25 → 0.14 µs/pt). The square is digit-compatible with the interpreter; for k ≥ 3 both routes go through different roundings and agree to ~1 ulp. Exponents ≥ 9, negative, and non-integer still use the general helper.

Resolved Issues​

  • Degree-mode compilation now reaches user-defined function bodies. The angular-unit rewrite (scaling trig arguments/results so radian-based compiled math reproduces angularUnit semantics) was applied only to the top-level expression: compiling t ↦ f(t) where f(x) := sin(x) under angularUnit: 'deg' emitted radian-based trig inside f's definition, while the inlined t ↦ sin(t) correctly scaled. The rewrite is now applied to each emitted user-function body. (Compiled-vs-interpreted agreement for unit-scaled trig is ~1 ulp, not digit-exact — the two routes round the unit conversion in different orders.)

  • ce.assign(name, fn) ties the recursion knot for pre-boxed function literals. A Function literal canonicalized before ce.assign(name, …) (the programmatic box-then-assign route) left its self-reference bound to a stale auto-declaration, so the body's types were wrong — for most names the self-call typed any and compiling the function fail-closed on the collection guard, while a lucky subset of names (pre-declared shells such as K or J) masked the bug. ce.assign now pre-declares the target as function-typed and re-canonicalizes such a literal, matching the behavior of the Assign operator and of f(n) := … parsing. (Naming a function after a built-in operator, e.g. N, still collides — unchanged.)

0.86.2 2026-07-19​

Resolved Issues​

  • An assigned complex symbol now compiles as complex without an explicit declaration. ce.assign("z_0", <complex value>) then compiling an expression using z_0 emitted the binding as a complex object literal while the operand analysis read only the DECLARED type (wide number / unknown ⇒ real) — number + {re, im} arithmetic, silently NaN at every point. The analysis now derives complex-ness from the assigned value, mirroring the fold. Compile-bound variables (loop indices, lambda parameters) shadow the engine, so an index named i does not pick up the imaginary unit.
  • Block/\coloneq locals infer complex-ness from their assigned right-hand side. In w_1 ⩴ (x+iy)² + z_0; w_2 ⩴ w_1² + z_0 the local w_1 was emitted as a complex object but consumed as REAL by later statements (its type defaulted to real; outer declares don't reach block locals) — silent all-NaN. Locals' complex-ness is now inferred in statement order — a later local reading an earlier complex local is itself recognized — shared by every target (this also extends the GPU vec2 local hints to chained locals).
  • Complex Add binds compound operands once — nested complex arithmetic now compiles in O(tree size). Each {re: …, im: …} slot spliced the full operand subexpression twice, doubling code size and runtime per nesting level: the depth-10 Julia closed form compiled to ~360 KB (~713 µs/pt). Compound complex operands are now bound to consts emitted exactly once: the same form compiles to ~1.9 KB and runs ~1.3 µs/pt, with digit-for-digit interpreter parity. Symbols and number literals stay inline, so simple shapes emit byte-identically.
  • Max/Min (and Supremum/Infimum) now type as number. Their declared result type was the vestigial union number | list, so even Max(1, 2) typed as list | number, a comparison over one typed list<boolean>, and the compilation targets' scalar-condition assert fail-closed every When restriction containing a reduction (y = x \{\max(a,x) < 2\} masked its whole curve). These operators always REDUCE — including a collection argument's elements — to a single scalar extremum (ElementMax/ElementMin are the broadcasting variants), so the result type is number unconditionally. Evaluation is unchanged.

0.86.1 2026-07-19​

Resolved Issues​

  • Materializing a list no longer restructures its eager elements. evaluate({materialization: true}) on a literal list spliced the contents of ANY collection-valued element into the parent — a list of Tuple pairs came back flattened ([("a",1), ("b",2)] → ["a",1,"b",2], so the result no longer fed DictionaryFrom), a nested list literal lost its nesting, and an infinite lazy element (Cycle) was spread until the evaluation deadline. Only finite lazy sub-collections are now flattened-and-materialized (the documented intent: [Range(1,3)] still materializes to [1,2,3]); eager literals are preserved, and an infinite lazy element stays put as a bounded preview.
  • Take of an infinite collection with a finite bound is now finite. Take(Range(1,+∞), 3) reported count 3 but isFiniteCollection false (it propagated the source's finiteness), which left ListFrom(Take(<infinite>, n)) symbolic. Take now reports finite whenever its own element count is known-finite; ListFrom(Take(Range(1, +∞), 3)) → [1,2,3]. (When the source's count is genuinely unknown — Take(ChunkBy(<infinite>, f), 3) — finiteness stays unknown, keeping materialization previews honest.)
  • Sort and Shuffle type as list<…>. Both always rebuild a List, but their static type claimed the source's type (Sort(Range(1,5)) typed as an indexed_collection-shaped Range). Both now report list<element-type>, matching Take.
  • Slice facets are now coherent over infinite and unknown-length sources. Slice claimed isFiniteCollection unconditionally, and a negative start over an infinite source produced a NaN count while at(1) fabricated the element +oo (from source.at(Infinity)). The facets now share one bounds resolver: a negative end over an infinite source means "through the end" — an honest infinite tail (Slice(Range(1,+∞), 5, -1): count ∞, not finite, at(1) = 5, Take(…, 3) → [5,6,7]); a negative start over an infinite source ("the last k elements") is unresolvable and stays inert; an unknown-length source now reports finiteness as unknown rather than true. Bounded positive windows are unchanged (ListFrom(Slice(Range(1,+∞), 1, 5)) → [1,2,3,4,5]).
  • Sum/Product bodies that bind looser than multiplication are now fenced when serialized. The big-op body is parsed back at multiplication precedence, so an additive body's trailing terms escaped the operator on re-parse: Sum(i + 1, i=1..3) serialized as \sum_{i=1}^3i+1, which re-parses as (\sum_{i=1}^{3}i)+1 — 9 became 7 — and a body-bound index in the escaped terms degenerated to a free symbol (i → the imaginary unit, turning real product expansions complex-valued). Additive (and other looser-than-multiplication) bodies now serialize parenthesized (\sum_{i=1}^3(i+1)); tighter-binding bodies (2i, \frac{1}{i}, a bare symbol) are unchanged.
  • Fused stepped ranges with a fraction or compound second anchor now parse to the intended Range. [0,\frac{1}{6}...1] parsed to List(0, Range(1/6, 1)) — silently wrong values — because the sample reader did not recognize fraction literals; it now yields Range(0, 1, 1/6) with an EXACT rational step (a float 0.1666… step would drift and miss the end anchor; the range lands exactly on 1). And [m+n,m+n+15...m+n+60] parsed to a nested-Range List — the ... infix binds its left operand tight, so the continuation range was embedded in the additive tail (Add(m, n, Range(15, m+n+60))); the normalization now recovers the true second sample and end anchor, yielding Range(m+n, m+n+60, 15). Both rewrites keep the provenance guard: only ellipsis/..-written ranges participate — an explicit \operatorname{Range}(…) element (bare or embedded in a sum) stays a literal List entry.

0.86.0 2026-07-19​

New Features​

  • ce.withTimeLimit(ms, fn) — run a block of work under a single evaluation deadline. Ordinarily each top-level evaluate() arms its own timeLimit budget, so a long sequence of short evaluations — e.g. draining a lazy collection element by element via each()/at() — can run unboundedly without ever tripping the limit. Wrapping the loop in withTimeLimit() arms one shared deadline for its full duration: any evaluation inside throws CancellationError (cause: 'timeout') once the deadline is exceeded. Re-entrant (an inner call can only shorten the effective deadline, never extend it).

Resolved Issues​

  • .N() of Tuple ± scalar·Tuple no longer throws at machine precision. When every term of a component sum was an integer-valued machine float (20 − 0.1·20), the exact summation path read bignumRe — which is undefined on a machine numeric value — and threw TypeError: Cannot read properties of undefined (reading 'toFixed'). At scale this killed composed lazy streams mid-drain (a 4001-point PointList − scalar·PointList died at the first integer-valued element) and made at(k) return undefined at the crashing indices. The integer fold now converts the integral machine value directly.
  • A solidus-rendered fraction juxtaposed with following material keeps explicit grouping. Serializing a non-canonical InvisibleOperator(Divide(1, 2), Delimiter(…)) at nesting depth > 3 (where the default fraction style switches to an inline solidus) emitted 1/2(sq) — which re-parses as 1/(2·s·q), silently changing the value. The solidus form is now parenthesized ((1/2)(sq)); \frac-rendered fractions and trailing-position solidus fractions are unchanged.
  • Multiply(symbol, Tuple) serializes with an explicit multiplication sign. s(1,2,3) re-parses as a function CALL ["s", 1, 2, 3] for any symbol the parser cannot prove non-applicable, silently turning a product into an application. A bare-symbol factor followed by a parenthesized comma-group now serializes as s\times(1,2,3). Single-expression groups (s(x+1)) and number-led products (2(1,2)), which re-parse as products, keep juxtaposition.
  • CountIf, Position, Ordering, DictionaryFrom, and RecordFrom stay inert on an infinite or unknown-length collection. These operators require walking every element, so on an infinite input (Range(1, +∞), Cycle, Iterate) they previously consumed the entire evaluation time limit and then threw CancellationError instead of returning a result. They now detect the non-finite input structurally and stay symbolic immediately. (Ordering previously returned a spurious empty list, claiming a complete ordering it never computed.) Huge-but-finite inputs still walk under the deadline as before, and Find is unchanged: it streams and short-circuits, so Find(Range(1, +∞), x ↦ x > 5) still returns 6.

Collections​

  • Insert, DeleteAt, ReplaceAt, Partition (chunk and window forms), SlidingWindow, and ChunkBy are now hybrid-lazy. Inputs at or below the 100-element eager threshold evaluate to an eager List exactly as before (byte-identical shapes); larger, lazy, or infinite inputs stay symbolic and serve their elements on demand through count/at/ iteration, following the same convention as the hybrid-lazy broadcast forms. Insert into a million-element Range no longer materializes the whole list to answer Count or an index probe, and streaming prefixes of infinite results now work: Take(Partition(Range(1, +∞), 3), 2) → [[1,2,3],[4,5,6]], Take(ChunkBy(Cycle([1,1,2]), x ↦ x), 3) → [[1,1],[2],[1,1]]. Partition's predicate form (Partition(xs, pred) → [trueGroup, falseGroup]) requires a finite input and is unchanged. Materializing consumers (ListFrom, …) behave as before.

0.85.1 2026-07-18​

Performance​

  • Large PointList transposes and point-coordinate projections are now hybrid-lazy, and scalar arithmetic over lazy broadcasts composes lazily instead of grinding (or staying inert). Numeric evaluation of scalar × (PointX(P), PointY(P), …) over a 4001-point PointList of broadcast components took ~600–1000 ms per product — each coordinate projection eagerly transposed the whole point list into n Tuples (at ~150 µs/element of per-element boxed re-canonicalization) only to project one slot back out — and could exceed the 2 s timeLimit on a full plot row. Three coordinated changes, all hybrid (collections at or below the 100-element eager threshold are byte-identical to the previous shapes):
    • PointList past the threshold transposes to the lazy Map form (consumable via at/each/count) instead of materializing every point-Tuple. BREAKING (shape): a >100-point PointList now evaluates to a lazy Map, not an eager List — element values are unchanged.
    • PointX/PointY/PointZ project lazily past the threshold, and project straight to the source collection when the operand is the lazy transpose form (PointX(PointList(a, b, c)) ≡ a for equal-length components; ragged or scalar slots keep transpose semantics).
    • addN/mulN re-dispatch their broadcast branches after numeric operand evaluation, so an operand that only becomes a collection through evaluation (Mod(L, 11) over a list L) now composes into the lazy Map form — previously the product/sum was silently left inert (0.2 · ⟨collection⟩ unreduced). The eager broadcast zip also streams its operands with hoisted iterators instead of per-index at() calls (which re-instantiated a lazy Map's mapping lambda on every access). The filed repro (a 4001-member 3D vector row) went from a 2 s timeout to ~10 ms of lazy composition, with materialization deferred to the consumer sweep.

Resolved Issues​

  • String vars values now splice into compiled JavaScript and Python as source, not as string literals. The vars compile option is the live-path contract: a mapped symbol always stays a runtime input instead of having its assigned value folded into the emitted code — so one engine state can serve both a compile-once path (sliders as runtime arguments) and a fold-early evaluate path. The GLSL and interval targets honored it, but the JavaScript and Python targets JSON-stringified the mapping, so compile(expr, { vars: { s: '_.s' } }) emitted Math.sin("_.s" * _.x) — a string literal yielding NaN at run time. A string value is now spliced verbatim (Math.sin(_.s * _.x)); a non-string value still bakes as a constant (vars: { a: 7 } → 7), unchanged.

0.85.0 2026-07-18​

New Features​

  • DSolve frontier round — parity with SymPy on the ODE audit (50/51, 0 wrong). Four new solvable classes:

    • Nonhomogeneous Cauchy–Euler (x²y″ + bxy′ + cy = g(x)): an x-power indicial ansatz for power forcing (x²y″ + xy′ = x → c₁ + c₂·ln x + x), with a variation-of-parameters fallback for resonant or non-power forcing.
    • The Airy family y″ = (px + q)·y: solutions as c₁·AiryAi(t) + c₂·AiryBi(t) with t = ∛p·x + q/∛p² (real cube root, either sign of p).
    • Airy-type Riccati y′ = q₀(x) + q₂·y² (constant q₂, linear q₀): the y = −u′/(q₂u) linearization yields the one-parameter (Ai′ + C·Bi′)/(Ai + C·Bi) family — y′ = x + y² now solves (SymPy errors on it).
    • Repeated-eigenvalue first-order linear systems: diagonal systems of any size and defective 2×2 systems via a generalized eigenvector, gated on an exact (A−λI)² = 0 check so near-repeated numeric eigenvalues stay inert rather than producing an approximately-wrong solution.
  • AiryAiPrime / AiryBiPrime operators (derivatives of the Airy functions), with machine-precision numerics across all three DLMF regimes and full derivative closure (Ai′ → AiryAiPrime, AiryAiPrime′ → x·Ai(x) — so repeated differentiation of Airy expressions evaluates and numericizes).

  • NDSolveFunction — ODE solutions as applicable functions. Where NDSolve returns a sample List, the new NDSolveFunction (same arguments, without the sample count) returns the solution as a callable function — a Function literal wrapping the new InterpolatingFunction operator, which holds the adaptive solver's piecewise-quartic dense-output table. Assign it and evaluate anywhere in the integration interval (f := NDSolveFunction(y′ = y, y, (x, 0, 1), 1); f(0.5) → 1.6487…), at the integration accuracy; outside the interval the value clamps to the nearest endpoint, and a symbolic argument stays symbolic. The solution compiles to plain JavaScript — compile(f) yields a positional lambda (run(0.5)), and compile(f(t)) an expression over t (~1 µs per evaluation) — and LaTeX display elides the data table (\operatorname{InterpolatingFunction}_{[0, 1]}(x)); the full table round-trips through MathJSON. Scalar equations (first-order and higher-order) are supported; the multi-dependent system form stays inert.

Improvements​

  • NDSolve now uses adaptive stepping (Dormand–Prince 5(4)) with dense output. The output is unchanged in shape — a List of steps + 1 uniform [x, y] samples — but the values are now tolerance-controlled: integration adapts its internal step size (embedded 4th/5th-order error control) and the uniform grid is emitted from the quartic dense-output interpolant. Fixed-step RK4 silently lost accuracy near rapid transients (y′ = −50(y − cos x) over [0, 3] with 100 steps erred at ~4·10⁻⁵; now ~2·10⁻¹²). Non-integrable problems (finite-time blow-up, tolerance failure) leave NDSolve inert rather than returning inaccurate samples.

  • Truncation dots after a repeating decimal tail now parse exactly. 0.999\ldots → 1, 0.333\ldots → 1/3, 0.1212\ldots → 4/33: a truncation marker after decimal digits ending in an evident repetend (a block repeated at least 3 times for single digits, at least twice for longer blocks) is read as the exact repeating decimal. Non-repeating tails (3.1415\ldots) keep the previous behavior (the marker is display-only).

  • nPr(n, k) parses in lenient mode as the k-permutation count P(n, k) = C(n, k)·k!, joining the existing nCr(n, k) → Binomial.

  • The infinite Sum/Product closed-form table grew substantially. New exactly-evaluated families (each numerically verified): alternating p-series (Σ (−1)^{k+1}/k → ln 2, Σ (−1)^{k+1}/k² → π²/12), odd p-series (Σ 1/(2k−1)² → π²/8), Dirichlet beta (Leibniz Σ (−1)^k/(2k+1) → π/4; β(2) → Catalan's constant; β(3) → π³/32; β(5) → 5π⁵/1536), the exponential series (Σ 1/k! → e, Σ xᵏ/k! → eˣ, shifted starts adjusted exactly), the first-moment geometric series (Σ k/2ᵏ → 2; symbolic ratio → x/(1−x)² guarded on |x| < 1), and the logarithmic series (Σ 1/(k·2ᵏ) → ln 2; symbolic ratio → −ln(1−x), same guard). New infinite-product entries: Π_{k≥a} (1 − 1/k²) → (a−1)/a, Π (1 − 1/(2k+1)²) → π/4, and Π (1 + 1/k²) → sinh(π)/π (previously numeric-only). Divergent or out-of-table shapes stay symbolic, as before.

0.84.2 2026-07-18​

Performance​

  • Removed a per-call inference-snapshot tax that had slowed the whole engine by ~1.4× since 0.74.0. Every top-level boxing or parsing operation eagerly snapshotted the set of inferred symbols by walking every binding in every scope — including the entire standard library — to provide provenance for the fresh-matrix-inference repair (Determinant(A + B) inferring A, B as matrices), a consumer that runs only when a matrix-typed parameter mismatches. The provenance is now computed forward: BoxedSymbol.infer() records a definition when its type first transitions unknown → concrete during a boxing operation, and the repair's eligibility reads that log. Matrix inference behavior is unchanged (pinned by a 13-case matrix in matrix-operator-typing.test.ts); eligibility is now keyed on definition identity rather than name, so a name whose fresh inner-scope definition was popped no longer masks an outer definition. Measured recovery: π.N() at 200 digits 2.5 µs → 0.12 µs (21×, faster than 0.73.0); ∫ 1/(x³+1) 5.8 ms → 1.6 ms (3.7×); the drift vs 0.73.0 across the benchmark suite is eliminated.

Improvements​

  • The sign of integer powers of pure-imaginary bases is now determined. For z of type imaginary, z² reports negative, z⁴ positive (the cycle (βi)^p = (-1)^{p/2}·β^p for even p, including negative exponents: (2i)^{-2} = -1/4), and odd powers report unsigned (pure imaginary). Powers of a general finite non-real base report not-zero — a non-real value is necessarily nonzero. Previously all of these were indeterminate: the handler branch that addressed non-real bases was unreachable, and wrong as written (it claimed every even power of a non-real base was negative — but i⁴ = 1 and (1+i)² = 2i).

Improvements​

  • Abs typing now follows the operand's finiteness. |x| of a provably finite operand (real or complex) types finite_real instead of the signature's generic real; a provably infinite operand types non_finite_number, and a literal NaN types number. Finiteness also propagates structurally (|x| is finite iff x is), which makes signs of products of absolute values determinate: |x|·|y| for finite x, y now reports non-negative (this path previously hit a latent inverted parity claim in Multiply — see the sgn audit below — and before that was masked entirely).

Resolved Issues​

  • Lazy broadcast over a declared-unknown symbol no longer throws Not canonical (Tycho item 42). Evaluating mod(L, N)/N with L a declared-unknown symbol holding a >100-element list built the lazy Map(L, …) over the SYMBOL, whose static type is unknown; every lazy collection operator's canonical handler hard-rejected such a source and boxFunction fell back to a silently NON-canonical expression, which the first arithmetic composition rejected with a thrown assert. Lazy collection canonical handlers now admit operands whose type is merely indeterminate (unknown/any/value/broadcastable) — provably-scalar operands still reject — and Map over such a source keeps value-aware indexed-ness (type, at, count, and the display preview, which no longer renders with a misleading Set head). The composed lazy result is consumable and honors x.N() ≡ x.evaluate().N().

  • A user symbol shadowing a builtin no longer breaks function application of that builtin. With N := 85 declared (ubiquitous in Desmos-style documents), any ["N", …] application — including the engine's own internal N(…) wrapper that makes lazy .N() elements float on access — resolved to the user's number and produced an incompatible-type error (surfacing as Nothing elements in lazy maps). Operator-position binding now defers a value definition that provably cannot be applied (a plain number, string, collection…) to an outer applicable definition of the same name; value-position references (N + 1) still resolve to the user's value. (Consequence: after prose-style devolution of an un-applied builtin — N + 1 — a later N(3.14159, 2) now numericizes instead of staying symbolic.)

  • The JavaScript compile target's floored-Mod emission is parenthesized (Tycho item 43). The fragment ((a % b) + b) % b was emitted without outer parentheses; composed as a Multiply/Divide factor, JS's left-associative same-precedence % reduced the whole product mod b (c * ((x % 1) + 1) % 1 ≡ (c·(x%1+1)) % 1), silently value-wrong whenever the product's magnitude reached the divisor. The standalone form was correct, which is why it survived. Compiled and interpreted now agree on the Neyret-hash idiom Σ cos(i)·mod(10⁴sin(10⁴i), 1).

  • Sum/Product over a collection-valued body type as the collection, and At extracts element types (Tycho item 44). A big-op whose body types vector<2> (e.g. summing scaled calls of a(t) := [cos t, sin t]) typed number, so indexing the sum baked an incompatible-type error at parse time; it now types vector<2>. At on a tuple-typed operand with a literal index types the selected slot, and an inference widen-guard stops a loose parameter type from coarsening an already-precise inferred function result (this made A(t)[1] type any; it now types number).

  • At over a typed-collection application compiles; a collection-valued big-op body fails closed instead of emitting wrong code (Tycho item 45). a(x)[1] with a returning vector<2> now compiles (the collection gate is type-aware, so _SYS.at is emitted). A compiled Sum/Product whose body is collection-typed previously emitted scalar accumulation over arrays — NaN or string concatenation, silently wrong; it now fails closed (D6) with a hint to distribute the element access through the big op.

  • Applying a function to a symbolic argument that mentions the parameter's own name no longer overflows the stack under .N() (Tycho item 46). a(t+1) for a(t) := [cos t, sin t] with t unbound: symbol values resolve by name through the evaluation context, so BoxedSymbol.N() recursed through the call-frame binding forever (t → t+1 → t → …). .N() now substitutes a self-referential context value once without numericizing through it — mirroring plain evaluate() — so nested helper-call expressions (the Tycho item-46 PointList(A(t)[1], A(t)[2]) repro) evaluate symbolically, verified against direct numeric evaluation.

  • Desmos-style range ellipsis with an elided comma parses again (Tycho item 47, regression of the 0.76.0 "request 6" class). [0,...300] → Range(0,300) (was an inert List(0, ContinuationPlaceholder·300)), [1,...N] and [0,...3N^{2}-1] likewise, and the stepped [0,15...210] → Range(0, 210, 15) (was the silently WRONG List(0, Range(15,210))). Fully-comma'd, bare-fused ([1...5], [-3N...3N]), and nested-group ([f(a,b)...5]) forms are unchanged. Compound-symbolic stepped anchors sharing an identical additive base with numeric offsets now infer too: [m+n, m+n+15, ..., m+n+60] → Range(m+n, m+n+60, 15); differing bases or non-numeric offsets stay a literal List. Stepped-range inference only applies to ranges the ellipsis syntax itself produced — a list literal ending in an explicit \operatorname{Range}(a,b) element stays a List.

  • A Range operand of a tighter-binding parent now serializes parenthesized (Tycho item 48). .. parses its end operand at a precedence below Add, so Add(Range(0, L-1), 3) serialized as 0..(L-1)+3, which re-parses as Range(0, L+2) — wrong values on any serialize→re-parse round-trip (with L = 5, an 8-element list instead of the shifted 5-element one). A Range under Add/Subtract/Multiply/ Power/solidus-Divide parents now wraps in parentheses ((0..(L-1))+3); bare and stepped ranges serialize unchanged. Same round-trip precedence class as the 0.83.2 Mod fix.

  • GPU targets emit a shape-matched NaN for masked conditional branches (Tycho item 49). A When/Which whose value is a tuple body — a restricted parametric (x(t), y(t)) with \{0 \le t \le 1\} — compiles the value to a vec2, but the masked branch emitted a scalar NaN: GLSL has no implicit float→vecN conversion in a ternary, so the driver rejected the shader and every restricted parametric member lost its GPU sampling path. The NaN branch is now vectorized to the value's component count (vec2(_gpu_nan()) on GLSL, vec2f(bitcast<f32>(…)) on WGSL — WGSL's select requires matching operand types); scalar bodies are unchanged.

  • Sign (sgn) handler audit. A mathematical-correctness pass over all ~69 sgn handlers fixed a dozen wrong claims (each could mislead simplifications or comparisons built on isPositive/isNegative): Gamma(0) and Gamma(-n) reported zero/indeterminate instead of recognizing poles; Log with a negative base claimed a real sign (the sign only flips for a base in (0,1)); Truncate(1/2) claimed positive (truncation of |x| < 1 is 0); Round(-1/2) claimed zero while evaluate rounds halves away from zero (−1); GCD(0,0) and LCM(0,n) claimed positive (both are 0); Floor/Ceil of a complex number used the sign of the raw real part instead of the rounded one (⌊0.5+0.5i⌋ = 0); Factorial(-1/2) claimed non-real (it is Γ(1/2) = √π; only negative integers are poles, same fix for Factorial2); Abs(NaN) claimed positive; Random(-5, 5) claimed non-negative; tensor Rank of a scalar claimed positive (it is 0); and a latent parity inversion in Multiply swapped non-negative/non-positive for products of sign-indefinite factors. Arctan now reports the sign of its argument (it previously never produced one).

  • A single-letter builtin operator used as a variable now stays connected to later assignments. Prose-style input like N \equiv 1 \pmod 5 devolves the un-applied builtin N to an unknown symbol, but when every other operand validated cleanly the devolved symbol was discarded and the expression kept the original symbol, still bound to the builtin operator: a later N \coloneq 11 was invisible and the expression stayed stuck symbolic. The substituted operand is now retained (same fix for operands re-typed by matrix-context inference repair), so assigning the variable evaluates as expected.

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE (current)CE 0.84.1SymPymath.jsMathematica
\pi^27.0121841104.0
\sin 121262264795.3
\cos 121252306677.1
\ln 214183565,0933.1
e^{\pi}12172154,8624.5
\zeta(3)1,5731,620268—49
\Gamma(\tfrac13)914921366—225
\psi(\tfrac13)7497436,854—188

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case; ✓ means it solves a case Mathematica can't. Compare the CE (current) and CE 0.84.1 columns to see what is new this release (a — under 0.84.1 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE (current)CE + R/FCE 0.84.1SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx6.4×2.7×3.4×0.4×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx9.6×1.7×6.0×0.09×—1×
\int\frac{1}{x^3+1}\,dx7.5×0.9×1.1×0.4×—1×
\int\frac{\sqrt x}{1+x}\,dx—1.9×—0.09×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—1.3×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—1.1×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.03×0.02×0.01×0.001×0.004×1×
Simplification
\sqrt{3+2\sqrt2}39×29×23×——1×
\sqrt6\,x+\sqrt2\,x79×45×43×2.8×15×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}59×29×21×1.2×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x9.6×5.1×4.5×2.3×—1×
\int_1^2\tfrac1x\,dx6653×6680×2654×63×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx402×104×159×2.5×—1×
Solving
x^4+x^2-1=00.2×0.2×0.1×0.07×—1×
x^3-x-1=01.6×1.8×1.0×0.03×—1×

Across the cases both solve, Compute Engine is a median 7.5× faster than Mathematica (up to 6653×) — in the browser, not a proprietary kernel.

Measured 2026-07-18 · Compute Engine 0.84.2 @ 40cc077a (current build) · published 0.84.1 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.84.1 2026-07-17​

Resolved Issues​

  • Equal/NotEqual over a possibly-collection operand now compile on the javascript target with an interpreter-faithful runtime dispatch. A comparison like q(2) = 9 where q is declared (number) -> unknown — so the call may return a collection at run time — previously failed closed (success: false, interpreter fallback). The binary form now lowers to a runtime helper mirroring the interpreter shape by shape: scalar operands compare tolerantly (within engine.tolerance, complex via the modulus), an array-vs-scalar pair is element-wise ([1,4,4] = 4 → [false, true, true]), and an array-vs-array pair is whole-collection equality — a single boolean, false on a length or shape mismatch, recursive over nested arrays. The chained (n-ary) form over a possibly-collection operand still fails closed: its pairwise && conjunction is only sound over scalar booleans.

  • Equality with an operand that only becomes a collection at evaluation no longer produces a cartesian nest. L(1) = [1,2] where L is declared (number) -> unknown and returns a list fanned the literal out before evaluation (the opaque call not yet being a collection), then broadcast again element-wise once it was — yielding a 2×2 list of lists of booleans. It now follows the documented, representation-independent rule that literal, symbol-bound and lazy collections already follow: two collections compare as a single boolean (L(1) = [1,2] → True), and a runtime scalar against a literal list still broadcasts element-wise.

  • .N() of an already-evaluated lazy Map now yields numeric elements. The 0.84.0 fix wrapped a lazy broadcast's elements in N only when the broadcast was constructed under .N(); calling .N() on an already-evaluated lazy Map was an identity, so Sin(Range(1, 200)).evaluate().N() streamed exact elements (sin(1), sin(2), …) from both each() and At. Requesting a numeric approximation of a lazy Map now rewraps its mapping function so every element floats on access — restoring x.evaluate().N() ≡ x.N() — while evaluate() alone still keeps elements exact and the result stays lazy (O(1) on Sin(Range(1, 10^8))).

0.84.0 2026-07-17​

Resolved Issues​

  • Sort and Shuffle of a non-List collection returned corrupt results. Both rebuilt their result with the source collection's operator, so the sorted/shuffled elements were reinterpreted as constructor arguments: Sort(Range(1, 10)) produced Range(1, 2, 3) — the one-element list [1] — and Shuffle(Range(1, 5)) could produce an empty collection. Both now return a List, matching every other eager collection operation. Sort of an infinite or unknown-length collection now stays inert instead of returning an empty collection.

  • Negative indices now work uniformly across all indexed collections. Negative-index normalization (-1 = last element) was implemented per-collection and most lazy collections lacked it, so Last, At(xs, -1) and anything built on end-relative access silently returned Nothing — or worse: Reverse walks its source from the end, so ListFrom(Reverse(Range(1, 5))) returned [] instead of [5,4,3,2,1]. Normalization is now centralized in the index dispatcher and works for Range, Linspace, Zip, Scan, Differences and every other indexed collection with a known finite length; infinite or unknown-length collections correctly return Nothing without enumerating.

  • A Filter over more than ~1000 elements crashed numeric canonicalization. Determining whether a Filter result was finite ran its count handler, which walks the source applying the predicate — and throws iteration-limit-exceeded past the iteration limit. Boxing an expression as simple as Filter(Range(1, 100000), p) + 1 threw. Finiteness and emptiness of a Filter are now answered structurally (a filter of a finite collection is finite) without running the predicate, and count of a filter of an infinite or unknown-length collection now correctly reports unknown instead of claiming Infinity (a filter of an infinite collection can be finite: Filter(Range(1, ∞), x < 5) has 4 elements).

  • IsEmpty and Contains no longer answer False when the answer is unknown. Both coerced an undetermined result to a definite False: IsEmpty(Filter(Range(1, 10^5), x ↦ False)) — a collection that is empty, but whose emptiness can't be established within the iteration limit — returned False. Both predicates are now three-valued and stay inert (unevaluated) when the answer cannot be determined.

Collections​

  • A comprehension's element memo now survives unrelated evaluations. Elements of a comprehension ([x^2 for x in xs]) are cached per instance, but the cache was keyed on an engine-wide counter that every scoped evaluation — any \sum, Block or big operator — bumps on exit, so in a live document the cache never survived between two reads and every re-read re-evaluated the body per element (the Tycho/Graph Paper team measured a document that bound comprehensions lazily analyzing ~3× slower than one that eagerly materialized them). The cache is now invalidated only by semantic mutations: reassigning a free variable the body reads (directly or through a helper function it calls), redeclaring or inferring an operator, assume() and forget() — including the implicit revert when a scope that assumed exits — all refresh it, and a comprehension nested under a Sum/Product refills for each value of the enclosing binder. Unrelated evaluations between reads leave the cache intact.

  • IdentityMatrix, ZeroMatrix, OnesMatrix and Diagonal of a vector are now safe at huge dimensions. These constructors eagerly built the full m×n matrix with no size limit — IdentityMatrix(10^6) attempted 10¹² elements. Above 10,000 total elements they now produce a lazy indexed collection with O(1) construction and element access; at or below, the result is the same materialized matrix as before, compatible with Determinant, Inverse and the rest of the dense linear-algebra operations.

  • .N() of a lazily-broadcast operation yields numeric elements. When a broadcast returns a lazy Map (see below), requesting a numeric approximation now wraps the mapped function so each element is computed as a float on access: Sin(Range(1, 10^8)).N() elements are numbers, while evaluate() keeps them exact (sin(1), sin(2), …).

  • Element-wise operations over infinite and unknown-length collections are now lazy instead of inert — or truncated. Broadcasting an element-wise operator over an infinite collection (Cycle), a finite collection of unknown size (Filter), or a symbolic-length Range now returns a lazy Map supporting First, At, Take and Length, rather than staying an inert expression: Add(Cycle([1,2]), 1) is the lazy [2,3,2,3,…], and Add(Range(1, n), 1) with n a declared, unassigned integer becomes Map(Range(1, n), _ ↦ _ + 1), which picks up n's value reactively when later evaluated. This also fixes a wrong-answer bug: the eager broadcast read a Filter's unknown length as 1, so Add(Filter(Range(1, 100000), x ↦ x > 2), 1) truncated to the single-element list [4]; it now yields the full lazy sequence [4,5,6,…]. A mixed broadcast folds with shortest-input semantics: Add([10,20,30], Cycle([1,2])) → [11,22,31].

  • Element-wise operations over large collections are now lazy. Applying an element-wise operator (Add, Multiply, Sin, a user function literal, …) to finite indexed collections of more than 100 elements returns a lazy Map instead of materializing every element: Add(Range(1, 10^8), 1) and Sin(Range(1, 10^8)) evaluate instantly to a lazy collection supporting At, Take, First, Last and Length without enumeration. Collections of 100 elements or fewer are unchanged and still evaluate eagerly to a List.

  • Length, Count, IsEmpty and Contains see through wrappers that cannot change their answer. Sort, Shuffle and Reverse preserve element count, and (together with Unique, for Contains) preserve membership, so these consumers now strip such wrappers at canonicalization: Count(Sort(xs)) becomes Count(xs) and no longer sorts — Count(Sort(Range(1, 10^5))) went from ~15 s to ~1 ms.

  • Numeric argument validation no longer enumerates large lazy collections. A lazy collection passed to a numeric operator was fully enumerated at canonicalization to check or infer its elements — even a Map over millions of elements. Validation now decides on the static element type: provably numeric or provably non-numeric collections are accepted or rejected without enumeration, and a collection whose element type is genuinely indeterminate is accepted structurally and fails at evaluation time if an element turns out non-numeric. Only eager, literal collections still have their elements inspected individually.

0.83.2 2026-07-17​

New Features​

  • Operator definitions can now supply a custom compilation handler. A compile handler on an operator definition emits target source for that operator when the expression is compiled; returning undefined falls back to the target's default lowering:

    ce.declare('MyGcd', {
    signature: '(number, number) -> number',
    compile: (args, compile, { language }) =>
    language === 'javascript'
    ? `_gcd(${compile(args[0])}, ${compile(args[1])})`
    : undefined,
    });

    The handler receives the canonical operands, a callback to lower sub-expressions, and the compilation context (branch on context.language — javascript or python). It takes precedence over the target's built-in operator mapping, so it can also re-map how a built-in operator compiles; structural and control-flow heads (Sum, If, Block, …) keep their bespoke lowering and ignore the handler.

Resolved Issues​

  • A Mod in a product or a power base now serializes parenthesized, fixing a round-trip corruption. Juxtaposition (invisible multiply) and superscripts bind tighter than infix \bmod on re-parse, so an unparenthesized Mod factor absorbed the adjacent notation into its trailing operand: ["Multiply", ["Mod", "A", 2], ["Mod", "B", 2]] serialized to A\bmod2B\bmod2, which re-parses as A mod (2B mod 2) = A mod 0 = NaN. It now serializes as (A\bmod2)(B\bmod2); similarly ["Power", ["Mod", "A", 2], "x"] is now (A\bmod2)^{x} instead of A\bmod2^{x} (which re-parsed as A mod 2^x). Same class as the 0.79.2 compound-operand Mod parenthesization fix, on the other side of the operator. (Reported by the Tycho/Graph Paper team — a hex-grid Desmos state rendered blank because a product of two Mod(Floor(…), 2) factors round-tripped to NaN.)

0.83.1 2026-07-17​

Resolved Issues​

  • Fixed a 0.82.0 regression: an operand typed broadcastable<T> was rejected by operators with a plain scalar parameter, baking an unrecoverable incompatible-type error at canonicalization. An application of an undeclared function symbol (["f", "k"]) flowing through Add, Multiply, or Power lifts the surrounding expression to broadcastable<number>; a non-threadable operator with a number parameter (Binomial, Totient, and the rest of the number-theory family, among others) then rejected it — e.g. ce.box(["Binomial", ["Add", "n", ["f", "k"]], 2]) was invalid. A broadcastable<T> operand could be a plain scalar T at runtime, so validation now admits it whenever T matches the parameter, exactly restoring the pre-0.82.0 admission. LaTeX input was largely unaffected (undeclared f(k) parses as the product f \cdot k, and a declared function types precisely); expressions built directly from MathJSON were the exposed surface.

0.83.0 2026-07-17​

Breaking Changes​

  • Collection indexing (At) now serializes with brackets by default: ["At", v, 1] → v[1] instead of v_1. The bracket form is the round-trip-safe notation: v[1] always parses back to At, while the subscript form v_1 only does when v is declared as an indexed collection — otherwise it re-parses as the unrelated subscripted symbol v_1, silently changing the meaning on a serialize→parse cycle. The previous behavior remains available engine-wide via ce.latexOptions.indexStyle = () => 'subscript' or per call via expr.toLatex({ indexStyle: () => 'subscript' }). (Requested by the Tycho/Graph Paper team, whose per-call indexStyle opt-ins were a recurring source of forgotten-call-site round-trip bugs.)

New Features​

  • Five linear-algebra operators now compile to the JavaScript and Python targets: ConjugateTranspose, Diagonal (rank-dispatched — a matrix gives its main-diagonal vector, a vector gives the diagonal matrix), MatrixPower (integer powers, with a negative power inverting first), RowReduce (reduced row echelon form), and Rank. Previously these threw at compile time and fell back to the interpreter. Note that Rank is the tensor rank — the number of axes (scalar 0, vector 1, matrix 2) — not the linear-algebra (row) rank.

Resolved Issues​

  • The javascript compile target now lowers a reduce (Sum/Product) and rank-dispatched multiplication over an unknown- or broadcastable-typed collection operand, where it previously failed closed and fell back to the interpreter. A collection Sum/Product over such an operand reduces under a runtime guard (a scalar at run time still matches Sum(scalar) = scalar), with an element-wise-aware combiner so a nested (matrix-valued) element reduces correctly rather than string-concatenating. A Multiply of two possibly-collection operands compiles to a runtime helper that dispatches on rank — element-wise for equal-length vectors, matrix product for matrices — matching the interpreter. This lets a compiled function whose evidence-derived signature is (…) -> unknown render through the compile path instead of only the interpreter.

  • A function declared with a fixed-length list return type (e.g. (number) -> vector<11>) now compiles. The value assignment wraps the body in a Typed ascription, which the compile targets did not handle, so every compiled call threw Unknown operator `Typed` . Typed is a transparent, no-op-at-runtime ascription and now compiles to its value operand on every target.

  • Multiply of two matrices is now the matrix product regardless of how the operands are presented. A matrix literal or a matrix-returning function application already contracted, but a symbol whose value is a matrix incorrectly broadcast element-wise (Hadamard) — the dispatch keyed on the node kind and missed a matrix-valued symbol. All three now contract consistently, so 0.82.0's per-step matrix-contraction rule holds for symbol operands too. Vectors remain element-wise.

  • The Python compile target no longer unwraps a one-element ElementMax / ElementMin / Clamp broadcast to a scalar. ElementMax([1, 2], [3]) now compiles to a value that runs to [3] (zipping to the shortest operand), matching the interpreter and the JavaScript target, instead of the bare scalar 3.

  • Broadcasting an element-wise operator over an N×1 column matrix now preserves its rank-2 shape. For example, \bold{v} = \begin{pmatrix} 5 \\ -3 \end{pmatrix} evaluates to the nested [[v === 5], [v === -3]] (a 2×1 result) instead of the flattened rank-1 [v === 5, v === -3]. A column vector is a matrix<Nx1>, and the broadcast result now mirrors the operand's shape, matching how row matrices (matrix<1xN>) and plain rank-1 vectors already broadcast. Consumers reading the broadcast result should expect one nested list per row.

  • A Comprehension body containing a scoped subexpression now sees the iteration index correctly. When the body contained a Block (e.g. a with-style local), a big operator (Sum, Product), a nested comprehension, or a user-function application whose evaluation was deferred by any of these, the subexpression evaluated blind to the index value: the index was bound in a runtime scope that scoped subexpressions' lexical chains never reached. Results could be silently wrong — an applied function literal whose piecewise guard could not be decided without the index escaped with its parameters permanently unbound (e.g. [total(f(n, 4)) for n in 1..3] with f(a,b) := [{a>b: b, a}, a-b] returned expressions still containing a and b) — and, because the wrongly-symbolic elements never reduced, evaluation of such comprehensions cascaded into orders-of-magnitude excess work. Index values are now installed in the comprehension's own scope for the duration of each element's evaluation (isolated per walk, so interleaved iterations and .count reads during a paused iteration are unaffected), and evaluating a canonical Comprehension no longer re-creates it with a detached scope.

  • Multiplying or adding a scalar to a piecewise (Which) no longer evaluates the selected branch twice. 2 \cdot \{A=1: X, Y\} evaluated the taken branch once during conditional-threading detection and again in the arithmetic handler, doubling the cost of every piecewise operand of Add/ Multiply (untaken branches were, and are, never evaluated).

  • Fixed a stack overflow when evaluating Negate of an indexed collection that cannot be materialized, such as a Range with symbolic bounds reached inside a comprehension body (-Range(0, m + 5) with m unbound): the element-wise distribution retried the same non-distributable negation without progress.

0.82.0 2026-07-17​

Breaking Changes​

  • Multiply of two vectors (rank-1 lists) is now element-wise, not a dot product. [1, 2, 3] \cdot [4, 5, 6] now evaluates to [4, 10, 18] instead of the scalar 32. This makes Multiply over lists consistent: element-wise is what Add, Power (k^2), scalar scaling (2k), and symbol-bound list operands (k \cdot k with k := [1,3,10]) already did — previously the same product could zip or contract depending on whether an operand was a literal list, a bound symbol, or a computed expression (\sqrt{k} \cdot k silently collapsed a 3-element family to one scalar). For the dot product, use the explicit Dot or MatrixMultiply operators, which are unchanged. A product is folded left-to-right, one pair at a time: a step involving a matrix (matrix·matrix, matrix·vector, vector·matrix) still contracts (matrix product, unchanged), while a step between two vectors is element-wise. Note this applies per step, so in a longer chain a contraction that produces a vector then combines element-wise with a following vector: M·u·v is (M·u) ⊙ v, no longer the scalar (M·u)·v. Vectors of differing lengths stay inert (no implicit zip-to-shortest). The compiled targets follow the same semantics: equal-length vector·vector compiles to the element-wise broadcast; statically mismatched lengths and matrix contractions fall back to the interpreter.

  • A bounds-less big operator over LaTeX (\sum ⟨body⟩, \prod ⟨body⟩) now parses to its own head (["Sum", body] / ["Product", body]) instead of ["Reduce", body, "Add"/"Multiply"]. This is a (non-canonical and canonical) parse-shape change, called out per the pipeline-contract rules: consumers matching on the Reduce shape should match the big-op head instead. It makes the serialization round-trip lossless — ["Sum", body] serializes to a bounds-less \sum ⟨body⟩, which previously re-parsed to a different expression. Evaluation semantics are unchanged (a collection body still reduces).

  • Broadcasting over a one-element collection now returns a one-element List instead of unwrapping to the scalar. \sin(2 \cdot [5]) now evaluates to [\sin(10)], previously the bare scalar \sin(10). Broadcasting a scalar function over an n-element indexed collection now produces an n-element List for every n ≥ 1, matching the expression's static list<…> type (a vector<1> operand no longer types list<number> while evaluating to a scalar) and the user-function broadcast path, which already returned a List for single-element collections. An empty broadcast still evaluates to Nothing.

Resolved Issues​

  • Sum/Product over a computed list-valued body now reduces instead of broadcasting. Sum(L) of a literal collection reduced correctly, but a body that only evaluates to a list — e.g. a broadcast chain over a list literal, \operatorname{Sum}(\operatorname{mod}(\operatorname{floor}(7/2^{[0...10]}),2)) — returned the broadcast list unchanged instead of its sum. The arity-1 reducer form now reduces the evaluated value when it is a collection.

  • A symbol operand naming an operator no longer leaks into .unknowns. A function reference held as a symbol operand (e.g. the Add of ["Reduce", L, "Add"]) was reported as a free variable by .unknowns / .freeVariables, so consumers walking unknowns saw a phantom unbound name. Operator names now resolve as function references, not free variables.

  • subs() no longer corrupts a bound index named i (or any name that collides with a constant). Substituting into a canonical big operator — ce.parse("\\sum_{i=1}^{n}2^{-i}").subs({n: 9}) — re-canonicalized the held Limits index outside its binding scope, re-typing i as the imaginary unit: the index slot became an incompatible-type error and serialization dropped the index (\sum_1^9…). Held (non-canonical) operands now stay raw through subs() and are re-bound by the parent's canonical handler, exactly as when the expression was first built.

  • A bare numeric bounds pair on a big operator (\sum_1^9 ⟨body⟩) is no longer silently dropped at parse. It now parses to an index-less ["Limits", "Nothing", 1, 9]: a constant body iterates (\sum_1^9 2 → 18), and a body with free variables stays symbolic rather than losing its bounds.

  • A divergent integral over (-∞, ∞) no longer numericizes to a clean 0. .N() of \int_{-\infty}^{\infty} x\,dx (and x^3, \sin x, any odd divergent integrand) returned an exact scalar 0: the Gauss–Kronrod quadrature mapped the doubly-infinite domain through a symmetric transform, so an odd integrand cancelled to exactly 0 on the first panel with a 0 error estimate — indistinguishable from a genuine result downstream. The doubly-infinite case is now split at 0 into two half-line integrals that must each converge (the definition of improper-integral convergence); a divergent half fails to converge and the result falls back to a Measurement with an honest (large) error bar that consumers can reject. Convergent integrands are unaffected (\int_{-\infty}^{\infty} x e^{-x^2}\,dx → 0, \int_{-\infty}^{\infty} e^{-x^2}\,dx → √π). Note this also means no Cauchy principal value is implied: a symmetric divergent integral reports non-convergence rather than its principal value.

  • Compiled JavaScript arithmetic over a value that may be a list is now correct for both outcomes. Compiling 2h(x) - 1 where h may return a list produced scalar code that yielded NaN on a list value at run time (behind success: true). Such operands — typed broadcastable<…>, see New Features — now compile through the runtime broadcast helper: the same compiled artifact returns the scalar result for a scalar value and the element-wise list for a list value, matching the interpreter. Cases the helper cannot lower soundly now fail closed instead of emitting silently-wrong scalar code: a product of two or more possibly-list operands (a run-time matrix would need the matrix product, not an element-wise one), Equal/NotEqual over a possibly-list operand, and — on the Python target, where */+ repeat or concatenate a plain list — all arithmetic over possibly-list operands. compile() reports these as compilation failures (with the interpreter fallback available), rather than producing code that computes the wrong value.

New Features​

  • New broadcastable<T> type: honest static typing for values that may broadcast. The engine broadcasts element-wise at run time (2·[1,2,3] → [2,4,6]), and statically-visible collections have carried honest types (vector<3>, list<number>) for a while — but the same arithmetic over a value whose collection-ness is not statically visible (2h(x,y)-1 with h returning unknown) used to collapse to scalar number, even though evaluation broadcasts if h returns a list. Such expressions now type broadcastable<T> — "a T, or an indexed collection of T, applied element-wise". The type is produced by Add/Multiply and every broadcastable operator (Sin, Sqrt, Power, Abs, …) over an operand whose type is a top type (an unknown-return call) or already broadcastable; it propagates through nested arithmetic, juxtaposition (2(2h(x)-1) is a product, not a tuple), function application, and indexing ((2h(x,y)-1)[1] is valid, with element type number). Relatedly, a scalar function over a fixed-shape-typed intermediate no longer collapses either: \sin(10^4 \cdot [1,2,3]) — whose inner product types vector<3> — now types list<number> through every scalar-function hop (mod, scaling, …), so indexing the end state is valid. Operators that compute their own collection result (-M, M+N, matrix + scalar) are unaffected. Subtyping: number <: broadcastable<number> and list<number> <: broadcastable<number>, but broadcastable<number> is not a subtype of number (it may be a list). The type can be used in declarations (ce.declare('b', 'broadcastable<number>')) and signatures. Bare symbols are unaffected: an undeclared x in 2x still types scalar (inference pending), and tuples/points still bind atomically.

  • Applying a scalar function to a collection-valued expression now broadcasts — for every function body. Broadcasting a user function over a literal collection (f([1,2,3]) → [f(1), f(2), f(3)]) is long-standing; it now also applies when the argument only evaluates to a collection (f(g(3)) where g returns a list), and for every body — previously a non-arithmetic body such as x \mapsto \operatorname{If}(x > 0, 1, -1) applied to a computed list stayed inert. The static type of such an application is honest as well: list<R> for a visible collection argument, broadcastable<R> for a possibly-collection argument, where R is the function's return type (a list-returning function maps to a list of lists — no flattening). Declaring a collection parameter type ((list<number>) -> …) still binds the argument whole, and tuple arguments still bind atomically.

0.81.0 2026-07-16​

New Features​

  • New PointList operator (the point-list surface form). PointList is the explicit, importer-emitted operator that zips a point-with-collection into a list of points: PointList(-6, n) with n a 21-element list evaluates to the 21-element list of points, while PointList(1, 2) is just a plain point. Zip-to-shortest for multiple list components; scalars broadcast; an empty component yields an empty list; an infinite or unknown-length component fails closed (stays inert, no hang). It round-trips through LaTeX as \operatorname{PointList}(…). A plain Tuple now stays inert data — it never transposes — so tuples used as data are genuinely unaffected; tuple-with-collection arithmetic still scales component-wise, with no baked incompatible-type error, so a definition such as m(P) \coloneq P + s(P)\cdot(1, 0.3n) stays valid. (An earlier, evaluate-time Tuple-transpose of this idiom was replaced by the explicit PointList operator before it ever shipped in a release.)
  • PointList compiles on the javascript, glsl, and wgsl targets. With all-scalar components (including free plot variables), it emits byte-identically to the equivalent Tuple ([x, y] / vec2(x, y) / vec2f(x, y)), so point literals rewritten to PointList stay on the compiled path (GPU grids, per-pixel bodies). Provably non-scalar components (collection- or tuple-typed, or a union with a collection member) fail closed to the interpreter, as does the interval-js target (where Tuple itself has no lowering).
  • At defers a possibly-collection base to runtime instead of baking a type error. Indexing an expression whose type was over-narrowed to a scalar by arithmetic over an unresolved operand ((2h(x,y)-1)[1] with h returning unknown), or whose type is a union with an indexable member (number | list<number>), now stays symbolic and indexes once the base resolves to a collection — enabling structural substitution over vector-valued helper functions. Provably scalar bases ((5)[1], \pi[1], \sin(3)[1]) still report incompatible-type at canonicalization, and a base that turns out scalar at runtime errors at evaluation.
  • Pipeline contract test suite. test/compute-engine/pipeline-contracts.test.ts pins the guarantees for MathJSON-carrying pipelines: non-canonical box(json).json structural fidelity (with its documented normalizations), non-canonical .latex round-trip (with its three documented exception classes), transform-then-canonicalize-once equivalence, cached-boxed re-binding rules, compile-from-boxed parity, and the non-canonical shape vocabulary. Breaking a test in this suite requires a CHANGELOG callout.

Breaking Changes​

  • Partition(xs, n) now returns chunks of size n, not n groups. Partition([1, 2, 3, 4, 5], 2) now evaluates to [[1, 2], [3, 4], [5]] (chunks of 2, trailing chunk short) instead of splitting the collection into 2 nearly equal groups. To split into a given number of groups, use Chunk instead (Chunk([1, 2, 3, 4, 5], 2) → [[1, 2, 3], [4, 5]]). A new sliding-window form Partition(xs, size, step) returns the complete windows of size elements whose starting positions are step apart (Partition([1, 2, 3, 4, 5], 2, 1) → [[1, 2], [2, 3], [3, 4], [4, 5]]). The predicate form Partition(xs, predicate) (split into matching / non-matching groups) is unchanged.

Resolved Issues​

  • The two declare forms are equivalent under declare-then-assign. Declaring a function head with the object form (ce.declare('f', {signature: …})) and then assigning a function literal (f(x) \coloneq … or ce.assign) silently discarded the declared signature — a scalar call to a tuple-typed parameter stopped type-erroring — while the string form (ce.declare('f', '(…) -> …')) preserved it. The object form now runs the same reconciliation: the declared signature is authoritative, arity mismatches error clearly, and the stored definition is identical to the string form's.
  • Juxtaposition with a value-typed symbol is multiplication again. A symbol inferred or declared with the wide value type (e.g. any bare symbol that had passed through Max/Min-style (value*) signatures on the same engine) made subsequent parses of 2x silently produce Tuple(2, x) instead of Multiply(2, x) — an order-dependent wrong-parse present in released versions, affecting any warm engine. A wide type is not evidence of point-ness; the juxtaposition gate now treats value like unknown and multiplies. Locked by warm-engine order-independence tests in the pipeline-contract suite.
  • \operatorname{sin} (and every lowercase spelled-out native function name) now binds as a function call. \operatorname{sin}(x)^{2} parsed as the unknown symbol sin times x^2 with isValid: true — silent wrong math; it now parses to \sin(x)^2 with call-binding identical to the native command (prefix minus after the call, postfix power on the result, ^{-1} inverse, base subscripts). Covers the trig/hyperbolic/inverse families, ln/log/lg/lb, and arg; bare identifiers (sin without \operatorname) are unchanged.
  • A {…} group after a function is now its argument list. For a dictionary-registered function that takes parenthesized arguments, a brace group is accepted exactly as if it were (...): \gcd{a} → GCD(a), \gcd{2,4} → GCD(2, 4), \operatorname{floor}{2.5} → Floor(2.5), and consecutive groups are successive arguments (\mod{x}{2} → Mod(x, 2), the TeX multi-argument-macro habit). Previously the function parsed as a bare symbol and the group multiplied against it — silently wrong (\gcd{a} was GCD · a). Commands with implicit (unparenthesized) arguments keep the transparent-grouping convention — braces render invisibly, so the argument reads the way the rendered formula does: \sin{x}y is Sin(x·y) and \sin{x}^2 is Sin(x²), matching \sin x y and \sin x^2. A brace group after a generic declared or unknown name (f{x}) keeps its juxtaposition (multiply) reading.
  • Function-style \operatorname{…} aliases now bind their call like natively-spelled functions. The parse-only aliases (\operatorname{mod}, var, cov, corr, count, length, nCr, random, shuffle, repeat, join, range, histogram, pdf, cdf) parsed as a bare symbol, so a prefix minus captured the function symbol itself (-\operatorname{mod}(x,1) → loud incompatible-type error) and a postfix power stole the argument group (\operatorname{mod}(-x,1)^{2} parsed silently as Mod · ((−x,1))², evaluating to NaN). They are now function-kind dictionary entries: the call binds before prefix minus, and a postfix power applies to the call result (\operatorname{mod}(-x,1)^{2} → Power(Mod(-x,1), 2)).
  • target.compile() can return a failure instead of throwing. A fail-closed compile error (e.g. At on a non-indexed base) always threw out of a compilation target's compile(); passing the new { fallback: true } option returns the documented { success: false, error, run } shape instead, with run falling back to the interpreter — matching the engine-level compile() contract. The default remains throwing, so existing callers are unaffected.
  • An over-arity function literal is rejected at registration. Assigning f(x, y) \coloneq x + y to a name declared (number) -> number was silently accepted, and f(3) then silently partial-applied; it now reports a clear error naming the literal's arity and the declared maximum. (The declared signature was already authoritative for types; this closes the arity gap.)
  • Applying a function whose body stays partially symbolic no longer loses the argument. When a lambda body could not fully evaluate (e.g. a Which/If guard over an undetermined symbol), the application returned the body inert with the parameter unsubstituted — so Map([1,2,3], k \mapsto \operatorname{Which}(k = m, 10^9, k)) yielded three identical copies of the raw body with k leaked free and the elements gone. The parameter's value is now substituted into the held result, fixing Map, Filter, Tabulate, Zip-with-function, and direct Apply in one place.
  • Lazy collections serialize faithfully. .latex of a canonical Map, Filter, Zip, Tabulate, Range, Linspace, or Comprehension no longer materializes an elided or value-baked preview (which could re-parse to a corrupt expression — a Map over a bound symbol serialized as N copies of its raw lambda body); each now emits its operator form, which re-parses to the identical expression. toString() still shows the materialized preview for display.
  • Negative indexing on a lazy Take was off by one. Take(xs, n).at(-1) returned the second-to-last element of the taken prefix (at(-1) on Take([10, 20, 30], 2) was 10 instead of 20).
  • The display preview of a lazy Take sampled the wrong tail. Displaying Take(xs, 50) over a lazily-enumerated source showed the source's last elements ([1, 2, …, 98, 99]) instead of the taken prefix's ([1, 2, …, 49, 50]): the operands were materialized to their own display preview — continuation placeholder included — before Take consumed them.
  • A boolean Sort comparator now orders instead of silently doing nothing. A comparator returning True/False (e.g. (a, b) -> a > b) never reordered — only signed-number comparators worked. Boolean comparators are now interpreted Elixir-style: True means the first argument sorts first, so (a, b) -> a > b sorts descending.
  • A mistyped GroupBy key function is now reported. GroupBy(xs, Even) (an unknown symbol auto-declared by its own use) silently placed every element in its own garbage group keyed "Even(1)", "Even(2)", …; it now throws with a spell-check suggestion, like Filter and Partition do for broken predicates. Grouping by explicitly declared symbolic functions is unaffected.
  • The optimization form of ArgMax/ArgMin canonicalizes its function operand again. ArgMin(f, RealNumbers) (the "locations of the minimum over a domain" form, used by the identities library) short-circuited canonicalization, leaving the function literal in a non-canonical shape that no longer matched the identities library's stored rewrite patterns. The form remains inert under evaluation; the collection form (ArgMin([3, 1, 2]) → 2) is unchanged.
  • A canonical Comprehension now serializes to LaTeX that round-trips. Its .latex was an elided display preview (\lbrack 1, 4, 9, \dots, 62\,500\rbrack) that silently re-parsed to a corrupt 11-element List containing a literal \dots; it now serializes through the faithful body \operatorname{for} var = domain form, which re-parses to the identical comprehension (including tuple bodies, dependent domains, and infinite domains).
  • Lazy Comprehension elements are memoized. .at(n) re-walked the domain on every call and each .each() recomputed from scratch, making repeated indexed access quadratic (at(100)×100 on a 200-element comprehension: ~5 s → ~23 ms; a repeat .each() walk: ~110 ms → ~0.2 ms). The prefix cache is generation-stamped, so reassigning a free variable the body depends on invalidates it, and it is capped (100k elements) beyond which access streams as before.
  • A broadcast condition no longer crashes Which/If. A condition that evaluates to a collection of booleans (e.g. a piecewise guard over a broadcast function application inside a comprehension) threw Condition must evaluate to "True" or "False"; it now stays symbolic (held), letting the surrounding expression evaluate.

Fixes from a review of the 0.78.0–0.80.0 changes:

  • Compiled n-ary and collection GCD/LCM returned wrong numbers. The compiled form passed a third operand into the internal tolerance slot (GCD(2.25, 2.1, 0.6) compiled to 2.1, silently consuming the 0.6 as ε; GCD(12, 18, 8) → 6 instead of 2; LCM(4, 6, 10) → 12 instead of 60), and a collection argument (GCD([12, 18])) compiled to NaN. All forms now fold pairwise and match the interpreter; collection operands whose elements can't be enumerated at run time fail closed to the interpreter.
  • A user-defined function sharing its name with a loop index hijacked compiled Sum/Product. With f(x) := x^2 declared, compiling \sum_{f=1}^{3} f emitted references to the function instead of the loop index (returning garbage with success: true; a null interval on interval-js). Bound names are now tracked explicitly through every binding form instead of being inferred from resolved code.
  • Adaptive quadrature no longer poisoned by a NaN sample. A single integrand NaN at a quadrature node (e.g. the removable singularity of \sin(x)/x at the midpoint of a symmetric interval) permanently corrupted the convergence accumulators, silently falling back to slow, nondeterministic Monte Carlo. Non-finite panels are now excluded until subdivided away: \int_{-1}^{1} \sin(x)/x \, dx converges to 2\,\mathrm{Si}(1). Also, .N() on an integral without a closed form now uses adaptive Gauss–Kronrod before falling back to Monte Carlo, matching the compiled path's accuracy.
  • Comprehension iteration state was shared across traversals. Two interleaved iterators over the same comprehension (or reading .count mid-iteration on a dependent comprehension) corrupted each other's index variables, yielding wrong elements. Each traversal now gets its own scope, and a function literal produced by a comprehension body now captures the per-iteration value of the loop variable ([x \mapsto x + i \text{ for } i \in 1..3] applied to 10 gives 11, 12, 13, not 13, 13, 13).
  • GPU gcd regressed on large integers. The tolerant float loop shipped in 0.80.0 dropped the exact-integer path on glsl/wgsl: gcd(4000000, 2) returned 4000000. Exact Euclid is restored for integer inputs within f32 range.
  • Tolerant GCD/LCM invariants. Scale-mismatched inputs violated gcd ≤ min / lcm ≥ max (\gcd(2.5, 10^{21}) → 10^{21}); zero-argument GCD()/LCM() crashed (now the identities 0/1); nested collections now fold in a single evaluation.
  • Max/Min absorb NaN found inside collections: Max([1, NaN, 3]) now returns NaN, consistent with Max(NaN, 5) and with compiled code.
  • IndexOf on infinite lazy collections hung indefinitely, ignoring ce.timeLimit. The search now streams (linear instead of quadratic on lazy collections) with deadline checkpoints. Compiled IndexOf also now uses the interpreter's tolerance-aware comparison instead of strict ===.
  • Sequence interpretation (Interpret) regressions. The 0.79.0 anchor-search optimization rejected legitimate non-monotonic polynomial sums (e.g. 100 + 164 + 198 + 208 + \dots + 308, which is \sum_{k=1}^{14} k^3-21k^2+120k) and then ground for hours in an exact-rational recurrence search that ignored ce.timeLimit. The break heuristic now requires a sustained divergence streak, and the recurrence search honors the deadline.
  • Parse-diagnostics false positives. f(x) \coloneq x^2 no longer emits juxtaposition-as-multiply/undeclared-symbol for the definition's own head, and symbols declared through the getSymbolType handler are no longer reported as undeclared.
  • Custom compile handler contract. The handler now genuinely takes precedence over built-in operator mappings (e.g. Add) as documented (control-flow heads remain non-overridable, now stated explicitly), and analyzeReferences no longer reports custom-compiled operators as unsupported.
  • Assorted: applying a non-numeric symbol to a collection (t(\{1,2\}) with t a string) reports an application type error again instead of a confusing Multiply error; PointX/PointY work on Sets of points (previously returned []); a provider timeout inside Integrate is re-thrown instead of swallowed; \operatorname{erf} at a directionless complex infinity stays symbolic instead of saturating to 1; compiled Map/Filter no longer leak JavaScript's 0-based callback index into two-parameter lambdas.
  • Long flat operator chains no longer overflow the parser stack. A flat chain of a same-precedence associative operator (1+1+\dots, a\times b\times\dots, chained <) recursed one parselet frame per term and overflowed at ~1,300 terms; same-precedence continuations now iterate in the parser's infix loop (a 20,000-term sum parses; same-operator chains produce the same flattened n-ary trees as before). One deliberate tree change: mixed same-precedence operators now group left-to-right — the conventional reading — where they previously nested rightward as an artifact of the recursion (a\times b\otimes c now parses as CircleTimes(Multiply(a,b), c), not Multiply(a, CircleTimes(b,c))). Right-associative chains (a=b=c) still nest by construction.
  • Only binary arithmetic operator symbols lower to first-class combiners. Passing a unary or relational operator symbol where a function is expected (Reduce([1,2,3], Negate, 0), Map(xs, Negate), Filter(xs, Less)) compiled to a wrong binary infix lambda (Negate folded like Subtract, returning -6 behind success: true). Non-arithmetic operator symbols and combiners of the wrong arity now fail closed to the interpreter. Also: compiled Reduce over an empty collection without an initial value returns NaN instead of throwing, and Tabulate with a statically non-positive dimension fails closed instead of returning [].
  • Flatten with no depth now fully flattens ragged lists. Flatten([[1,x],[2]]) was a no-op (only uniform tensors flattened), which became visibly inconsistent once Flatten(expr, depth) shipped; the default now flattens completely, per the documented (Wolfram) semantics.
  • Scan no longer silently drops an invalid initial value — the error is surfaced instead of computing the unseeded scan.
  • ElementMax/ElementMin/Clamp on the python target now match the interpreter's broadcasting. Length-mismatched arrays previously raised a NumPy ValueError (or size-1-broadcast) where the interpreter zips to the shortest operand; an injected helper now aligns semantics while keeping the vectorized NumPy fast path.
  • lambda.body is canonical for every declaration route. The public accessor returned a raw, non-canonical body for functions declared with a MathJSON evaluate handler (parse/assign routes were fine), tripping canonical-only asserts in consumers; it now returns the canonical scoped Block shape everywhere. And analyzeReferences now probes a custom compile handler per target language, so an operator whose handler only supports some targets is correctly reported in unsupported on the others.

New Features​

  • New collection operators. A batch of higher-order and structural collection operators (see the Collections reference):
    • Quantifiers — Any(xs, predicate?) and All(xs, predicate?) test whether some or every element satisfies a predicate (the elements themselves, treated as booleans, when no predicate is given). Both short-circuit, so they return a definite answer even on infinite collections, and stay symbolic when the result depends on undetermined elements. Any([]) is False, All([]) is True.
    • Cumulative — Scan(xs, f, initial?) is the running fold (same length as the input: Scan([1, 2, 3, 4], Add) → [1, 3, 6, 10]), and Differences(xs) gives the successive differences (length n − 1, computed exactly).
    • Prefix/suffix — TakeWhile(xs, predicate) and DropWhile(xs, predicate) take or drop leading elements while the predicate holds; both are lazy and compose with infinite collections.
    • Mapping — FlatMap(xs, f) maps f over xs and splices collection-valued results into a single list (a scalar result is kept as a single element).
    • Extrema — MaxBy(xs, f) / MinBy(xs, f) return the element with the largest/smallest key f(x); ArgMax(xs, f?) / ArgMin(xs, f?) return its 1-based index. The first occurrence wins ties, and all stay symbolic on empty or infinite collections.
    • Grouping — ChunkBy(xs, f) splits into maximal runs of consecutive elements sharing the same key f(x); Dedup(xs) collapses consecutive duplicates only (contrast Unique, which removes all duplicates).
    • Functional element updates — Insert(xs, index, value), DeleteAt(xs, index), and ReplaceAt(xs, index, value) return a new list with an element inserted, removed, or replaced at a 1-based index. Negative indexes count from the end (Elixir-style); Insert at -1 or n + 1 appends.
  • Map is now variadic. Map(xs, ys, …, f) applies f element-wise across several collections (a zipWith), truncating to the shortest input; the function is always the last argument (Map([1, 2, 3], [10, 20, 30], (x, y) ↦ x + y) → [11, 22, 33]).
  • Sort accepts a one-argument key function. In addition to a two-argument comparator, Sort(xs, f) with a unary f sorts ascending by the key f(x) (stable on ties), discriminated from a comparator by arity.
  • Flatten accepts an optional depth. Flatten(xs, depth) flattens only depth levels of nesting; without it, the collection is fully flattened (Flatten([[1, [2]], [3]], 1) → [1, [2], 3]).
  • More collection operators compile on the javascript target.
    • Reduce accepts a custom combiner: a Function literal (Reduce([1, 2, 3], (a, b) \mapsto a + 2b, 0) → 12), a user-defined function symbol, or an operator symbol such as Subtract — previously only the Add/Multiply/Min/Max folds compiled. A custom combiner requires an explicit initial value (without one the interpreter folds from Nothing, which has no numeric equivalent — that form still fails closed). Fold, which canonicalizes to Reduce, now compiles too.
    • Tabulate (1-D and 2-D) and Fill compile to native array construction with 1-based indexes — and therefore Table, in both its alias and Mathematica-style iterator forms. Compiling is the natural fast path for materializing these now-lazy collections.
    • CountIf, Find, IndexWhere, Position compile their predicate lambda to native array operations, with the interpreter's conventions preserved: 1-based indexes, IndexWhere → 0 and Find → NaN (Nothing projected onto a real target) when no element matches.
    • Append, Most, Slice, IsEmpty, Count, Contains, Unique, RotateLeft/RotateRight, Zip, Linspace, Chunk, Partition (integer and predicate forms), Ordering, and Shuffle compile to native array operations, each verified element-for-element against the interpreter (1-based inclusive Slice with negative-from-end indexes, rotation shift normalized modulo the length, Chunk/Partition producing k chunks of ⌈len/k⌉, stable Ordering ties, Zip truncating to the shortest input, Linspace including both endpoints). Non-finite runtime counts/indexes fall back to the interpreter's defaults instead of crashing or silently diverging, and Shuffle honors the engine's randomSeed (a deterministic, reproducible permutation, like Random). Forms with no numeric equivalent on the target fail closed: a custom Ordering function, the explicit-seed Shuffle form, statically non-positive Chunk/Partition counts, and Contains/Unique over compound (nested-list, tuple, complex) elements, whose JS equality is referential rather than structural.
    • Any, All, TakeWhile, DropWhile, FlatMap, and Scan compile (predicate/mapping lambdas → native some/every/flatMap and slices; Scan is the running fold, with the initial value not emitted and the seedless form emitting the first element as-is, matching the interpreter).
  • Core scalar operators compile on the javascript target: Boole (Iverson bracket, with the same runtime boolean guard as Which/When), KroneckerDelta (n-ary, tolerance-aware like compiled Equal), Element(x, list) membership, Identity, and Apply of a function literal.
  • Linear algebra compiles on the javascript target (closing the parity gap with the python target): Dot and MatrixMultiply (with the interpreter's dimensionality dispatch — vector·vector → scalar, matrix·vector → vector, matrix·matrix → matrix), Cross, Norm (scalar, 2-/Frobenius, and p-norms), Transpose, Determinant, Inverse (singular → NaN), Trace, Flatten (with optional depth), Shape, and Reshape (with the interpreter's cyclic padding).
  • The python compilation target now covers collections and function literals. Function literals compile to Python lambdas, and the collection operators above (list access/slicing, Map/Filter and the other higher-order operators, Reduce/Scan, Tabulate/Fill, Linspace, Zip, Ordering, plus Flatten/Shape/Reshape/Trace via NumPy) emit Python with the same interpreter-verified semantics, validated by executing the emitted code (venv-gated parity suite). Also fixed: compiled Range was off by one on the Python target — np.arange excludes the stop value and is 0-based in the one-argument form, so Range(2, 6) compiled to [2..5] and Range(5) to [0..4]; CE Range is inclusive and 1-based.
  • Compiled Range with no explicit step now auto-descends on both targets, like the interpreter: Range(5, 1) → [5,4,3,2,1] and Range(-2) → [1,0,-1,-2] previously compiled to [] on the JavaScript target (the implicit step was a fixed +1).

0.80.0 2026-07-16​

New Features​

  • Custom per-operator compilation handler. An OperatorDefinition can now supply a compile handler — (args, compile, { language }) => string | undefined — that emits target-language source for a call to that operator. It mirrors a built-in compiled-function handler (recursively compile operands, branch on language for javascript/glsl/wgsl/python) and takes precedence over the target's built-in mapping, so a consumer can override how even a built-in operator compiles (e.g. a custom-tolerance GCD), or add compilation for an operator the target doesn't know. Returning undefined falls back to the default compilation. This replaces the never-wired, interpreter-only xcompile stub. See OperatorCompileHandler.
  • ElementMax, ElementMin — element-wise (broadcasting) maximum and minimum (the NumPy maximum/minimum primitive). Unlike Max/Min, which reduce all operands — including a collection's elements — to a single scalar, these broadcast: a scalar over a collection returns a collection of the per-element extremum (ElementMax(0, [1, -2, 3]) → [1, 0, 3]), collections zip, and all-scalar arguments give a scalar. They are variadic (two or more arguments): ElementMax(0, [1, -2, 3], 2) → [2, 2, 3]. Exactness is preserved (ElementMax(√2, 1) → √2). They compile on every target: javascript (all-scalar → a direct call, a collection operand → a _SYS.bcast), interval-js (interval max/min — restoring break detection), glsl/wgsl (native max/min), and python (np.maximum/np.minimum).
  • Clamp(x, lo, hi) — clamp a value to a range (min(max(x, lo), hi)), also broadcasting over collection arguments (Clamp([-1, 0.5, 2], 0, 1) → [0, 0.5, 1]). Compiles on all targets, including the native clamp on glsl/wgsl and np.clip on python.

Resolved Issues​

  • GCD/LCM now evaluate on non-integer real arguments. \gcd(2.25, 2.1) and \operatorname{lcm}(2.5, 1.5) previously stayed symbolic (and compiled to NaN, blanking any plot that used them); they now fold via a tolerant floating Euclidean algorithm — the standard "float GCD" that terminates when a remainder falls below ε · max(|a|, |b|) (ε = 1e-6). This honors the exactness contract: an inexact (float) argument numericizes, like \cos(5.1), while integer and exact-rational operands keep their exact (\gcd(4, 6) → 2) and symbolic (\gcd(9/4, 21/10) under evaluate(), 0.15 under .N()) behavior. The compiled javascript, glsl, and wgsl targets fold reals the same way (the GPU _gpu_gcd cutoff was integer-tuned and is now scale-relative). The tolerance is deliberately its own constant, not the engine's numeric tolerance — float-commensurability is a much looser notion, and the value that reproduces a given renderer's output is a consumer choice. (Requested by the Tycho/Graph Paper team for expressions such as r ≤ gcd(θ², θ + a).)
  • GCD/LCM reduce a finite collection argument. \gcd([12, 18, 24]) → 6 and \operatorname{lcm}([4, 6]) → 12 (a list argument is folded over its elements); previously the whole call stayed symbolic even for integers. Mixed list-and-scalar arguments (GCD([12, 18], 8) → 2) and lists of reals are handled; an infinite or enumeration-declined collection stays symbolic rather than grinding to the evaluation deadline.
  • Max/Min of a scalar and a collection no longer mis-compiles to NaN. Max(0, [1, -2, 3]) evaluates to 3 (the reduction folds the collection's elements), but on the JavaScript target it compiled to Math.max(0, [1,-2,3]) — passing an array as an argument — and returned NaN at run time. It now folds the scalar and every collection's elements into a single reduction, matching evaluate(). (Surfaced by the Tycho/Graph Paper team's max(0, …) plot expressions.)
  • Max/Min compile correctly on the python target. They were mapped to the element-wise, strictly-binary np.maximum/np.minimum, so a collection operand was mis-reduced (Max(0, [1,2,3]) → [1,2,3] instead of 3) and a single-list or n-ary call errored at run time. Max/Min now reduce a collection operand with np.max/np.min and combine the per-operand results element-wise (keeping a scalar/array operand — e.g. the plot variable — vectorized), matching evaluate() and the JavaScript target.
  • At(base, index) serialization now parenthesizes a compound base. At(x+1, 2) serialized to x+1_2 (subscript) / x+1[2] (bracket index style) — the index bound only to the trailing operand, dropping the base grouping so the LaTeX re-parsed to something other than element access. A base whose precedence falls below the postfix index operator is now wrapped: (x+1)_2 / (x+1)[2]. Symbol and function-application bases (v_1, H(x, y)[1]) are unchanged. (Serialization round-trip reported by the Tycho/Graph Paper team. The default subscript index style is unchanged; programming-style bracket output — which re-parses to At independent of the base's declared type — remains available via indexStyle: () => 'bracket'.)

Collections​

  • A list comprehension is now a lazy collection: evaluate() no longer walks its whole domain. A Comprehension ([body \operatorname{for} i=…]) behaves like Range/Map — evaluate() returns the comprehension itself, and its collection type, .count, and emptiness are reported from the iterator-clause counts without enumerating a single element. Elements are materialized only when actually consumed (a positive at(n) walks just the first n); indexing, iteration, and aggregation (Sum, Length, At, Take, Map, …) are unchanged. Binding an unread comprehension to a name is therefore ~O(1) instead of materializing its whole domain up front — e.g. 25 dead 225-element weight tables dropped from ~6 s to negligible. (Reported by the Tycho/Graph Paper team.)
  • A bracket comprehension parses to the comprehension itself, not a one-element List wrapping it. [body \operatorname{for} i=…] previously produced ["List", ["Comprehension", …]], which reported count: 1 and mis-indexed (W[3] → NaN); it now returns the Comprehension directly, mirroring the Range/Linspace bracket passthrough. (Reported by the Tycho/Graph Paper team.)
  • Tabulate (and its Table alias) is now a lazy indexed collection. evaluate() returns the Tabulate itself rather than building the whole array; .count is the outer dimension and an element is computed by applying the function only when indexed or iterated. A Tabulate(f, 1_000_000) that is bound but unread is now O(1) instead of hanging while it builds a million-element list. The Mathematica-style Table(i^2, {i, 1, n}) inherits this (it canonicalizes to Tabulate).
  • Permutations and Combinations are now lazy collections with closed-form counts. Permutations(xs, k?) and Combinations(xs, k) no longer materialize their factorially-many elements to be bound, counted, or indexed: .count is P(n, k) / C(n, k) computed directly (previously Permutations of a 9-element list took ~33 s just to answer .count), elements stream from the iterator, and at(n) walks only as far as needed.

Compilation​

  • A user-defined function passed as a higher-order operand now compiles by reference. Compiling Map(list, f) / Filter(list, f) — where f is a function declared on the engine (f(x) := …, x ↦ …) rather than an inline lambda — previously emitted a dangling _.f and threw at run time. The function operand now resolves to the same shared local (_fn_f) the call-site path already emitted, so a user function used as a first-class value works wherever it is referenced, not only when it is called. Its freeSymbols are computed from the operand function's body (so a free symbol used only inside f is reported), and inline-lambda operands are unaffected. (Reported by the Tycho/Graph Paper team.)

API​

  • BoxedOperatorDefinition.lambda exposes a user-defined function's body and parameters. ce.lookupDefinition(name).operator.lambda returns { parameters, body } — the parameter names/types and the body as a boxed expression — for a definition created from a function literal (f(x) := …, x ↦ …, ce.assign('f', lambda)), or undefined for a built-in operator. This is a supported, stable accessor over the internal _lambdaLiteral, letting a consumer traverse or resolve a function reference structurally without re-parsing or textually inlining its source. (Requested by the Tycho/Graph Paper team.)

0.79.3 2026-07-15​

Parsing​

  • A number-valued symbol juxtaposed with a parenthesized collection now parses as multiplication, not a function application. When a symbol known to be a non-function value — declared with a numeric type or assigned a value — was juxtaposed against (…) whose body referenced a collection (e.g. k(\cos(S)) with k a number and S a bound list), it parsed as k applied to the body — an illegal application of a number, yielding NaN — instead of k\cdot\cos(S). The single-argument invisible-operator rule only treated a scalar-numeric argument as multiplication; a collection-typed argument fell through to the function-call heuristic even when the leading symbol could not be a function. Such a symbol now scales over the argument, matching the scalar-argument and multi-operand cases. An undeclared or unknown-typed symbol stays ambiguous and keeps the f(x) function-application default. (Reported by the Tycho/Graph Paper team.)

  • Parsing a call no longer un-assigns a bare-symbol argument. Parsing an application like f(S) runs argument-type inference on each operand. When the callee's parameter type was unknown or any and the argument symbol had been declared unknown and assigned a value, inference computed unknown (a no-op narrowing) and wrote it back to the symbol's type — and the value-definition type setter discards the held value whenever the type is set to unknown. So merely parsing f(S) (no evaluate/N) silently cleared S's assigned value, leaving it unbound and every dependent expression NaN. Inferring unknown adds no information, so it is now skipped entirely and never overwrites an existing binding; inference of a concrete parameter type still narrows an open argument as before. (Reported by the Tycho/Graph Paper team.)

Collections​

  • The .x/.y/.z point-coordinate accessors now broadcast over a list of points. They previously parsed to First/Second/Third — the collection element-indexing operators. On a single point that is correct ((3,4).x = 3, since the first element of a 2-tuple is its x-coordinate), but on a list of points the two diverge: [(1,2),(3,4),(5,6)].x returned the first point (1,2) instead of the list of x-coordinates [1,3,5]. The accessors now parse to dedicated PointX/PointY/PointZ operators that extract a coordinate and map element-wise over a list of points (matching the threadable .real/.imag accessors), while a single point still returns the scalar coordinate. First/Second/Third are unchanged and continue to index a collection. The new operators broadcast on the javascript compile target (L.x → (L).map((p) => p[0])) and, for a single point, swizzle on the GPU target as before. (Reported by the Tycho/Graph Paper team.)

Compilation​

  • Element-wise (scalar↔list) arithmetic now broadcasts on the javascript compile target. An arithmetic or element-wise math operator applied to a list-valued operand — x - L, 2L, L^2, -L, \sin(L), \sqrt{L}, or two lists L + M — previously compiled to scalar JavaScript that returned garbage (-_.L + _.x → NaN) behind a success: true, unless an operand was a concrete collection at compile time (which failed closed). A symbolic list-valued parameter (bound at run time — the normal compile case) slipped through entirely. These now compile to a _SYS.bcast runtime helper that maps the operator element-wise, matching the interpreter's broadcasting: scalars are reused for every element, two lists zip to the shorter length, and nested lists (matrices) recurse. The pure-scalar fast path is unchanged. A complex-valued list still has no coverage and now fails closed correctly (success: false → interpreter fallback) instead of silently returning garbage. Combined with the .x/.y point-broadcast change above, a compiled expression such as \min((x - V.x)^2 + (y - V.y)^2) over a list of points V now evaluates correctly. (Reported by the Tycho/Graph Paper team.)

0.79.2 2026-07-15​

Compilation​

  • The collection form of Sum, Product, Max, and Min now compiles on the javascript target. Applied to a collection with no indexing set — e.g. [3,4,5].\operatorname{total} (which canonicalizes to Sum([3,4,5])), a list product, or \max(v) for a list v — these previously threw Sum: no indexing set (dropping the whole expression to interpretation) or, for Max/Min, compiled to Math.max([…]) and returned NaN. They now lower to a native .reduce, with empty-collection identities matching the interpreter (Sum([]) = 0, Product([]) = 1, Max([]) = -\infty, Min([]) = +\infty). This lets a compiled list comprehension whose body uses .total/.count/\min/\max compile end-to-end into a loop instead of falling back. The indexing-set forms (\sum_{n=1}^{5}) and the scalar variadic \max(a,b,c) are unchanged; a non-collection operand still fails closed. (Reported by the Tycho/Graph Paper team.)
  • List-shaped collection operators now compile on the javascript target. Last, Rest, Take, Drop, Join, Reverse, Sort, IndexOf, Map, and Filter previously fell back to interpretation (Unknown operator); they now lower to native array operations (.slice, .reverse, .sort, .indexOf, .map, .filter, …). Take/Drop clamp a negative count to match the interpreter (Take(xs, -2) = [], Drop(xs, -2) = xs); Reverse and Sort copy first so the source is not mutated; Sort compiles the default ascending numeric order (a custom comparator fails closed); IndexOf is 1-based (0 when absent); Map/Filter compile their lambda operand. A non-indexed-collection operand fails closed. (Requested by the Tycho/Graph Paper team.)

Evaluation​

  • IndexOf / IndexWhere now work on a tensor-backed list. A rectangular numeric list is represented as a BoxedTensor, which inherited the abstract no-op indexWhere and so made IndexOf/IndexWhere always return 0 (not found) even for a present element. The base indexWhere now scans any finite indexed collection for the 1-based index of the first match.

Serialization​

  • Mod (\bmod) parenthesizes compound operands so its LaTeX round-trips. Infix \bmod binds tighter than +/- on re-parse, so Mod(x+5, 2\pi) serialized to x+5\bmod2\pi and re-parsed as x + (5 \bmod 2\pi). An operand at addition precedence is now wrapped — (x+5)\bmod2\pi — while juxtaposition products (3k, 2\pi), fractions, powers, and negation stay unwrapped since they already re-parse as tight units. A left-nested Mod is also now parenthesized (\bmod is right-associative, so Mod(Mod(a,b),c) → (a\bmod b)\bmod c). (Reported by the Tycho/Graph Paper team.)

Parsing​

  • Juxtaposition of a collection-typed symbol with a function call parses as multiplication. A symbol declared with an abstract indexed_collection or collection type but not yet assigned a value — e.g. y_r in y_r\sin(a) — grouped into a Tuple instead of a Multiply, even though its concrete subtypes (list, vector, matrix, numeric tuple) and the assigned-value case already multiplied. The abstract collection type now scales like its subtypes; non-indexed set and heterogeneous tuple operands still group as a Tuple. (Reported by the Tycho/Graph Paper team.)

0.79.1 2026-07-15​

Evaluation​

  • ce.timeLimit is now enforced during symbolic integration and rule matching. Extends the 0.79.0 expression-tree-growth checkpoint to two paths that previously ran unbounded: the multinomial expansion of a power of a sum (expandPower) and the rule-set scan (matchAnyRules). The worst case is compiling a definite integral of a high-power integrand — e.g. \int_{-15}^{15} (2 + \sin(3y) + \cos(\pi^2 y))^p\,dy: the compiler's antiderivative-first attempt expands (trinomial)^p into a multinomial with C(p+2, 2) terms (≈ 6·10⁴ at p=350) and matches the integration rule set against it (~100 ms per rule), neither of which hit the per-node checkpoint, so compilation stalled for many seconds (or hung outright) instead of honoring the deadline. Both paths now cooperatively check the deadline, so a hard integrand degrades to Gauss–Kronrod quadrature at ce.timeLimit (default 2 s) as intended, and a bare evaluate() of such an integral throws a catchable CancellationError (cause: 'timeout') rather than running unbounded. (Reported by the Tycho/Graph Paper team.)

0.79.0 2026-07-14​

Evaluation​

  • ce.timeLimit is now enforced during expression-tree growth. The evaluation deadline was only checked inside specific loops (collection enumeration, polynomial GCD, …), so an evaluation whose cost is dominated by expression construction never hit a checkpoint. The worst case is a nested user-function chain whose body references a parameter several times — e.g. a symbolic Newton iteration s(y, x_p) := \frac{y - f(x_p)}{f'(x_p)} + x_p applied as s(y, s(y, … s(y, x_0))) — which grows the result ×4 per nesting level: at depth 15 it previously exhausted an 8 GB heap without ever honoring timeLimit. A cooperative checkpoint on the per-node evaluation path now cancels such evaluations at the deadline with a catchable CancellationError (cause: 'timeout'), e.g. at the default 2 s limit the depth-15 chain aborts using < 60 MB. Numeric chains (each level folds to a number) are unaffected and remain fast. Note: long-running evaluations that previously completed after exceeding timeLimit now throw — raise ce.timeLimit (or set it to 0 for no limit) if you rely on multi-second symbolic evaluations.

Library and Definitions​

  • ce.searchDefinitions() now treats the query as OR-ed keywords. Previously a multi-word query only matched definitions containing every word, so keyword-bag queries like "floor quotient integer division" returned nothing. Any matching word now suffices, and results are ranked by how many words they match and how exactly (identifier match, then trigger or curated keyword, then description). The query may also be an array of strings — ce.searchDefinitions(['gcd', 'least common multiple']) — with each element treated as an OR-ed alternative.

Solving​

  • Solve accepts Mathematica-style constraint systems. The first argument may now bundle domain constraints together with the equation — \mathrm{Solve}(\{100a+10b+c=11(a^2+b^2+c^2), a\in\{1,\dots,9\}, b\in\{0,\dots,9\}, c\in\{0,\dots,9\}\}, \{a,b,c\}) → [(5, 5, 0), (8, 0, 3)]. Element items inside a Set/List/And first operand are lifted into per-variable domain specs (equivalent to passing a \in D as separate spec arguments), the remaining items form the equation/system, and a variable list written as a set (\{a,b,c\}) is accepted alongside the existing [a,b,c] list form. The variable list may be omitted entirely when the bundled constraints name the unknowns: Solve(\{eq, a\in\{1,\dots,9\}, …\}) solves for the constrained symbols in constraint order. A constraint for a variable that already carries a spec-position domain is merged conjunctively (both must hold), and a constraint naming a symbol absent from an explicit variable list leaves the expression unevaluated rather than guessing. A system of equations given as a Set (or And) now also solves like the equivalent List.
  • Trailing domain argument: Solve(eq, x, \mathbb{Z}). A trailing set constant (Integers, RealNumbers, …) after the unknowns applies as the domain of every unknown, Mathematica-style: \mathrm{Solve}(x^2=4, x, \mathbb{Z}) → [2, -2]. Unknowns that already carry an explicit Element domain keep it. Over an unbounded integer domain a polynomial equation with no integer roots now decides [] (\mathrm{Solve}(2x=3, x, \mathbb{Z}), \mathrm{Solve}(x^2=2, x, \mathbb{Z})) instead of staying unevaluated; non-polynomial equations stay inert rather than risk over-claiming "no solutions" from a partial root set.
  • Inequality side conditions in constraint sets. A relational or boolean predicate bundled in the first argument restricts the solution set instead of being mistaken for an equation: \mathrm{Solve}(\{x^2=4, x>0\}, x) → [2] (previously returned the incorrect []), and a multi-variable condition filters candidate tuples — \mathrm{Solve}(\{a+b=5, a\in\{0,\dots,5\}, b\in\{0,\dots,5\}, a<b\}, \{a,b\}) → [(0, 5), (1, 4), (2, 3)]. Filtering is conservative (a candidate is dropped only when a condition is definitely False) and applies across the symbolic, diophantine-fallback, and enumeration paths. A constraint set containing only predicates solves them directly by enumeration over the domain (\mathrm{Solve}(\{x \equiv 2 \pmod 5, x\in\{1,\dots,20\}\}, x) → [2, 7, 12, 17]).

Mathematica-Style Operator Forms​

  • Iterator triples: \{i, lo, hi\} and \{i, lo, hi, step\}. The Mathematica iterator spec is now recognized in the iterator/bounds slot of Sum, Product, Integrate and D: \mathrm{Sum}(i^2, \{i, 1, 10\}) → 385, \mathrm{Sum}(i, \{i, 0, 10, 2\}) → 30, \mathrm{Integrate}(x^2, \{x, 0, 1\}) → 1/3 (the bounds were previously silently dropped, yielding an indefinite integral), and \mathrm{D}(f, \{x, n\}) is the n-th derivative. Symbolic bounds work (\mathrm{Sum}(k, \{k, 1, n\}) ≡ \sum_{k=1}^n k). The interpretation is strictly positional — a brace set anywhere else keeps its literal set meaning — and operates on held (raw) operands, so the index symbol is scoped like a binder (an i index does not collapse to the imaginary unit).
  • New Table operator, an alias for Tabulate. \mathrm{Table}(i^2, \{i, 1, 5\}) → [1, 4, 9, 16, 25], with general iterator bounds and step (\mathrm{Table}(i, \{i, 0, 10, 2\}) → [0, 2, 4, 6, 8, 10]) and multiple iterator specs for nested dimensions (\mathrm{Table}(i j, \{i, 1, 2\}, \{j, 1, 3\}) → [[1,2,3],[2,4,6]], first spec outermost). \{v, 1, n\} specs canonicalize directly to Tabulate; general bounds map to Map over Range. Tabulate also gained the table search keyword so ce.searchDefinitions('table') finds it.
  • \mathrm{D}(f, x) differentiation. Applied to an argument list, \mathrm{D} / \operatorname{D} is the derivative operator: \mathrm{D}(x^3, x) → 3x^2, \mathrm{D}(x^2 y, x, y) takes sequential partials. The bare forms keep their previous meanings (\mathrm{D} is the upright-D glyph symbol; \operatorname{D} remains usable as a pipeline stage, x^2 \rhd \operatorname{D} → 2x).
  • Limit(f, x \to x_0) rule-arrow form. \mathrm{Limit}(\frac{\sin x}{x}, x\to 0) → 1, equivalent to \lim_{x\to 0}. One-sided arrows carry the direction: \mathrm{Limit}(\frac{1}{x}, x\to 0^+) → +∞.
  • Simplify(expr, assumptions). An optional second argument supplies one or more boolean assumptions (a bare predicate, or a List/And of them) that hold only for the duration of the simplification: \mathrm{Simplify}(\sqrt{x^2}, x>0) → x, \mathrm{Simplify}(|x|, x<0) → -x.
  • New ReplaceAll operator. \mathrm{ReplaceAll}(x^2+x, x\to 2) → 6 (Mathematica expr /. rules). Rules are lhs \to rhs (or Rule(lhs, rhs)), given as extra arguments or bundled in a set/list: \mathrm{ReplaceAll}(x+y, \{x\to 1, y\to 2\}) → 3. Symbol rules are applied simultaneously in a single pass; non-symbol left-hand sides use the pattern-rule machinery. The result is evaluated after substitution.
  • Tuple membership distributes: (a,b) \in \mathbb{Z}. Membership of a tuple of symbols in a scalar (number-element) collection now distributes to a conjunction — Element(a, Integers) ∧ Element(b, Integers) — instead of evaluating to False. Value tuples against product sets are unaffected.

LaTeX Parsing​

  • One-sided limits: \lim_{x\to 0^+} and \lim_{x\to 0^-}. The ^+/^- direction marker on a limit point was previously captured by the generic superscript entries as PseudoInverse(0) / Superminus(0), making every one-sided limit unevaluatable. The marker now maps to Limit's direction operand (["Limit", f, 0, 1] / …, -1]), which the limit evaluator already supported: \lim_{x\to 0^+} \frac{1}{x} → +∞, \lim_{x\to 0^-} \frac{1}{x} → -∞, \lim_{x\to 0^+} \ln x → -∞. Directions serialize back as ^{+}/^{-} (round-trip), symbolic points (\lim_{x\to a^+}) keep a correct representation, and superscript +/- everywhere else (A^+ pseudoinverse, 3^- signed value) is unaffected.
  • \mapsto lambda bodies extend through comparisons. n \mapsto n > 102 now parses as n \mapsto (n > 102) — previously the body closed at the comparison, mis-parsing as (n \mapsto n) > 102, which made unparenthesized predicates like \mathrm{Filter}(\mathrm{Range}(100,105), n \mapsto n > 102) fail. The body now extends through comparisons and logical connectives (n \mapsto n > 2 \wedge n < 5), stopping at the comma/sequence level, so a lambda in an argument list still does not swallow the following argument.
  • Ellipsis ranges in set braces. \{1,\dots,9\} now parses to ["Range", 1, 9], matching the existing bracket form \lbrack1,\dots,9\rbrack; the stepped form \{0, 2, \dots, 10\} yields ["Range", 0, 10, 2]. Previously the ellipsis was kept as a literal placeholder element (["Set", 1, "ContinuationPlaceholder", 9]), which made a \in \{1,\dots,9\} unusable as a domain. Enumerated sets of non-numeric or non-progression elements (\{a, b, c\}, \{1, 2, 3\}) are unaffected.

Performance​

  • Interpret no longer spends ~13 s rejecting a non-polynomial sequence. Interpreting a continuation such as 1 + 1 + 2 + 3 + 5 + 8 + \dots + 55 (Fibonacci) first tries the polynomial recognizer, which fits a degree-5 interpolant through the samples and searches for the index where it reaches the anchor. That interpolant has a negative leading coefficient, so it eventually decreases — but the search's overshoot test used the sample trend ("increasing"), which never fired, so it ground through all 100 000 candidate indices before falling through to the recurrence recognizer. The search now stops on the interpolant's local trend (a polynomial is eventually monotonic, so once it is past the anchor and still diverging it cannot return), cutting this interpretation from ~13 s to a few milliseconds. Legitimate polynomial sums (triangular numbers, squares) are unaffected.

Calculus​

  • Improper integrals of polynomial × exp-decay no longer return NaN. A definite integral to ±∞ whose antiderivative carries a term like y^2 e^{-y} was evaluated at the infinite bound by naive substitution, producing an ∞·0 indeterminate that collapsed to NaN — so \int_0^\infty y^2 e^{-y}\,dy (which is \Gamma(3) = 2) returned NaN. Such an endpoint is now resolved as the limit \lim_{y\to\infty} F(y) (exponential decay dominates polynomial growth), giving the exact closed form (2); with the Rubi integration rules loaded the same fix closes the χ²-tail \int_x^\infty y^{3/2} e^{-y/2}\,dy to 3\sqrt{2\pi} - F(x). An endpoint that still cannot be resolved keeps the integral inert (so N() quadrature applies) rather than leaking NaN.
  • Limit at infinity resolves Erf/Erfc and \sqrt{}/\sqrt[n]{}. \lim_{x\to\infty}\operatorname{erf}(x) = 1, \lim_{x\to-\infty}\operatorname{erf}(x) = -1, \operatorname{erfc} saturating to 0/2, and \lim_{x\to\infty}\sqrt{x} = +\infty (likewise \sqrt[n]{x}) were previously left unevaluated. Besides being correct in their own right, these fill the gaps behind the improper-integral endpoints above — an antiderivative's \operatorname{erf}(\sqrt{y}) term needs both.
  • The upper incomplete gamma reduces at infinity: \Gamma(s, +\infty) = 0. The tail \int_{+\infty}^\infty t^{s-1} e^{-t}\,dt vanishes for any finite s (the e^{-t} factor dominates), so \Gamma(s, \infty) — including symbolic s such as \Gamma(\frac{k}{2}, \infty) — now evaluates to 0 instead of staying inert (a provably infinite first argument stays symbolic). This closes the free-parameter χ²-tail antiderivative \int_x^\infty y^{\frac{k}{2}-1} e^{-\frac{y}{2}}\,dy to a form free of the leftover \Gamma(\cdot, \infty) term.
  • A rational integrand with fully symbolic coefficients no longer hangs. \int_0^x \frac{u-a}{b_2 u^2 + b_1 u + b_0}\,du spun for ~109 s (ignoring a 3 s timeLimit) inside the polynomial-GCD used to cancel common factors: its Euclidean loop divided by a symbolic constant, which produced a spurious nonzero constant remainder that never tested as zero, so the loop iterated forever building ever-larger coefficient expressions. A nonzero constant remainder now correctly resolves the GCD to 1 (coprime over the coefficient field), and the loop carries a deadline checkpoint as a backstop. The integral closes in ~200 ms (to an ArcTanh/Ln form with the Rubi rules loaded); polynomialGCD of coprime symbolic polynomials returns 1 instead of spinning.

Compilation​

  • Compiled definite integrals now resolve symbolically before falling back to quadrature. compile() of an expression containing a definite Integrate first attempts a closed form (the same antiderivative machinery evaluate() uses); if one is found, the generated code is straight-line arithmetic rather than a per-call numerical integration. A plotted \int_0^x 0.1\sqrt{1+t^2}\,dt compiles to its closed form 0.05\,(x\sqrt{1+x^2} + \operatorname{arsinh} x), evaluated in microseconds per sample (exact and deterministic) instead of ~150 ms/sample of quadrature. The symbolic attempt is bounded by ce.timeLimit (default 2 s), so a hard integrand degrades to quadrature rather than stalling compilation, and it is skipped when the integral references a symbol supplied through the vars option (which must stay a live runtime input, not be folded to a constant).
  • The compiled quadrature fallback is now deterministic adaptive Gauss–Kronrod (GK15), not Monte-Carlo. An integral that does not resolve symbolically is estimated with adaptive Gauss–Kronrod quadrature: near machine precision on smooth integrands, microseconds-to-milliseconds per call, and — unlike the previous 10⁷-sample Monte-Carlo estimator (~1e-4 error, a different value on every call, ~150 ms/call) — the same value on every call. Infinite bounds are handled by a smooth variable transform. Monte-Carlo remains an automatic fallback when the adaptive rule does not converge, and can be forced with the new compile(expr, { quadrature: 'monte-carlo' }) option ('adaptive' is the default).

0.78.1 2026-07-14​

Parse Diagnostics​

  • juxtaposition-as-multiply now covers unit-lexed and letter-run application shapes. Two application-shaped sources that produced no diagnostic on 0.78.0 are now reported: a symbol lexed as a unit applied to a group (\mathrm{N}(2), where N reads as the newton unit) fires with detail.name set to the source symbol and a new additive detail: { lexedAs: "unit" } hint; and a letter-run applied to a group (divisors(60), which segments into d·i·v·i·s·o·r·s) fires a single diagnostic whose name is the joined run ("divisors") and whose span covers the full divisors(60) source shape. Run reconstruction stops at numbers and multi-character commands (2x(3) reports x; \pi r(2) reports r).

0.78.0 2026-07-14​

Parse Diagnostics​

  • New opt-in diagnostics parse option. ce.parse(latex, { diagnostics: true }) attaches a parseDiagnostics array to the top-level result, flagging charitable parse decisions that are usually errors in machine-generated LaTeX (LLM output, OCR). Four codes are reported: undeclared-symbol (a symbol reference with no declaration — detail: { name, type }), juxtaposition-as-multiply (a symbol immediately followed by a delimited group (…) or a matrix environment read as multiplication — detail: { name, declaredAs }), comment-discarded (an unescaped % dropped input — detail: { discardedLength }), and recovered (trailing noise silently skipped by non-strict error recovery). Each carries a start/end source span: undeclared-symbol and juxtaposition-as-multiply are offsets into CE's normalized LaTeX, while comment-discarded (and best-effort recovered) use original-input coordinates. The feature is purely additive — enabling it never changes the parse output — and works under { canonical: false }. parseDiagnostics is present (a possibly-empty array) only on the result of a diagnostics: true parse, and undefined otherwise.

0.77.1 2026-07-13​

Breaking Changes​

  • f'(x) now parses to ["Apply", ["Derivative", "f", 1], x] instead of ["D", ["f", x], x]. Prime notation on an applied function denotes the derivative function evaluated at the argument — Lagrange semantics, as in Mathematica and Desmos — not the derivative of the applied expression with respect to an inferred variable. The previous representation produced silently wrong results whenever the argument was not a bare variable: f'(2) evaluated to 0 instead of f' evaluated at 2 (e.g. 6 for f(x) := x^2+2x+1), and f'(2x) picked up a spurious chain-rule factor (8x+4 instead of 4x+2). Higher orders (f''(2) → 2) and the f^{(n)}(x) superscript form follow the same rule. Serialization is unchanged (f^{\prime}(x)), so LaTeX round-trips are unaffected; only consumers pattern-matching the MathJSON parse shape need updating.

Resolved Issues​

  • Symbolic derivatives of declared-then-assigned functions (0.77.0 regression). Declaring a function symbol before assigning its body — ce.declare("f", "function") (or with an explicit signature such as "(number) -> number") followed by f(x) := x^2 + 2x + 1 — left the function's derivative inert: f'(x) evaluated to itself instead of 2x + 2, and ["D", "f", "x"] evaluated to 0, even though direct calls like f(2) worked. The declared-signature reconciliation introduced in 0.77.0 keeps the assigned function literal in the symbol's value definition (preserving the declared signature) instead of converting it to an operator definition, and the symbolic differentiation path only expanded function bodies from operator definitions. Differentiation now expands user-defined function bodies from both definition shapes.

0.77.0 2026-07-13​

Pattern Matching​

  • New Match operator for structural pattern matching. ["Match", subject, ["MatchCase", pattern, body], …] selects the first case whose pattern matches the structure of the subject and applies its body to the captured values: ["Match", ["List", 3, 4], ["MatchCase", ["List", "_a", "_b"], ["Add", "a", "b"]]] → 7. Cases may carry a guard (["MatchCase", pattern, guard, body]); ["Pin", expr] matches the value of an expression (a constant like Pi, or the current value of a variable); ["Alternatives", p1, p2, …] shares one body among several binding-free patterns. Unlike Which, which stays unevaluated while a condition is undecidable, Match always decides — a symbolic subject falls through to a wildcard case. No matching case yields an ["Error", "'match-no-case'"] value.
  • Cortex: match expression. The reserved match keyword is now a full pattern-matching expression: match x { 0 => "zero"; 1 | 2 | == Pi => "small"; [first, ...rest] => first; n if n > 3 => n; _ => "other" }. Bare identifiers in a pattern always bind (a non-final catch-all like Pi => … is a parse error suggesting == Pi to match the constant); == expr pins a value; | gives or-alternatives; […], (…) and {key -> pat} destructure lists, tuples and dictionaries (open matching); n: integer adds a type guard; ...rest captures the tail.
  • Constant-time dispatch and compilation. Matches over constant cases dispatch through a cached table instead of the general pattern matcher, and fixed-shape destructuring compiles to direct positional checks. compile() emits comparison chains or a JavaScript switch for constant cases and destructuring closures for fixed shapes; symbolic patterns (e.g. a + b) fail closed with a clear error rather than producing incorrect code.

Typed Function Literals​

  • Function literals can declare parameter and return types. A Function parameter may be annotated — ["Typed", "x", "'integer'"] — and the body may carry a return-type ascription, so ["Function", ["Add", "x", 1], ["Typed", "x", "'integer'"]] now has type (x: integer) -> integer instead of (unknown) -> number. Annotations feed body type inference, and in strict mode arguments are checked at application: applying 2.5 to an integer parameter yields an incompatible-type error instead of silently computing. Partial application preserves the remaining annotations and the return type. Assigning an annotated literal to a symbol gives it the full typed signature — including the declared return type, which is an ascription (authoritative, like a TypeScript annotation) rather than a check against inference. Untyped literals are unchanged.
  • New Typed operator for type ascription. ["Typed", expr, type] asserts the type of an expression for the type system and is transparent at evaluation. It accepts a type string ("'integer'") or a type-name symbol (integer). LaTeX serialization drops annotations (no typed-parameter notation in v1); MathJSON round-trips them.
  • Cortex: typed function definitions are enforced end to end. f(x: integer) -> real = x + 1, function g(n: integer) -> integer {…}, and the anonymous form (x: integer) |-> x + 1 (new grammar) all parse to native annotated literals; mistyped calls error, declared return types are carried, and serializeCortex reconstructs the typed syntax faithfully. Recursive typed definitions work (fact(n: integer) -> integer = if n <= 1 {1} else {n * fact(n - 1)}).

Programming and Collections​

  • Closures capture per-call state. A zero-parameter closure returned from a factory function now captures its own instance of the factory's local variables, so separate invocations no longer share mutable state. For example, two counters built from the same makeCounter() factory advance independently. Parameterized factories already behaved this way; the fix extends the same per-call scope instantiation to nullary functions.
  • Lazy collection operations iterate eager sources. A lazy operation such as Map or Filter applied to a collection that only materializes on evaluation (e.g. UnicodeScalars(s), Characters(s)) now iterates its elements instead of behaving as empty. For example, StringFrom(Map(UnicodeScalars(s), c -> c + 1), "unicode-scalars") now produces the shifted string rather than "".

Symbolic Computation​

  • Arithmetic and function application thread through conditional values. A restricted value When(v, cond) and a piecewise Which(c_1, v_1, …) now flow through scalar operations instead of staying inert: sin(When(x, x > 0)) → When(sin(x), x > 0), guards combining by conjunction (When(x, x > 0) · When(y, y < 1) → When(x·y, x > 0 ∧ y < 1)), and Which(x > 0, 1, x < 0, -1) + 2 → Which(x > 0, 3, x < 0, 1). Logic operators are excluded (so And(When(A, g), False) still short-circuits to False), and piecewise products above 16 combined branches stay unevaluated.
  • Restriction guards survive arithmetic cancellation. Evaluating When(x, c) − When(x, c) previously folded to plain 0, silently discarding the restriction on the (fat) region where c fails; it now yields When(0, c). Similarly 0 · When(x, c) → When(0, c) and When(x, c) / When(x, c) → When(1, c).
  • When respects numeric approximation. When(π, cond).N() with a true condition now numericizes (previously the option was dropped and the value stayed symbolic).
  • DMS angles stay exact. (contributed by yelliver) Degrees-minutes-seconds notation now parses to an exact rational number of degrees instead of a float (9°30' is 19/2°), and Degrees converts any rational — not just integers — to an exact multiple of π, so 5°37'30" simplifies to π/32. Decimal components are recovered exactly when possible (9°30'15.5" → 68431/7200°) and otherwise fall back to floats. Degrees of values beyond 2⁵³ no longer loses precision. In raw (non-canonical) parsing, DMS angles that previously produced a float now produce a Rational. (#321)

Calculus​

  • Exponentials with any linear exponent integrate. ∫e^{−ax}dx with a symbolic a previously stayed unevaluated (only e^{a·x}-shaped exponents were recognized); it now returns −e^{−ax}/a. Any linear exponent works: ∫e^{3−2x}dx, ∫5e^{−x/2}dx.
  • Improper integrals with symbolic parameters return convergence-guarded results. ∫₀^∞ e^(−ax)dx with a free a previously stayed unevaluated; it now returns 1/a {0 < a}. Results that formerly leaked indeterminate endpoint forms are fixed: ∫₀^1 xⁿdx returned an expression containing 0^(n+1), and ∫₁^∞ x^(−s)dx one containing ∞^(1−s); they now return 1/(n+1) {0 < n+1} and 1/(s−1) {1 < s}. Integrals whose endpoint behavior cannot be classified stay unevaluated rather than leaking indeterminates. Numeric-parameter integrals are unchanged.
  • Series expands at algebraic branch points (Puiseux series). A series expansion may now carry fractional powers: Series(√(sin x), x) → √x − x^{5/2}/12 + x^{9/2}/1440 + O(x^{13/2}). This covers √x, 1/√x, x^{3/2}·e^x, Root(g, r) and rational powers g^{p/r} where the base vanishes (or has a pole), as well as compositions: cos(√x), csc(√x) (→ 1/√x + √x/6 + …), and Γ(√x) (→ 1/√x − γ + …).
  • Series expands through logarithmic singularities. Expanding about a zero or pole of a logarithm's argument now yields a log-carrying series: Series(ln(sin x), x) → ln x − x²/6 − x⁴/180 + O(x⁶), and Series(x^x, x) → 1 + x·ln x + x²·ln²x/2 + …. Base-b logarithms (log₂ x, log₁₀ x) expand through the same path. Nested or reciprocal logarithms (ln(ln x), 1/ln x) and essential singularities (e^{1/x}) still stay unevaluated rather than returning a partial expansion.
  • Series of an irrational power no longer returns an invalid expansion. Series(x^π, x) previously produced coefficients containing unresolved 0^{π−1}-style indeterminates; it now stays unevaluated at 0 (the expansion about a regular point, e.g. x^π about 2, is unchanged).
  • Logarithmic asymptotic expansions at ±∞. A log-carrying expansion at +∞ now resolves back to x instead of deferring: Series(ln x, x, +∞) → ln x, and Series(ln(x²+x), x, +∞) → 2 ln x + 1/x − 1/(2x²) + …. At −∞ (a logarithm of a negative quantity) such expansions still stay unevaluated.
  • Stirling asymptotics for the log-gamma. Series(GammaLn(x), x, +∞) (and the parsed ln Γ(x)) now returns Stirling's series x·ln x − x − ½ln x + ½ln(2π) + 1/(12x) − 1/(360x³) + O(1/x⁵). The series is asymptotic (divergent), so the BigO is placed at the true remainder order. Series(Γ(x), x, +∞) — the exponential of a trans-series — still defers.
  • GammaLn evaluates to +∞ at the poles of Γ (the non-positive integers, where ln|Γ| → +∞ — as in Mathematica's LogGamma and SymPy's loggamma); it previously stayed inert there. Consequently Series(GammaLn(x), x) at such a pole now returns the log-aware expansion −ln x − γ·x + (π²/12)·x² + … (matching the parsed ln Γ(x)) instead of an invalid expansion with inert GammaLn(0), Digamma(0), … coefficients.
  • Provably-exact expansions drop the BigO remainder. When the truncated sum is symbolically equal to the whole function, Series now returns it without a remainder term: Series(√x, x) → √x, Series(ln x, x) → ln x, Series(x/(x−2)², x, 2) → 2(x−2)⁻² + (x−2)⁻¹. Genuinely-truncated series (1/sin x, Γ(x) at 0, ζ at 1) keep their BigO.

Sums and Products​

  • Geometric series closed form. Σ_{n=0}^∞ rⁿ now evaluates: exactly for a numeric ratio (Σ(1/2)ⁿ → 2, Σ(1/√2)ⁿ → 2 + √2), and with its convergence condition for a symbolic ratio (Σxⁿ → 1/(1−x) {|x| < 1}). Constant multiples and integer start indices are handled (Σ_{n=2}^∞ xⁿ → x²/(1−x) {|x| < 1}); divergent numeric ratios stay unevaluated.

Solving Equations​

  • Radical equations with a symbolic right-hand side return guarded roots. Solve(√(x+3) = a, x) previously returned []; it now returns a² − 3 {0 <= a} (a square root is non-negative, so a real solution exists only for a ≥ 0). Substituting a concrete value resolves the guard: a = 2 gives 1, a = −2 gives Undefined. Numeric right-hand sides are unchanged.
  • Trigonometric and hyperbolic equations with symbolic coefficients record their validity condition. Solve(sin(x) = a, x) previously returned arcsin(a) and π − arcsin(a) unconditionally — wrong whenever |a| > 1. Roots now carry their domain-of-validity guard — a When restriction, displayed arcsin(a) {|a| <= 1} (LaTeX \arcsin(a)\left\{|a|\le 1\right\}). The guard resolves as soon as it is decidable: substituting a = 1/2 collapses the root to π/6, while a = 3 yields Undefined, and a guard known false at solve time prunes the root (down to []). Numeric-coefficient equations are unchanged. The same applies to cos (|ratio| ≤ 1), cosh (ratio ≥ 1), and tanh (|ratio| < 1) equations.
  • Solve of an inequality stays inert instead of returning an empty list. Solve(x^2 < 4, x) previously returned [], which reads as "no solutions"; univariate inequality solving is unsupported, so the expression now stays unevaluated. Solving equations is unchanged.

Compilation​

  • The deprecated interval-glsl compilation target has been removed. GPU interval evaluation only pays off when the entire pipeline stays on the GPU, and the target could not compile relational operators, so it could not host restriction conditions. Use interval-js (CPU interval arithmetic) or the scalar glsl/wgsl targets instead. The IntervalGLSLTarget export from @cortex-js/compute-engine/compile is gone, and compile(expr, { to: 'interval-glsl' }) now throws an unregistered-target error.

Parsing​

  • ce.parse() no longer blows up on repeated \command[opt]{} groups. Adjacent bracketed groups — a garbage \begin{tikzpicture}katu[scale=0.6]{}… run, or even plain index notation a[6]a[6]… — triggered exponential-time backtracking, so a few-hundred-character string could hang the parser for tens of seconds (and timeLimit, which bounds evaluation, did not stop it). The reversed-bracket ISO interval notation ]a, b[ opens on ], the same token that closes an index bracket, so every stray ] speculatively re-parsed the rest of the input as an interval body, nesting exponentially. Parsing is now polynomial; the interval (]0, 1[) and indexing (a[6]) notations are unchanged.
  • \operatorname{nPr}(n, r) now has a definition. Matching \operatorname{nCr} (the binomial coefficient), the Desmos permutation-count notation lowers to Choose(n, r)·r! (= n!/(n−r)!), so nPr(5, 2) evaluates to 20. Previously it parsed to an inert symbol and stayed symbolic under N(), silently producing NaN in a compiled function.

Arithmetic​

  • Round accepts an optional precision argument. Round(x, n) rounds x to n decimal places — Round(2.567, 2) → 2.57, Round(1234.5, −2) → 1200 — matching the Desmos/spreadsheet round(x, n) convention. Previously the second argument produced an unexpected-argument error. The single-argument round-to-integer form is unchanged, and the two-argument form compiles on the javascript and interval-js targets.
  • New Rationalize operator for rational approximation. Rationalize(x) approximates a real number by a rational at full working precision (like single-argument Rational); with a tolerance, Rationalize(x, tolerance) returns the rational with the smallest denominator within the bound — Rationalize(√3, 1/500) → 26/15, Rationalize(π, 1/100) → 22/7 — a continued-fraction convergent cut.

Number Theory​

  • New StirlingS1 operator: signed Stirling numbers of the first kind. StirlingS1(n, m) is the coefficient of xᵐ in the falling factorial x(x−1)…(x−n+1); its absolute value counts the permutations of n elements with m disjoint cycles — StirlingS1(5, 2) → −50. Complements the existing Stirling (second kind).

Relational Operators​

  • Equal and NotEqual broadcast over a named list operand. With R = [1, 2, 3], x² + y² = R² now broadcasts to a list of three element-wise equations — matching the literal-list form (x² + y² = [1, 2, 3]) and the inequality operators (<, ≤, …), which already broadcast. Previously the named form collapsed to a single False. Whole-list equality, where two or more operands are collections ([1, 2] = [1, 2]), still returns a single boolean.

0.76.0 2026-07-11​

Programming and Collections​

  • Dictionary lookups have the value's type. At(dict, key) — and thus d["a"] in Cortex — was statically typed as the key-value pair (tuple<string, T>), so using a lookup directly in arithmetic (d["a"] + 10) failed with an incompatible-type error. It is now typed as the value; a record indexed by a literal string gets that field's precise type.

  • Reduce/Fold honor the exactness contract. The compiled floating-point fast path no longer runs under plain evaluate(): exact operands fold exactly (Fold((a, k) ↦ a + 1/k, 0, Range(1, 5)) → 137/60 instead of 2.2833…). The fast path is reserved for .N() and already-inexact inputs. Complex-valued folds no longer silently drop imaginary parts (Product(Map(Range(1, 3), k ↦ k + i)) → 10i, previously 6).

  • Map infers its element type from the mapped function. The result of Map(Range(1, 3), k ↦ k + i) was typed with the source element type (integer); it now reflects the lambda's result type, so downstream operations dispatch correctly.

  • Elementwise broadcasting is uniform across lazy and eager collections. A finite lazy Range now broadcasts like an eager List in tuple products: with R = Range(-2, 2), R·(2,3) yields a list of five scaled points instead of distributing the range inside the tuple components. A scalar also folds into a collection produced by an inner broadcast step: L^2 - 2 evaluates to [-1, 2, 7] instead of the unevaluated Add(-2, [1, 4, 9]), and evaluation is idempotent again on these shapes. Infinite or unknown-length ranges stay symbolic rather than transposing.

  • Scalar operations accept lazy collection operands during validation. Mod([0,\ldots,kN], N) with a symbolic bound produced an incompatible-type error at canonicalization even though the eager-list form broadcast fine; the argument validator now recognizes parametrized indexed_collection<T> wherever broadcasting applies. Declared types follow the values: broadcast results type list<…> (previously a scalar-or-list union, or a scalar type for symbolic-length ranges).

  • Big integers survive numeric list literals. A list literal promoted to a tensor stored oversized integers in float64 and truncated them ([100!] lost digits, breaking exact iterative algorithms such as a Fibonacci pair accumulator). Integers beyond the float-safe range now keep their exact representation.

  • StringFrom joins collections. With a list argument and the "unicode-scalars", "utf-8", or "utf-16" format, StringFrom([100, 101, 102], "unicode-scalars") returns "def"; it previously broadcast element-wise and returned a list of one-character strings.

  • One-step function definitions bind inside function bodies (Cortex). function outer(n) { sq(m) = m * m; sq(n) } left sq(n) unevaluated because the call site resolved a value placeholder while the runtime created an operator definition; function application now falls back to the operator definition. The example-program suite grew by 18 programs covering control flow, number theory, complex numbers, linear algebra, and exact sums (mirrored in the Cortex documentation).

  • Cortex: do { … } block expressions and zero-parameter lambdas. A statement block can now appear in any expression position with the explicit do prefix — its value is its final statement — so multi-statement closure bodies are expressible: x |-> do { let t = x * x; t + 1 }. Set literals are unchanged (x |-> {1, 2} still returns a set). Zero-parameter lambdas (() |-> …) now parse and apply, enabling the stateful makeCounter-style closure documented in the examples.

  • Cortex: a named inner function escapes its scope as a value. function make() { helper(x) = x + 1; helper } now returns a callable first-class function — previously the returned symbol went inert once the call frame popped. Captured locals and parameters of the enclosing call are preserved; returned |-> lambdas are unchanged.

  • Cortex: lowercase true/false, ASCII .. ranges, and StringJoin over a list. true/false are now input aliases for True/False (and reserved as binding names); 1..n is a range (for k in 1..5), equivalent to the existing ‥, without disturbing decimal literals like 1.5; and StringJoin accepts a single collection of strings — StringJoin(Reverse(Characters("hello"))) → "olleh".

  • Cortex: "did you mean" warnings for near-miss function names. Calling an undeclared function whose name is close to a library operator no longer fails silently symbolic: executeCortex emits a warning diagnostic with the suggestion (Quartile(xs) → did you mean Quartiles?). The match is conservative (case-insensitive, singular/plural, small edit distance, unique prefix) and the diagnostic only fires when a suggestion exists, so intentionally symbolic calls are never flagged; the returned value is unchanged. The matcher is also available as ce.suggestOperatorName(name). Arg is now an alias for Argument.

Compilation​

  • Calls to user-defined functions compile. After f(x) := e^{-x^2/2}, compiling f(2) — or any expression referencing f — emits the definition as a named local function instead of throwing Unknown operator `f` . Nested user functions are emitted in dependency order; recursive definitions fail closed with an explanatory error. This also removes a silent interpreted fallback in numeric integration: definite integrals of user-defined functions now run compiled quadrature (10⁷ samples instead of 10⁴ — comparable wall time, ~30× tighter error estimate). Applies to the javascript and interval-js targets.

  • Collection-valued conditions fail closed instead of compiling wrong code. Equal/NotEqual over a collection-typed operand, and If/Which/When with a collection-typed condition, previously compiled with success: true and returned null or the wrong branch at run time; they now throw an explanatory compile-time error. Interpreted evaluation is unchanged: comparisons broadcast elementwise, conditionals require a scalar boolean.

  • Reduce, Length, and At compile on the javascript target. Reduce(xs, Add|Multiply|Min|Max, init?) compiles to a loop; Length returns the element count; At follows the interpreter's 1-based, negative-from-end indexing (out-of-range yields NaN).

  • GLSL: Length no longer collides with the length() builtin. CE's Length (element count) compiled to GLSL length() — the Euclidean norm — reporting success while computing the wrong value, or emitting invalid GLSL for lists longer than four. length() is now emitted for Norm; collection Length fails closed on the GPU targets.

  • GLSL/WGSL: literal integer powers are sign-correct on the GPU. x^3 compiled to pow(x, 3.0), which the GLSL specification leaves undefined for negative bases — real GPUs returned pow(-2, 3) = +8, silently flipping the sign of odd-power terms. Small integer exponents now emit repeated multiplication; larger and compound-base cases use a sign-preserving _gpu_powi preamble helper; negative integer exponents wrap the reciprocal. Fractional exponents still emit pow.

  • The interval-glsl target is deprecated. GPU interval evaluation only pays off when the entire pipeline stays on the GPU, and the target cannot compile relational operators, so it cannot host restriction conditions. A once-per-process warning now points to interval-js and the scalar glsl/wgsl targets. It will be removed in a future release.

Numeric Evaluation​

  • Numeric infinite products use tail acceleration. .N() now Richardson-extrapolates the logarithms of positive real factors instead of returning a plain finite truncation. This gives accurate values for products such as Product(1 + 1/k², k, 1, +∞) = sinh(π)/π. Products with non-real, non-positive, or non-convergent factors decline the accelerator and retain the existing bounded-truncation behavior.

  • Machine Gamma keeps full relative accuracy through the overflow edge. Positive real arguments now use a balanced recurrence from the Lanczos core instead of reconstructing large values from exp(gammaln(z)), which preserves about 15-16 digits up to the IEEE-754 limit near Gamma(171.624).

Linear Algebra​

  • Matrix operators infer fresh symbolic operands from context. An expression such as \det(A+2B) no longer fails because bottom-up arithmetic canonicalization provisionally typed A and B as numbers before Determinant required a matrix. Validation now repairs only inferences made while constructing the current expression and canonicalizes the argument once more with matrix context. Explicit declarations and inferences from earlier expressions are never overwritten, and ambiguous products remain unchanged rather than guessing which factor is the matrix.

Symbolic Computation​

  • Exact cube-root arithmetic handles more algebraic forms. Positive perfect-power bases with rational exponents normalize to a common base and extract their integer part (4^(2/3) → 2·2^(1/3)), allowing compatible cube-root powers to combine exactly. Real nested cube roots of the form ∛(a+b√c) are denested when exact integer conjugate identities prove a result; in particular, ∛(90+34√7) → 3+√7 (and the conjugate form with minus signs). The Wester-28 cube-root identity now simplifies directly to exact zero (no explicit Expand required) and numericizes without NaN.

  • Infinite p-series support positive-integer lower bounds beyond 1. Sum(k^(-s), k, a, +∞) now returns Zeta(s) − Sum(k^(-s), k, 1, a−1) for exact real s > 1; for example, Sum(1/k², k, 3, +∞) evaluates to π²/6 − 5/4. The existing lower-bound-1 behavior and divergence guards are unchanged.

  • e^{iθ} stays exact for constructible angles. e^{i\pi/3} now evaluates to 1/2 + (√3/2)i instead of a machine float (the exact cosine/sine values were being recombined through float-folding arithmetic). .N() numericizes as before, and the degenerate angles (e^{i\pi} → -1, e^{i\pi/2} → i) are unchanged.

Serialization​

  • AsciiMath prints series in textbook order. Taylor-series terms are serialized in ascending degree and asymptotic series in descending degree, with the BigO remainder last, matching LaTeX output. Canonical expression order and ordinary sums without a BigO term are unchanged.

Parsing​

  • Stepped ellipsis ranges accept negative and symbolic samples. [-9,-6,\ldots,9] now parses to Range(-9, 9, 3) — previously any negative leading sample fell back to a literal list containing a ContinuationPlaceholder that enumerated as NaN. Symbolic stepped forms infer a symbolic step when the samples are numeric multiples of one common symbol ([-3N,-2N,\ldots,3N] → Range(-3N, 3N, N), progression-validated on the coefficients); generic sequence notation ([x_1,x_2,\ldots,x_n]) intentionally still parses as a plain list.

Engine Lifecycle​

  • Popping a scope releases configuration listeners owned by its constants. Constant definitions now retain and invoke the unsubscribe closure returned when they register for precision and angular-unit changes. Local constants from discarded scopes therefore no longer remain reachable for the lifetime of the compute engine.

  • Cancellation errors carry a structured cause. A cap breach reports 'timeout', 'iteration-limit-exceeded', or 'recursion-depth-exceeded' via the exported CancellationCause type. In Cortex, a final-statement breach carries the cause as a second operand on the Error value, and non-final statements emit a dedicated evaluation-canceled diagnostic. Error messages are unchanged, so existing string matching keeps working.

0.75.0 2026-07-11​

Numeric Evaluation​

  • Inverse trigonometric and hyperbolic functions now evaluate outside their real domain. .N() returns the complex principal value when no real value exists, for example \arcsin(2) → 1.571 − 1.317i and \operatorname{arcosh}(0.5) → 1.047i. Complex arguments such as \operatorname{arsinh}(1+i) are also supported. Exact arguments remain symbolic with evaluate(). Incorrect complex values from Arcoth on part of its branch cut and from Arsech have also been fixed.
  • Products and quotients of square roots no longer throw on large radicands. Evaluating an expression such as \sqrt{1234}\cdot\sqrt{1235}, whose combined radicand (1234·1235) exceeds the exact-radical limit, no longer raises an internal "Unexpected value for radical part" error. Any perfect-square factor is extracted (√(k²·r) = k·√r), keeping the result exact when the square-free part is small enough and otherwise returning the numeric value.

Solving Equations​

  • Solve handles systems of equations. Solve([eq1, eq2, …], [x, y, …]) returns each solution as a tuple of values in the order of the variable list: Solve([x + y = 3, x - y = 1], [x, y]) → [(2, 1)], and a nonlinear system such as [x^2 + y^2 = 25, x + y = 7] returns both solutions [(3, 4), (4, 3)]. Linear systems solve exactly (rational values), an underdetermined system returns a parametric tuple ([(5 - y, y)] for x + y = 5), and a system the solver cannot decide stays unevaluated. This matches the tuple shape already used when solving over explicit domains.
  • solve() correctly rejects trigonometric and hyperbolic equations with no real solutions. Equations such as \sin x = 2, \tanh x = 2, and \cosh x = 1/2 now return no solutions. Symbolic equations and equations with complex polynomial roots are unaffected.

Integration (opt-in Rubi rules)​

  • More integrals containing binomial radicals are supported. This includes mixed even- and odd-power numerators such as \int\frac{c+dx}{\sqrt{-a-bx^4}}dx, \int\frac{x^2(c+dx+ex^2+fx^3)}{(a+bx^4)^{3/2}}dx, and Laurent variants with denominators of the form (a+b·x^n)^{3/2}.
  • More integrals that are algebraic in a hyperbolic function are supported. Half-integer powers of hyperbolic expressions such as \int\coth(x)(a+b\sinh^2 x)^{3/2}\,dx, \int\coth^2 x\sqrt{a+b\tanh^2 x}\,dx, \int\frac{\operatorname{csch} x}{(a+b\sinh^2 x)^{3/2}}\,dx, and \int\sqrt{a+b\operatorname{csch}^2 x}\,dx now close in elementary form via a hyperbolic substitution. This also fixes a wrong-answer case, \int\frac{\sqrt{\coth(a+b\ln(cx^n))}}{x}\,dx.
  • More integrals that are rational in a hyperbolic function are supported. Ratios of hyperbolic functions with a squared-or-higher power, such as \int\frac{\tanh^2 x}{a+b\tanh x}\,dx, \int\frac{\tanh x}{a+b\sinh x}\,dx, and \int(a+b\tanh^2 x)^3\tanh^4 x\,dx, now close in elementary form.

Library and Definitions​

  • Definitions can be searched by concept. ce.searchDefinitions(query) returns a ranked list of matching { id, kind } entries. It searches names, descriptions, synonyms (for example, average finds Mean), and LaTeX commands (for example, \gcd finds GCD). Use the optional limit argument to control the number of results (default: 10). Custom definitions declared with ce.declare() can provide an optional keywords list, and returned IDs can be passed to ce.lookupDefinition().
  • Definition descriptions filled in. About 80 operators and constants that lacked a description now have one (the trigonometric family, logic and relational operators, collection primitives such as List, Range, and Fold, and constants like Pi and ExponentialE), and Sec's description was corrected (secant is the reciprocal, not the inverse, of cosine). These surface in ce.searchDefinitions() and ce.lookupDefinition().
  • New builtin operators are available: Pipe(x, f), Append(collection, element), Fold(f, init, collection), StringJoin(s1, s2, …), and RandomInteger(n) or RandomInteger(a, b). Pipe enables evaluation of x |> f; StringJoin enables the existing <> notation; and RandomInteger uses inclusive bounds and honors the seeded random-number generator.

Calculus​

  • Limit accepts the explicit-variable form. Limit(expr, var, point) — e.g. ["Limit", ["Divide", ["Sin", "x"], "x"], "x", 0] → 1 — now canonicalizes to the same internal form as Limit(expr, point), matching the convention Series already uses. The (function, point, direction) reading is preserved when the middle operand is not a free variable of the expression.

Linear Algebra​

  • Inverse of an exact matrix is exact. An integer or rational matrix now inverts over the rationals — Inverse([[2,1],[1,3]]) → [[3/5,-1/5],[-1/5,2/5]] instead of floats — with .N() and inexact matrices using the numeric path as before.
  • New LinearSolve(A, b) operator solves the linear system A·x = b, exactly for exact input. Composed forms like Dot(Inverse(A), b) also work now: Inverse's result is typed as a matrix, so matrix operators accept it.

Units and Quantities​

  • Quantity accepts a string unit. Quantity(30, "km/h") parses the string through the same unit grammar as the LaTeX path and canonicalizes identically to the symbolic form; a malformed unit string produces a clear error instead of a partially-built expression.

Programming and Collections​

  • Recursive functions can be defined without a separate declaration. A function assignment that refers to itself, such as ce.parse('f(n) := n \\cdot f(n-1)'), now works directly.
  • N() numericizes through user-defined functions. For f(x) := x/3, N(f(2)) now returns 0.666… instead of the exact 2/3; plain evaluate() still returns the exact form, and the approximation is applied within the function's own scope, preserving lexical scoping.
  • Keys(dict) and Values(dict) evaluate, returning the keys (as strings) and values in the dictionary's iteration order — the same order for kv in dict yields.
  • Intersection accepts lists (any finite collection), deduplicating into a Set; Union already did.
  • A 2-element MathJSON List in a set operation is a collection, not an interval. ["Intersection", ["List",1,2], ["List",2,3]] (Cortex: Intersection([1,2], [2,3])) now intersects the two-element collections — Set(2) — instead of reading the lists as closed intervals. The interval reading of ambiguous bracket pairs is now applied where it belongs, at the LaTeX boundary: x \in \lbrack 1, 5 \rbrack, (-\infty, 0) \cup (0, \infty), and the subset relations parse to Interval exactly as before, and \setminus now gets the same interval reading (previously \R \setminus (0, 1) kept a raw pair). Unambiguous interval notations ([a, b), ]a, b[, …) are unchanged.
  • Collection equality no longer depends on representation. A computed collection — an Intersection or Union result, a lazy Map, Filter, or Join pipeline, or a symbol assigned a collection — now compares equal to a literal with the same elements: Intersection({1,2,3,4}, {2,3,5}) = {2,3} is True (it was False unless the operand was evaluated first). Sequences compare element-wise in order, sets by membership, and a set is never equal to a sequence. In addition, Equal between two collections now always returns a scalar boolean instead of sometimes broadcasting element-wise ({1,2} = [1,2] returned ["True","True"]); broadcasting still applies to list-vs-scalar comparisons such as L = 4.
  • Intersection of two Filter collections no longer overflows the stack. Membership tests on a lazy Filter recursed without bound; Intersection(Filter(…), Filter(…)) now evaluates normally.
  • Indexing a matrix once returns a correctly typed row. Expressions such as At(At(m, 2), 1) now validate and evaluate correctly for matrices and other rank-2-or-higher collections.
  • List elements and dictionary values are evaluated by evaluate() and .N(). For example, ["List", "y", ["Add", "y", 1]] with y = 7 evaluates to [7, 8], and [1/3].N() is numericized. Lazy collections such as Range, Map, and Filter remain lazily enumerated.
  • Ellipsis lists with symbolic bounds parse to Range. \left[-N,\ldots,N\right] and \left[-3N,\ldots,3N\right] now parse to Range(-N, N) and Range(-3N, 3N), matching the numeric-start forms ([1,\ldots,N]). Previously a symbolic start fell through to a raw List containing a literal ContinuationPlaceholder, which enumerated as NaN. In addition, a Range whose bounds bind looser than the .. operator now serializes with parentheses ((-N)..N) so it round-trips through LaTeX (an unwrapped -N..N reads back as -(N..N)).
  • Using a symbol bound to a symbolic list no longer corrupts builtin definitions. After ce.assign('L_1', ce.parse('\\left[N,2N\\right]')), constructing 2 L_1 — via subs(), ce.box(), or ce.function() — permanently broke the builtin N operator for the lifetime of the engine: every subsequent parse of the token N returned an unexpected-symbol error. Type inference on the list elements no longer overwrites an operator definition with an unsatisfiable (never) type.

Cortex​

  • % and postfix ! operators: a % b is Mod(a, b) (multiplicative precedence) and n! is Factorial(n) (the ! must directly follow its operand; prefix !x is still Not and x != y is still NotEqual).
  • Chained indexing: m[2][1] now works alongside m[2, 1].
  • String escape sequences are processed correctly. "a\tb\nc" now contains a real tab and newline (escapes were previously double-processed in plain and multiline strings; interpolated strings were already correct).
  • The examples suite roughly doubled (src/cortex/docs/examples.md), adding units and uncertainty, calculus, linear systems, dictionaries, sets, closures, seeded randomness, errors-as-values, and string formatting — every example verified by an executable test.

0.74.0 2026-07-10​

This release significantly expands CE's calculus capabilities. Limits, residues, and series now handle many poles of special functions exactly; infinite sums and products gain more closed forms and substantially better numeric convergence; and the optional Rubi integration rules support more integrands, any integration variable, and reliable time limits. Step-by-step explanations now cover integration and systems of equations or inequalities, with clearer traces for simplification.

It also improves exact and symbolic computation throughout the engine. Linear algebra gains exact ranks, null spaces, eigenvectors, matrix square roots, and singular values; assumptions and simplification prove more identities; and several correctness issues involving canonicalization, fractions, symbolic collections, compilation, and LaTeX parsing are fixed. New special-function support, reproducible seeded randomness, and more useful Cortex diagnostics round out the release.

Calculus​

  • Limits and residues at special-function poles evaluate exactly. Expressions at poles of Gamma, Digamma, Trigamma, PolyGamma, and Zeta that previously stayed symbolic now resolve in closed form: \lim_{x\to-1}(x+1)\psi(x) = -1, \lim_{x\to0}(\Gamma(x)-1/x) = -\gamma, \lim_{s\to1}(s-1)\zeta(s) = 1, \operatorname{Res}_{s=1}\Gamma(s)\zeta(s) = 1, \operatorname{Res}_{x=0}\Gamma(x)^2 = -2\gamma, and higher-order poles that previously deferred (\operatorname{Res}_{x=-2} \Gamma(x)/(x+2) = 3/4 - \gamma/2). Deferral behavior is unchanged where no exact expansion exists (branch points, essential singularities, two-sided pole limits).
  • The polygamma family expands, differentiates, and integrates through the ladder. Series now produces correct Laurent expansions of Trigamma and integer-order PolyGamma(m, x) at their poles (previously a spurious regular expansion could be produced), and D knows \psi_1' = \psi^{(2)} and the general d/du\,\psi^{(m)}(u) = \psi^{(m+1)}(u). Series at a Digamma pole is also about 20× faster.
  • Residues at infinity evaluate. Residue(f, x, \infty) — any infinite point names the Riemann-sphere point at infinity — computes -\operatorname{Res}_{s=0} f(1/s)/s^2 through the exact Laurent kernel: \operatorname{Res}_\infty 1/x = -1, \operatorname{Res}_\infty \frac{3x^2+2}{x^3+x} = -3 (the negated sum of the finite residues).
  • Limits at poles resolve to signed infinities. A directional limit at a pole now evaluates to \pm\infty from the exact Laurent data: \lim_{x\to0^+} 1/x = +\infty, \lim_{x\to0^-}\Gamma(x) = -\infty, \lim_{s\to1^\pm}\zeta(s) = \pm\infty, \lim_{x\to0^+}\ln x = -\infty. A two-sided limit resolves only when both sides agree (even pole order): \lim_{x\to0} 1/x^2 = +\infty, \Gamma(x)^2 \to +\infty, \ln(x^2) \to -\infty. Disagreeing two-sided limits (\lim_{x\to0} 1/x, \Gamma, \ln x at their poles) deliberately stay inert — the engine does not produce ComplexInfinity limits.
  • Beta joins the meromorphic pole family. The Laurent kernel expands \operatorname{B}(a,b) through the \Gamma-quotient identity, so residues, limits and Series at Beta poles evaluate: \operatorname{Res}_{x=0} \operatorname{B}(x,3) = 1, \lim_{x\to0} x\cdot\operatorname{B}(x,3) = 1.
  • Numeric limits containing sums now converge instead of hanging. N() respects evaluation limits when probing a Limit at \infty whose body contains a variable-length Sum or Product. Examples that now converge quickly include \lim_{n\to\infty}(\sum_{k=1}^{n} 1/k - \ln n), which evaluates to the Euler–Mascheroni constant, and \lim_{n\to\infty}\frac{4}{n^2}\sum_{k=1}^{n}\sqrt{n^2-k^2}, which evaluates to \pi.
  • Numeric limits with odd-power error terms now converge correctly. This fixes cases such as H_n - \ln n - \gamma \sim 1/2n, which previously returned NaN. Decaying oscillations such as \operatorname{sinc} at -\infty now resolve to 0, while divergent oscillations such as \sin x at \infty still return NaN.

Step-by-Step Explanations​

  • explain('Integrate') traces symbolic integration through the Rubi rule chain. With the opt-in integration rules loaded (loadIntegrationRules(ce) from @cortex-js/compute-engine/integration-rules), ce.parse('\\int x\\sqrt{1+x}\\,dx').explain('Integrate') replays the driver's derivation as whole-expression states — term-by-term splits (integrate.sum), constant factors moved out (integrate.constant-factor), each corpus rule application (a stable rubi:… id with a compact description such as "Apply integration rule 1.1.1.2#19 (Rubi)"), reductions to special functions (integrate.si-ci, integrate.partial-fractions, …), and a closing simplification. A definite integral is presented via the Fundamental Theorem of Calculus: the antiderivative derivation, then the bracket F |_a^b (integrate.fundamental-theorem), the bounds substituted unevaluated (integrate.evaluate-bounds — skipped for improper integrals, where the bracket is a limit), and the value. Symbolic bounds are supported. The result is identical to evaluate(). Without the rules loaded, or when the rules cannot close the integral, a precise error is thrown. (Also fixed: the LaTeX serialization of the two-bound \left. F \right|_a^b EvaluateAt form dropped the upper bound.)

  • explain('solve') traces systems of inequalities and mixed systems. A List/And of linear inequalities in two variables is traced through constraint normalization (solve.system.normalize-inequality), boundary intersection (solve.system.intersect-boundaries), and the feasible vertices (solve.system.vertices); mixed equality/inequality systems show the elimination steps, then each candidate checked against the constraints (solve.system.check-constraints, solve.system.reject). Both previously threw "not supported" errors.

  • explain('simplify') surfaces the work done inside operands. Simplifications applied while descending into the operands of a sum, product or function argument — previously summarized by an opaque bookkeeping step — now appear as labeled steps with their own rule ids (\tan x\cot x + \frac{x^3+x^2}{x^2} shows the \tan x\cot x \to 1 rewrite before the expansion). At default verbosity, consecutive applications of the same rule are coalesced into a single step; pass verbosity: 'all' for the raw chain.

Integration (opt-in Rubi rules)​

  • Integration consistently respects timeLimitMs. Nested integration attempts now share the original time limit and recursion safeguards, avoiding runaway evaluation on cyclic or difficult subproblems.
  • Any integration variable works—not just x. Integrals using another variable could previously return an expression in x; for example, \int t^2\,dt now correctly returns t^3/3.
  • Symbolic-coefficient quartic-denominator rationals close. \int \frac{d+e\,x^2}{a+b\,x^4}\,dx — and shapes that reduce to it, such as \int \frac{x^6}{(a+c\,x^4)^3}\,dx — now reach the trinomial terminal rules instead of ping-ponging between integrand expansion and binomial splitting.
  • Symbolic-coefficient reciprocal hyperbolics close. \int \frac{1}{a+b\sinh x}\,dx and the cosh/tanh/coth/sech/csch variants resolve via a rational-normal-form retry in the exponential-substitution fallback.
  • Complex special-function closures. Rational integrands with irreducible quadratic denominators split over complex-conjugate roots in the Si/Ci fallback, reciprocal-argument integrands like \int x^m \sin(a + b/x)\,dx close, and inverse-trig antiderivatives producing complex-argument Erfi evaluate (riding the new complex error-function kernels).
  • \int F(\ln(a\,x^n))/x\,dx closes via a function-of-logarithm recognizer (substitution u = \ln(a\,x^n)).
  • Products of sines and cosines reduce via product-to-sum before integration, closing mixed-angle products the term-by-term rules could not reach.

Arithmetic​

  • Canonical expressions no longer depend on a variable's current value. A mutable symbol holding 0, 1, or -1 could be folded into an expression while it was boxed, producing stale and sometimes incorrect results after the symbol changed. Canonicalization now folds only literal numbers; symbol values are substituted during evaluation. Numeric const symbols follow the same evaluation behavior as Pi.
  • Huge scientific exponents no longer crash. Parsing or serializing a number literal whose exponent exceeds what the bignum layer can represent (1e999999999) threw; it now overflows cleanly to +\infty (and -\infty for negative mantissas), matching float semantics.
  • Complex values with an infinite component type as complex. A Complex whose real or imaginary part is infinite was typed finite_complex, so type-gated paths mishandled it; it now reports the non-finite complex type.

Sums and Products​

  • Telescoping sums and products evaluate in closed form. A sum whose body is a k \to k+1 shift pair collapses exactly, for arbitrary symbolic bounds and either orientation: \sum_{k=0}^{n} \bigl(g(k+1) - g(k)\bigr) evaluates to g(n+1) - g(0). The product counterpart recognizes a shift-quotient body after combining it over a common denominator: \prod_{k=1}^{n-1}\left(1 + \frac{1}{k}\right) evaluates to n.
  • \prod_{k=1}^{n} k evaluates to n!. The bare-index product with a symbolic upper bound returns Factorial(n) instead of staying inert.
  • Classic infinite series and products evaluate to their exact closed forms. p-series reduce to the zeta function — \sum_{k=1}^{\infty} \frac{1}{k^2} evaluates to \frac{\pi^2}{6}, \sum \frac{1}{k^2} + \frac{1}{k^3} to \frac{\pi^2}{6} + \zeta(3) (term-wise splitting applies only when every summand has a closed form) — and the Wallis product \prod_{k=1}^{\infty}\left(1 - \frac{1}{(2k)^2}\right) evaluates to \frac{2}{\pi}. Series with no known closed form stay symbolic under exact evaluate(), per the infinite-domain contract.
  • .N() of convergent infinite sums reaches near machine precision. The numeric path Richardson-extrapolates the partial sums instead of returning a plain 10⁴-term truncation: \sum 1/k^2 now numericizes to ~2·10⁻¹⁶ of π²/6 (previously ~10⁻⁴ off), and series without closed forms benefit equally (\sum 1/(k^2+1) to ~2·10⁻¹⁴). Divergent or non-smooth series are detected and fall back to the capped truncation.

Equation Solving​

  • Trigonometric equations with symbolic coefficients solve correctly. For example, x^2 - 2x\cos t + 1 = 0 solved for t now returns \pm\arccos\left(\frac{x^2+1}{2x}\right).

Assumptions​

  • Transitive closure over assumed inequality chains. Assumptions now chain: a \ge b, b \ge c, c \ge d entails a \ge d, strictness propagates (p > q > r entails p > r and p \ne r), and an antisymmetric cycle collapses to equality — Wester 21's x \ge y, y \ge z, z \ge x now proves x = z is True. A chain without a back-edge deliberately does not prove equality.
  • Even-power monotonicity on ordered positives. Wester 22's x > y, y > 0 \vdash 2x^2 > 2y^2 now evaluates to True (a difference of equally-scaled squares factors as k(x-y)(x+y) with both factor signs settled from the assumptions). x > y alone deliberately does not conclude x^2 > y^2, and solve()'s conservative root-filtering behavior is unchanged.

Simplification and Exact Arithmetic​

  • The Fu strategy reduces same-power sin/cos differences. simplify({ strategy: 'fu' }) now rewrites \sin^4 x - \cos^4 x to -\cos 2x (and the mirrored/2nd-power forms): a difference of squares whose Pythagorean sum factor is 1, which the exponent-2-only TR5/TR6/TR7 transforms could not reach. Verified numerically; the default simplify() path is deliberately unchanged (pinned by test).
  • Exact modulus of complex expressions with radical parts. Abs of a constant a + b\,i with radical/rational parts computes the exact \sqrt{a^2 + b^2} when it genuinely folds: Kahan's \left|3-\sqrt{7}+i\sqrt{6\sqrt{7}-15}\right| simplifies to exactly 1 (its .N() alone carries a 1.0000000000000000315 float residue), |5-12i| = 13, |2+\sqrt{5}\,i| = 3, |1+2i| = \sqrt{5}. A split whose "imaginary part" is itself imaginary (a negative radicand) is rejected by a numeric cross-check, and symbolic |x+iy| never folds.
  • Matrices differentiate elementwise. D over a vector/matrix List literal maps over the elements (recursively for nested lists) instead of producing a nonsensical scalar chain-rule expansion: the second derivative of the rotation matrix [[\cos t, \sin t], [-\sin t, \cos t]] is -M, as it should be. Derivative shares the fix.
  • Together combines fractions correctly. It now uses a common denominator instead of adding numerators and denominators independently: \frac{a}{b} + \frac{c}{d} \to \frac{ad + bc}{bd}, 1 + \frac{1}{k} \to \frac{k+1}{k}, reusing the denominator when terms already share it.

Linear Algebra​

  • Exact null spaces, ranks, and eigenvectors. The exact bigint-fraction elimination introduced for RowReduce in 0.73.0 now backs Kernel (null-space basis vectors come out as exact rationals: [[2,3],[0,0]] → basis [-3/2, 1]), MatrixRank (rank = exact pivot count, with no float-tolerance ambiguity), and eigenvector computation (when the matrix and the eigenvalue are exact rationals, A - \lambda I is solved exactly — the eigenvectors of [[4,1],[2,3]] are the exact [1, 1] and [-1/2, 1]). Inexact or symbolic entries fall back to the numeric path unchanged.
  • M · M^{-1} simplifies to the identity for symbolic matrices. Two fixes combine: simplify() now recurses into List elements (matrix entries were previously unreachable by any simplify rule), and a new rule combines a sum of fractions sharing an identical denominator into a single fraction so the diagonal entries \frac{a^2 b}{a^2 b - b} + \frac{-b}{a^2 b - b} cancel to 1.
  • Symbolic matrix rank via the determinant. MatrixRank of a small symbolic matrix now concludes when the simplified determinant settles the question: the trigonometric matrix [[\sin 2t, \cos 2t], [2\sin t\cos t, \cos^2 t - \sin^2 t]] has rank 1 (its determinant vanishes under TrigReduce). Indeterminate cases stay symbolic, as before.
  • Vandermonde determinants return the difference product. The determinant of a symbolic Vandermonde matrix (either orientation) is produced directly in its factored closed form \prod_{i<j}(x_j - x_i) instead of an unfactored expansion.
  • The numeric eigensolver converges on hard spectra. The QR iteration was rebuilt as Householder reduction to Hessenberg form followed by the Francis double-shift algorithm with deflation. The classic 8×8 Rosser stress matrix — double eigenvalue 1000, a ±10\sqrt{10405} pair, and a tiny eigenvalue ≈0.098 — now yields the true spectrum (the unshifted iteration returned wrong values), and non-symmetric matrices get proper complex-conjugate eigenvalue pairs ([[0,-1],[1,0]] → \{i, -i\}).
  • MatrixPower(M, 1/2) — principal matrix square root. Half-integer powers of an exact 2×2 positive-semidefinite matrix evaluate exactly via the closed form \sqrt{M} = (M + \sqrt{\det M}\,I)/\sqrt{\operatorname{tr} M + 2\sqrt{\det M}}: MatrixPower([[10,7],[7,17]], 1/2) → [[3,1],[1,4]], and 3/2, -1/2 etc. compose with the integer path.
  • New operator: SingularValues — the singular values of a matrix, descending, zeros included; exact when the Gram matrix is at most 2×2 with rational entries (SingularValues([[1,1],[2,2],[3,3]]) → \{2\sqrt{7}, 0\}), numeric via the SVD machinery otherwise. (Across this release's Wester rounds the wester.test.ts skip ledger drops from 21 to 3 — the remaining three are the radical-denesting tail.)

Core​

  • String(…) joins values, not serialized forms. A string operand's quotes leaked into the result: String("x = ", 3) evaluated to a string whose content was "x = "3. It now evaluates to x = 3. This also fixes Cortex string interpolation, which lowers to String — the documentation's headline example "\(x) has type \(Type(x))" now produces "2047 has type integer".
  • Type reports the type of symbols and expressions. The Type operator holds its operand unevaluated, but an unevaluated operand is not canonical and a non-canonical expression has no type — so Type(y) returned "unknown" even for a symbol bound to an integer, and Type(1 + x) returned "unknown" instead of "number". The operand is now canonicalized (still not evaluated) before its type is read.

Cortex Language (Experimental)​

  • Runtime problems in non-final statements are no longer silent. Only the last statement's value is returned from executeCortex, so an error value produced by an earlier statement used to vanish — an unsupported indexed assignment (xs[2] = 9) or a mid-program const reassignment went completely unreported. Each non-final statement that evaluates to an error value now emits a runtime-error diagnostic carrying the statement's source range; the final statement's errors stay in value, per the errors-are-values contract.
  • Verbatim symbols are truly literal. The content of a backtick-quoted symbol (`while`) receives no escape processing and must be a valid MathJSON symbol name — the verbatim form exists to name reserved words. Previously, string escape sequences were applied inside the backticks (`\sin` silently cooked \s into a space) even though no valid symbol name contains an escapable character, so every such escape could only produce an invalid name.
  • New “Examples” documentation page. Eighteen complete Cortex programs — iteration and accumulation, recursion, numeric methods, exact and symbolic computation, collections — from FizzBuzz-as-a-Map to Newton's method on exact rationals, the Basel problem against \pi^2/6, and a golden-ratio continued fraction checked against a $…$ LaTeX island. Every program on the page is verified by an executable test suite.

Collections​

  • Symbolic-bound Range and Linspace stay inert instead of collapsing. A symbolic bound was silently coerced to 1, so Range(1, n) behaved as the one-element range [1] everywhere: Count(Range(1, n)) evaluated to 1, Sum(Range(1, n)) to 1, Range(1, n) = Range(1, m) to True, and materialization produced the literal [1]. All of these now stay symbolic/indeterminate, across the scalar accessors (Count, At, equality, SubsetOf, element sign), iteration, materialization, and the extrema (Supremum/Infimum/Min/Max). Likewise for Linspace: a symbolic point count is indeterminate (only a missing count selects the default of 50), and symbolic endpoints no longer materialize as NaN literals or fold Sum(Linspace(a, 1, 3)) to 0 — a collection that reports a size but cannot compute its elements now keeps its lazy form rather than fold to the reduction's initial value. Concrete bounds are unaffected.
  • Min/Max/Supremum/Infimum keep unenumerable collections symbolic. The extrema used to iterate any collection operand: an infinite one (a Map over a continuous Interval) ground through the interval's dense sampler until the evaluation deadline, and one that reports elements it cannot compute (a Map over a Linspace with a symbolic endpoint) silently vanished from the result — Min(Map(...), 5) returned 5 even though the mapped values could be smaller. Both now stay in the symbolic result. A genuinely empty lazy collection (a Filter with no matches) still folds away, and finite collections fold as before.

Compilation​

  • New iterationBudget compile option. expr.compile({ iterationBudget: 1e6 }) caps the trip count of emitted Sum/Product loops: a loop whose iteration count would exceed the budget — including an infinite bound, which previously compiled to a loop that never terminated — evaluates to NaN instead of running. Compilation without the option is unchanged (unbounded loops, zero overhead); the engine's numeric limit probes use it internally to stay interruptible.
  • The interval-js target compiles every operand of n-ary nodes. Chained relations (1<x<4) compiled to only their first binary comparison, and n-ary And/Or dropped every operand past the first pair — for Or this was unsound in the exclusion direction (an interval admitted only by a dropped branch reported a definitive "false", so a mask-driven consumer would wrongly cull it). Chains now emit the tri-state conjunction of all pairwise comparisons, and And/Or fold all operands; the javascript/glsl targets were always correct.
  • The javascript target fails closed on scalar arithmetic over a list-valued operand. L + x with a list-valued L previously compiled with success: true to JS array coercion (returning a string). It now reports success: false with an explanatory error, and the interpretation fallback returns the correct broadcast list. Supported list compilation — broadcast (\sin([x, 2x])), literals, ranges, GPU vectors, custom vector operators — is unchanged.
  • Seeded, reproducible randomness: ce.randomSeed. Assigning a number or string seed makes Random()/Random(n) (and Shuffle, Sample) draw from a per-engine deterministic PRNG stream; re-assigning the same seed resets the stream so identical evaluation sequences reproduce, and null (the default) restores non-deterministic behavior. With a seed set at compile time, each Random node in a javascript-target compilation bakes to a constant derived from the seed and the node's position — a compiled plot function returns the same value at the same call site on every invocation (one draw per compilation), instead of flickering per sample. The explicit per-call Random(seed) overload is unchanged.
  • GLSL masked branches emit an overridable _gpu_nan() helper. The else-branch of a compiled When/Which/If was a bare 0.0 / 0.0, whose NaN semantics GLSL ES 1.00 leaves implementation-defined. The literal now lives in a single selective-preamble helper that ES 3.00 hosts can replace with intBitsToFloat(0x7FC00000) for a guaranteed bit pattern.

Parsing​

  • Bare-command function names \abs, \floor, \mod, \sign parse as function calls. \abs\left(x\right) → Abs(x), \floor(x) → Floor(x), \mod(a, b) → Mod(a, b), \sign(x) → Sign(x) — common informal shorthand (and Desmos output) that previously errored with unexpected-command. The infix a \mod b (synonym of \bmod) is unchanged. Also, \operatorname{sign} now aliases to Sign like sgn (it previously parsed silently as a free symbol sign multiplied by the argument).
  • A dot-number after a closing group multiplies. \left(1-t\right).9\left(2\right) and t^{i}.4 parse the .9/.4 as a decimal literal juxtaposed with the preceding operand (implicit multiplication), instead of erroring with unexpected-operator. Degenerate dot sequences after a number (1.2.3) still error, and member access (v.x), ranges (1..2), and trailing-dot numbers ((1., 2)) are unaffected.
  • \frac{d}{X} is a division unless the denominator is a differential. Leibniz-derivative parsing now requires an actual d-marker in the denominator (\frac{d}{dx}, \frac{dy}{dx}, \frac{d^2}{dx^2}…). A bare-d numerator over a plain denominator — \frac{d}{L} where d is an ordinary variable, common in pedagogy graphs — previously parsed to a malformed derivative D(missing, L); it is now Divide(d, L).
  • A matrix environment parses as a function argument. \operatorname{Trace}\left(\begin{pmatrix}1&2\\3&4\end{pmatrix}\right) — and any library or user-declared function called on a pmatrix-family environment, with or without \left/\right — parsed the argument as a missing-argument error, so Trace, Eigenvalues, Eigenvectors, etc. appeared broken from LaTeX while working from MathJSON. The matrix (alone or among other arguments) now parses, evaluates, and round-trips.

API​

  • ce.operatorInfo() reports computability. The returned record now carries canEvaluate: boolean — true when the operator's definition has an evaluation rule, false for a registered-but-inert head that only parses/serializes (e.g. To, Tilde). Together with an undefined return (no operator definition), integrators can gate free-form input on "can this actually compute" instead of hand-maintaining allowlists. Note: heads that reduce via canonicalization to another operator (Exp → Power, Greater → Less) report false; query the canonical form.

Special Functions​

  • New operators: SinhIntegral and CoshIntegral — the hyperbolic sine and cosine integrals Shi and Chi, with numeric evaluation for real and complex arguments (Shi(2) ≈ 2.50157, Chi(2) ≈ 2.45267; validated against mpmath) and derivatives (\frac{d}{dx}\operatorname{Shi}(x) = \frac{\sinh x}{x}, \frac{d}{dx}\operatorname{Chi}(x) = \frac{\cosh x}{x}). Exact arguments stay symbolic under evaluate(); .N() owns the numeric path.
  • Erf and Erfi evaluate for complex arguments. Both error functions now have full complex-plane numeric kernels (\operatorname{erf}(1+i) ≈ 1.31615 + 0.19045i, validated against mpmath), instead of evaluating only on the real line.
  • Subscripted special-function notation parses. \operatorname{W}_{-1}(x) now parses to the two-argument ["LambertW", x, -1] (branch last), and \operatorname{J}_{n}(x) / \operatorname{Y} / \operatorname{I} / \operatorname{K} parse to BesselJ(n, x) et al. (order first) — these forms previously serialized but did not parse back, so LaTeX round-trips of non-principal Lambert branches and indexed Bessel functions now close.
  • The two-argument LambertW(z, k) differentiates. Every fixed branch satisfies the same functional equation, so d/dz W(z,k) = W(z,k)/(z·(1+W(z,k))) now carries the branch through (chain rule included); the derivative with respect to the discrete branch index stays inert. Verified against central differences on both real branches.
  • Fungrim identities: W₋₁(x·ln x) → ln x fires. The upstream entry a172c7 published an empty assumption interval (OpenClosedInterval(0, −1/e)); the corrected band x ∈ (0, 1/e] was fixed in the corpus fork (submitted upstream), and the recompiled identities artifact now carries the rule: with loadIdentities(ce) and assume(0 < x ≤ 1/4), simplify(W(x·ln x, −1)) returns ln x.
  • Fungrim identities: the polygamma family is live (+28 rules, artifact 1,442). The corpus' 2-argument DigammaFunction(z, m) (the order-m polygamma) now translates to CE's native PolyGamma(m, z) instead of a compat-shadowed 2-arg Digamma, so 28 previously skipped identities and special values compile and fire: simplify(PolyGamma(1, 1)) → π²/6, PolyGamma(1, 1/4) → π² + 8·Catalan, PolyGamma(1, 1/2) → π²/2, the digamma/polygamma recurrence and reflection identities, and more.
  • Fungrim identities: set-builder comprehensions get a real encoding (+8 rules, artifact 1,450). Corpus formulas of the shape {f(x) : x \in S, P(x)} used to translate to a literal Set that CE read as a two-element enumeration — producing wrong scalars where one was consulted (Count of a set-builder returned its operand count). They now translate to the faithful Map(Filter(S, P), f) form, which both fixed the miscounts and recovered nine identities whose match side had been untranslatable — notably the prime-counting definition, so with loadIdentities(ce), simplify rewrites Count(\{p \in \mathrm{Primes} : p \le x\}) to \operatorname{PrimePi}(x). Extrema over comprehensions (\min\{f(x) : x \in S\}) get the same encoding. The full 2,551-entry corpus now validates with zero numerically false entries.

0.73.0 2026-07-09​

New Operator: Interpret​

  • Interpret(expr) gives formal meaning to elliptical notation. Evaluating ["Interpret", expr] turns a continuation-bearing sum or product (the inert notational objects produced by the ellipsis fold barrier, see below) into a formal Sum/Product: Interpret(1 + 2 + \dots + n) → \sum_{k=1}^{n} k, Interpret(2 \cdot 4 \cdot \dots \cdot 2n) → \prod_{k=1}^{n} 2k, and Interpret(1 + 2 + \dots + 100) → a Sum that evaluates to 5050. Interpretation is an explicit opt-in — a plain evaluate() never guesses — and the gate is strict by design: at least two exact numeric sample terms in arithmetic progression and a single anchor whose implied upper bound is integral (so 1 + 3 + \dots + 2n, whose even anchor does not belong to the odd progression, stays untouched). Anything the gate cannot prove is returned unchanged.
  • Polynomial and geometric patterns are recognized too (v2). Successive finite differences identify polynomial general terms — Interpret(1 + 4 + 9 + 16 + \dots + n^2) → \sum_{k=1}^{n} k^2, cubes and triangular numbers likewise — and a constant exact ratio identifies geometric ones: 1 + 2 + 4 + \dots + 2^n → \sum_{k=1}^{n+1} 2^{k-1}, 2 \cdot 4 \cdot 8 \cdot \dots \cdot 2^n → \prod_{k=1}^{n} 2^k. Numeric anchors resolve to concrete bounds (1 + 4 + 9 + \dots + 100 → a sum to 10 that evaluates to 385). An evidence discipline guards against overfitting: a degree-g polynomial needs its constant difference row witnessed twice, or one fewer sample when the anchor structurally confirms the general term — three samples fit any quadratic, so 1 + 2 + 4 + \dots + m stays untouched.
  • Linear recurrences are recognized (v3). An exact-rational Berlekamp–Massey pass finds the minimal constant-coefficient recurrence (order ≥ 2) behind the samples, obtains a verified closed form through RSolve, and resolves numeric anchors by iterating the recurrence exactly: Interpret(1 + 1 + 2 + 3 + 5 + 8 + \dots + 55) → \sum_{k=1}^{10} \operatorname{Fibonacci}(k), which evaluates exactly to 143; Pell-number sums likewise (with a Binet-style body). The same evidence discipline applies — a recurrence of order L needs 2L+1 samples (or 2L with a confirming anchor), so primes and factorials stay untouched. Closed forms are verified against every sample before being trusted.
  • Subtraction-spelled ellipses are protected too.

Number Theory​

  • Modular arithmetic reaches common notation. Mod and Congruent now reduce integer-valued expressions in ℤ/mℤ without materializing the (potentially astronomically large) intermediate value. Modular exponentiation, sums, products, negations and factorial reduction are all handled, so 2^{3^{20}} \pmod{100} evaluates to 52 and 2^{3^{20}} \equiv 52 \pmod{100} evaluates to True, where both used to stay inert. The floored-sign convention of Mod (the result follows the divisor) is preserved on the new path.
  • New ModularInverse(a, m) returns the modular multiplicative inverse of a modulo m — the integer x in [0, m) with a·x ≡ 1 (mod m) — and stays symbolic when a and m are not coprime.
  • Linear congruences and CRT systems solve. solve on a linear congruence returns the parametric residue family with a fresh integer parameter t ∈ ℤ (6n \equiv 4 \pmod 7 → 7t + 3), an empty result when there is no solution (2x \equiv 1 \pmod 4), and reduces gcd-divisible congruences (4x \equiv 2 \pmod 6 → 3t + 2). A system of simultaneous congruences in one unknown is combined via the Chinese Remainder Theorem — including non-coprime moduli — into a single family (x \equiv 2 \pmod 3, x \equiv 3 \pmod 5, x \equiv 2 \pmod 7 → 105t + 23); an inconsistent system reports no solution.
  • Huge exact products stay symbolic instead of overflowing. Multiply now applies the same digit-count budget as Power when an exact power term (base^exp) would be folded into a product's numeric coefficient: if materializing that power would exceed the budget, the factor is kept as a symbolic Power term rather than computed eagerly (2 \cdot 3^{5000000} stays 2 \cdot 3^{5000000} instead of building a multi-million-digit bigint). This also lets Mod/Congruent reduce such products — 2 \cdot 3^{5000000} \pmod 7 evaluates to 4 — without ever materializing the giant intermediate value.

Arithmetic​

  • New operator: PolyLog — the polylogarithm Liₛ(z). Numeric evaluation for integer order s ≥ 2 over the whole complex plane (validated against mpmath to ≈5·10⁻¹⁵; branch cut z ∈ (1, ∞) with the below-the-cut convention), and exact reductions for the elementary orders and special points: Li₁(z) → −ln(1−z), Li₀(z) → z/(1−z), Li₋₁(z) → z/(1−z)², Liₙ(1) → ζ(n), Liₙ(−1) → (2^{1−n}−1)·ζ(n), Liₛ(0) → 0. Parses and serializes as \operatorname{Li}_s(z) (the unsubscripted \operatorname{Li}, conventionally the offset logarithmic integral, is deliberately not claimed).
  • LogIntegral now has its standard notation. \operatorname{li}(x) parses to LogIntegral and serializes back (previously the fallback \mathrm{LogIntegral}(x)).
  • Repeating decimals box as exact rationals. A LaTeX repeating-decimal literal — vinculum (0.\overline{3}), dots (0.\overset{.}{1}4285\overset{.}{7}), parenthetical (1.54(2345)), or arc (0.\wideparen{142857}) notation — and the MathJSON {num: "0.(3)"} shorthand now box directly to the exact Rational they represent (0.\overline{3} → ["Rational", 1, 3], 1.(2345) → ["Rational", 12344, 9999]) instead of a truncated decimal float carrying a repeating-decimal marker.
  • Norm accepts point-like Tuples. \|(-3, 4)\| now evaluates to 5 instead of leaving the expression inert.
  • Double-factorial symbolic reductions. Under simplify(), (2n)!! reduces to 2^n \cdot n! and (2n+1)!! reduces to \frac{(2n+1)!}{2^n \cdot n!} when n is integer-typed.

Equation Solving​

On a 40-case univariate solving benchmark derived from SymPy's own test suite (graded by substituting the returned roots back into the equation), this release reaches 38/40 — parity with both SymPy and Mathematica — up from 26/40 for the previous release (base engine, without the opt-in solve templates: 33/40, up from 24). The two remaining cases (Dottie-style transcendental fixed points) are unsolved by SymPy and Mathematica as well. What changed:

  • Inverse trigonometric and hyperbolic equations solve exactly. \arcsin x = c, \arccos x = c and \arctan x = c return the exact root (\arcsin x = \frac12 → \sin\frac12), with out-of-range constants correctly rejected (\arctan x = 2 has no solution — 2 is outside arctan's range). \sinh x = c and \tanh x = c return their single root, and \cosh x = c returns both roots \pm\operatorname{arcosh}(c).
  • Exponential-symmetric equations are recognized. e^x \pm e^{-x} harmonizes to 2\cosh x / 2\sinh x before solving, so e^x + e^{-x} = 4 returns both roots \pm\operatorname{arcosh}(2).
  • Two-absolute-value equations solve. a\,\lvert f(x)\rvert = b\,\lvert g(x)\rvert is squared into a^2 f^2 - b^2 g^2 (candidates are validated against the original equation, so no extraneous roots): \lvert x-1\rvert = \lvert x+3\rvert → -1.
  • Rational equations cancel correctly before solving. Clearing denominators no longer expands numerators past their common factor (\frac{2x}{x+2} = 1 → 2), and pure-number denominators are no longer multiplied through at all — rational constants stay where the solve patterns expect them.
  • LambertW gains the real lower branch W₋₁. The 2-argument form ["LambertW", z, k] selects the branch (k is 0 or -1; other branches stay symbolic): exact evaluation, machine- and arbitrary-precision numerics on the branch domain [-1/e, 0), compilation, and \operatorname{W}_{-1}(x) LaTeX serialization. W(-\frac1{10}, -1) evaluates symbolically and .N()s to -3.5771520639….
  • The opt-in solve templates now cover Lambert-type equations on both real branches. With loadIdentities(ce, { solve: true }) (from the identities bundle), equations reducible to W solve exactly and return every real root: x e^x = -\frac1{10} → \{\operatorname{W}(-\frac1{10}), \operatorname{W}_{-1}(-\frac1{10})\}, e^x - x - 2 = 0 → \{-2 - \operatorname{W}(-e^{-2}), -2 - \operatorname{W}_{-1}(-e^{-2})\}, and mixed linear-exponential forms like x + 2^x = 0 → -\operatorname{W}(\ln 2)/\ln 2. Exact rational, float, and integer right-hand sides are all handled. The identities library also simplifies W(x e^x, -1) \to x under assume(x \le -1).

Integration (opt-in Rubi rules)​

New rule coverage in the integration-rules bundle (loadIntegrationRules):

  • Polynomial × csc²/sec² integrates by parts. \int x\csc^2 x\,dx → -x\cot x + \ln \sin x, and likewise for P(x)\sec^2(ax+b) with any polynomial P (the recursion reduces the polynomial degree).
  • Rational × sin/cos of a linear argument reduces to Si/Ci. \int \frac{\sin x}{x+1}\,dx returns the exact \sin(-1)\operatorname{Ci}(x+1) + \cos(-1)\operatorname{Si}(x+1) form via partial fractions over linear factors.
  • Secant-family binomials route through the dedicated secant rules. Integrands like \frac{1}{1+\sec x} now resolve (x - \frac{\tan x}{\sec x + 1}) instead of returning unevaluated.
  • Cotangent integrands reflect onto the tangent rules, closing forms like \int \cot^3 x\,dx → -\frac{\cot^2 x}{2} - \ln \sin x.

Simplification and Exact Arithmetic (Wester round 1)​

  • Rational radicands extract perfect-power factors. (1029/1000)^{1/3} now canonicalizes to \frac{7}{10}\sqrt[3]{3} (numerator and denominator factored independently), extending the existing integer-radicand extraction. Also fixed an exactness leak where a higher root of an exact literal could evaluate to a float times Root(1, n) (e.g. Wester 28's 2^{1/3} expressions now stay all-exact under evaluate()).
  • Pythagorean factoring in simplify(). \cos^3 x + \cos x\sin^2 x - \cos x now simplifies to 0: a sum with a shared factor times \cos^2 u and \sin^2 u combines (g\cos^2 u + g\sin^2 u \to g), generalizing the bare \sin^2 x + \cos^2 x \to 1 case.
  • Rational-function cancellation fires in simplify() (Wester 14): \frac{x^2-4}{x^2+4x+4} simplifies to \frac{x-2}{x+2}. The cancellation machinery existed but its result was destroyed by a subsequent expand-over-sum-denominator rewrite in the same pass; that split is now suppressed (it never reduces complexity).
  • Binomial(n, k) and Pochhammer(a, k) expand for small literal k with a symbolic first argument: Binomial(n, 3) evaluates to \frac{n(n-1)(n-2)}{6}, Pochhammer(a, 3) to a(a+1)(a+2) (k ≤ 20). Pochhammer is a newly registered operator (it previously had no definition and was fully inert).
  • Six Wester CAS-review tests unskipped in wester.test.ts (the skip ledger drops from 27 to 21).

Linear Algebra​

  • RowReduce is exact on exact input. Reduction of an integer or rational matrix now uses exact bigint-fraction elimination — the RREF of an integer matrix has exact -1/3 pivots instead of -0.999…/2.999… float artifacts. Float matrices use the numeric path unchanged. (NullSpace/MatrixRank's float elimination is tracked in the ROADMAP for the same treatment.)
  • Products of declared matrices type correctly. A product with a matrix/vector/list-typed operand now carries the collection type instead of collapsing to a numeric type: with X and Y declared matrix, 2Y, XY, X - Y and 3X + 2Y all type as matrix (previously finite_number, which made \det(XY) fail validation as an incompatible-type error). Trace of a matrix now types as number. All-scalar products are unchanged. Note: undeclared symbols in a matrix-expecting argument (\det(A+2B) with fresh A, B) still infer as numbers — declare matrix/vector symbols for symbolic matrix algebra (see the ROADMAP "Matrix-operator typing" item for the planned inference-ordering fix).

Units​

  • Compound units cancel in quantity arithmetic. Multiplying or dividing quantities now cancels units structurally instead of accumulating them: 18 \text{ in} / (12 \text{ in/ft}) evaluates to 1.5 \text{ ft} (previously the inscrutable 1.5 \text{ in/in/ft}). A repeated unit symbol cancels exactly — no conversion factors are introduced — while different units of the same dimension on opposite sides of a fraction bar are converted and folded into the magnitude: \frac{10 \text{ m} \cdot 1 \text{ s}}{5 \text{ in}} → 78.74 \text{ s}. Products of same-dimension units are left as written (2 \text{ in} \cdot 3 \text{ ft} stays 6 \text{ in} \cdot \text{ft}), and simplification to named derived SI units still applies afterwards (2 \text{ N} \cdot 3 \text{ m} → 6 \text{ J}). Works with measurement (uncertainty-carrying) magnitudes as well.
  • New units: yd, qt, pt, cup, wk. Yards, quarts, pints, cups (US liquid convention, consistent with the existing US gal) and weeks join the unit registry, with their English word aliases (5 \text{ yards} → 5 \text{ yd}), and convert exactly: 1 \text{ gal} / 1 \text{ qt} evaluates to 4.
  • Currency: dollars and cents. A new currency dimension backs the USD and cent units (18 \text{ dollars} → 18 \text{ USD}, 1 \text{ dollar} + 50 \text{ cents} → 1.5 \text{ USD}), and currency participates in unit cancellation (\$6 / (\$2/\text{lb}) → 3 \text{ lb}). Other currencies are deliberately not modeled: exchange rates are not fixed constants, so cross-currency expressions stay inert rather than silently wrong.
  • Spaced unit phrases parse. Multi-word unit text such as 60 \text{ miles per hour} now parses to 60 \text{ mi/h} — spaces inside \text{...} unit annotations are preserved and per reads as division — where previously the words ran together and the unit was not recognized.

New Notations​

  • Base-subscript numerals compute. A numeral with an integer-literal subscript base, e.g. 10111_2 or 2748_{16}, now parses to the numeric BaseForm(value, base) head (10111_2 → ["BaseForm", 23, 2]), so arithmetic on based numerals works: 1011_2 \cdot 101_2 evaluates to 55, and 11_8 - 3_8 = 6_8 evaluates to True. The guard is strict — every digit must be valid for the base (19_2 stays an inert Subscript), subscripted symbols (x_2) are unchanged, and values larger than 2⁵³ stay exact. The BaseForm LaTeX serializer was also fixed (it emitted an unbalanced parenthesis) and now round-trips: BaseForm(23, 2) serializes as 10111_{2}. A numeral with a symbol subscript base, e.g. 161_b or 161_{b}, now parses to BaseForm of the digit polynomial in that base (161_b → ["BaseForm", ["Add", ["Power", "b", 2], ["Multiply", 6, "b"], 1], "b"], i.e. b² + 6b + 1), so arithmetic works symbolically (161_b + 134_b evaluates to 2b² + 9b + 5) and the numeral round-trips back to 161_{b}. Base equations solve: 161_b + 134_b = 315_b reduces to b² − 8b = 0 and solves to b = 8 (and b = 0).
  • Sequence-braces notation. \{a_n\}_{n=1}^{\infty} now parses to the new inert IndexedSequence(term, index, lower, upper) head instead of an incompatible-type error. The term uses the operator-call form (["a_", "n"]) so the index binding survives; _{n\in\mathbb{N}} subscripts map to the set's least element as the lower bound; the expression is inert under evaluate() and simplify() and round-trips through LaTeX. Bare \{a_n\} remains a Set, and the parenthesized form (a_n)_{n\in\mathbb{N}} is unchanged.

Ellipsis Expressions​

  • Sums and products no longer fold numeric terms across an ellipsis. An Add or Multiply containing an ellipsis (\dots, the ContinuationPlaceholder symbol) is a notational pattern, not an arithmetic one: it now keeps its operands in source order with their structure intact, and is returned unchanged by evaluate(), N() and simplify(). Previously 1 + 2 + \dots + n canonicalized to n + 3 + \ldots — folding the sample terms and destroying the pattern — and 2 \cdot 4 \cdot \dots \cdot 2n folded to 16 \cdot \ldots \cdot n, tearing the coefficient out of the 2n anchor. Such products also round-trip through LaTeX now (an explicit \times is emitted around the ellipsis instead of juxtaposition).

LaTeX Parsing​

Recovery fixes from the Hendrycks-MATH genre sweep (docs/mathnet/), taking that corpus from 97.09% to 97.38% clean parse:

  • Ordinal superscripts devolve to the base number: 13^{\text{th}} now parses as 13 (also 1^{\text{st}}, k^\text{th}, \mbox variants). Only an exact ordinal suffix (st/nd/rd/th, case-insensitive) is dropped; other superscripts are unchanged.
  • Empty scripts are dropped: x^{} and x_{} now parse as x instead of producing an error.
  • {,} thousands separator: the LaTeX thin-separator idiom 1{,}000 now parses as the number 1000. Only between digits, and a configured decimalSeparator: '{,}' (European convention) takes precedence — 3{,}14 still parses as 3.14 in that mode.
  • \cancel, \bcancel, \xcancel unwrap to their body, and \cancelto{4}{72} parses to the replacement value 4 — matching the worked-solution usage the notation comes from.
  • \not-prefixed relations compose into the negated relation: \not= → NotEqual, \not\in → NotElement, \not\equiv (incl. a trailing \pmod n) → the negated congruence, \not\subset → NotSubset, and relations without a dedicated negated head wrap in Not(…).
  • Standalone \pmod{7} now places the modulus as the second argument of Mod (previously the operands were flipped).
  • (2n)!! stays symbolic: Factorial2 accepts symbolic arguments (its signature was integer-only and rejected 2n with an incompatible-type error); numeric double factorials are unchanged (8!! = 384).
  • Primed variables type-check as arguments: \sin a' now parses to Sin(Prime(a)) instead of a type error — Prime mirrors the type of its base (a primed value is a value; a primed function is a function). Derivative notation (f'(x) → D(f(x), x)) is unchanged.
  • Bare N/D devolve to variables in all argument positions: N \equiv 1 \pmod k now parses as a congruence over the variable N (previously the standard-library N operator's function type failed the relation's numeric parameter check; the existing devolution fallback ran only for arithmetic operators). Applied uses (N(2/3)) still call the operator.
  • Congruence chains and fragments: 3^{27}\equiv 3^7\pmod{100}\equiv 87\pmod{100} folds into a conjunction of the adjacent congruence steps; a leading \equiv b \pmod n with an elided left-hand side recovers with a missing-operand placeholder.
  • Empty subscripts on multi-letter symbols are dropped: \alpha_{} parses as alpha (completing the earlier x_{}/13^{} fix).
  • English unit words in \text{…} parse as quantities. 18 \text{ inches} → ["Quantity", 18, "in"]: common measurement words (singular and plural — inches, feet, miles, gallons, pounds, minutes, hours, meters, liters, degrees, …) are normalized to their canonical unit symbols at the parse boundary, including inside compound units (\text{ inches/foot} → in/ft). An exponent outside the text binds to the trailing unit factor: 7.5 \text{ gallons/ft}^3 → Quantity(7.5, gal/ft³) (gallons per cubic foot, not (gal/ft)³). Strictly gated: the whole text must resolve as a unit, so prose like 9\text{ to }80 is untouched. No ton(s) alias (a US short ton is not the metric tonne t — mapping it would be a silent 10% error).

Restriction Braces​

  • Comma-separated brace conditions combine as a union (Or). x^2\{x\ge0, x\le3\} now parses to ["When", x², ["Or", 0≤x, x≤3]] — each comma element is piecewise shorthand for cond: 1 evaluated first-match, so the expression is defined where any condition holds. (Previously the condition was a Tuple, which is not boolean and could not compile.) Stacked braces (\{c_1\}\{c_2\}) still AND-combine, unchanged.
  • Colon groups parse as piecewise value selectors. x\{x>0:1, x<0:-1\} now parses to ["Multiply", "x", ["Which", 0<x, 1, x<0, −1]]: a brace group is a first-class piecewise value ({cond} ≡ {cond: 1}) attached by juxtaposition — i.e. multiplication, the same convention that makes the bare-condition form a restriction. A trailing bare value is the else branch (\{x>0:1, -1\} → …, "True", −1), and a bare condition inside a colon group means cond: 1. (Previously the cond:val pairs were parsed as a When gate for the body — inverted semantics.)
  • When now masks correctly on the interval-js compile target. The interval comparisons return the tri-state string 'true' | 'false' | 'maybe' — all truthy — so the previously-emitted JS ternary could never take its masking branch: an input interval entirely outside the restriction returned a normal interval result. When now compiles to a tri-state-aware runtime helper (_IA.restrict): 'false' masks ({kind: 'empty'}), 'true' passes the value through, and 'maybe' — an input straddling the restriction boundary — reports the value range as domain-clipped ({kind: 'partial', domainClipped: 'both'}) so adaptive samplers see a domain edge rather than a clean interval. Scalar javascript and glsl When emission is unchanged.

Pipelines and Held Operands​

  • Hold operators reduce transformer heads. Solve, Integrate, and Limit hold their expression operand (so an equation is not collapsed to a boolean before solving) — but a held operand whose head is an expression transformer (Simplify, Expand, ExpandAll, Factor, Together, Distribute, TrigExpand) is a computation step and is now reduced before the algorithm runs. x^2+2x+1 \rhd \operatorname{Simplify} \rhd \operatorname{Solve} now returns [-1] (previously []: the solver found no roots in an expression whose operator was Simplify), and \int \operatorname{Simplify}(x^2)\,dx / \lim of a transformer-wrapped body compute instead of staying inert. Only the curated transformer set is reduced — full evaluation would collapse relations and substitute assigned values into the unknown.
  • Unknown-inference defers on the pipe topic placeholder. Operators that infer their variable when omitted (Solve, D, Series, the polynomial operators) no longer run that inference on the pipeline topic placeholder _: ce.box(["Solve", "_"]) stays ["Solve", "_"] instead of canonicalizing to ["Solve", "_", "_"], which baked the placeholder into the unknown slot so a prefix pipeline stage (\rhd \operatorname{Solve}) computed Solve(expr, expr) → [0] where the infix spelling returned [-1]. Solve re-infers the unknown when the applied stage evaluates; the two spellings now agree. Piping an equation through the prefix form works too, now that an undecidable Equal survives the lambda's argument pre-evaluation (see "Undecidable Relations Stay Symbolic" below): Apply(\rhd Solve, x^2 = 4) → [2, -2].

Undecidable Relations Stay Symbolic​

  • An equation with free variables is a condition, not a falsity. Equal and NotEqual with an undecidable comparison now stay inert under evaluate(): x^2 = 4 evaluates to itself instead of False (and x \ne 4 to itself instead of True). This matches the inequality operators — x^2 < 4 already stayed symbolic — and Mathematica's ==. Decidable comparisons are unchanged (2+2=4 → True, 2=3 → False, x=x → True), list/scalar elementwise comparisons are unchanged, and assumption discharge still applies (assume(z > 0) ⇒ z \ne 0 → True). The previous collapse silently ruined stored equations: a notebook cell holding x^2 = 4 evaluated to False at storage time, breaking every answer-referencing Solve pipe downstream.
  • If and Which stay unevaluated on an undecided condition. A condition that is boolean-typed but not yet decidable (e.g. x = 4 with a free x) leaves the conditional inert — it may become decidable once the variables are bound — instead of throwing Condition must evaluate to "True" or "False" (or, previously for Equal conditions, silently taking the else branch). Genuinely non-boolean conditions (a number, a misspelled symbol) still throw with the spell-check hint.

Issues Resolved​

  • toLatex({ digits: <number> }) no longer throws RangeError: The number NaN cannot be converted to a BigInt on a bignum-precision engine. A bare number — not part of the documented DisplayDigits forms, but the exact shape of a mechanical fractionalDigits: n → digits: n migration — is accepted with the deprecated numeric convention (n ≥ 0 = fractional digits, n < 0 = significant digits), and a genuinely invalid shape reports a clear validation error instead of crashing.
  • The engine no longer trips its own `digits` and `fractionalDigits` were both specified deprecation warning. The serializer re-entered the public toMathJson() boundary — which always carries both (resolved) options — for any dictionary-typed expression; for a symbol bound to a dictionary value this also recursed without bound (a warning flood followed by a stack overflow). Dictionary values now serialize inside the serializer proper; the warning fires only for genuine caller mistakes, once.
  • BoxedDictionary.toMathJson() called without options no longer throws (Cannot read properties of undefined); it resolves the same defaults as every other expression kind.
  • .latex on a dictionary-typed symbol with no value no longer overflows the stack; it serializes as the symbol. (.latex on a dictionary value — which crashed in released builds — now returns an empty string: dictionaries have no LaTeX display form yet.)

Benchmarks​

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE (current)CE 0.70.0SymPymath.jsMathematica
\pi^218122812756.2
\sin 134363539457.1
\cos 140413511,31511
\ln 227245988,1955.8
e^{\pi}20223768,9847.5
\zeta(3)2,7152,787494—151
\Gamma(\tfrac13)1,4521,4244,843—267
\psi(\tfrac13)1,2691,2413,782—235

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case; ✓ means it solves a case Mathematica can't. Compare the CE (current) and CE 0.70.0 columns to see what is new this release (a — under 0.70.0 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE (current)CE + R/FCE 0.70.0SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx7.4×3.0×5.0×0.6×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx8.3×1.2×7.7×0.08×—1×
\int\frac{1}{x^3+1}\,dx4.3×0.7×3.4×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—2.0×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—0.9×—0.008×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—1.2×—0.006×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.01×0.02×0.02×0.0009×0.002×1×
Simplification
\sqrt{3+2\sqrt2}29×23×27×——1×
\sqrt6\,x+\sqrt2\,x71×36×54×2.8×9.1×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}43×20×38×2.5×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x6.3×4.2×6.0×2.1×—1×
\int_1^2\tfrac1x\,dx5213×6049×5202×82×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx332×106×279×2.3×—1×
Solving
x^4+x^2-1=00.2×0.2×0.2×0.06×—1×
x^3-x-1=01.2×1.4×1.4×0.04×—1×

Across the cases both solve, Compute Engine is a median 6.3× faster than Mathematica (up to 5213×).

Measured 2026-07-10 · Compute Engine 0.72.0 @ 2cf87db4 (current build) · published 0.70.0 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.72.0 2026-07-09​

Angular Units​

  • Compilation targets honor ce.angularUnit. Compiled code from every built-in target (javascript, interval-js, glsl, wgsl, interval-glsl, python) now reproduces the engine's angular-unit semantics instead of always computing in radians: direct trigonometric arguments (Sin…Csc, Haversine) are scaled by the unit→radian factor and inverse-trigonometric results (Arcsin…Arccsc, Arctan2, InverseHaversine) by its reciprocal, for all units (deg, grad, turn). With ce.angularUnit = 'deg', compile('\\sin(x)') emits Math.sin(0.017453… * x) so run({x: 90}) returns 1, matching evaluate() — previously a degree-mode expression evaluated in degrees but compiled (and therefore plotted) as if radians. Radian mode emits the same code as before.

  • Hyperbolic functions are now unit-independent. Their argument (and an inverse hyperbolic's result) is a dimensionless real, not an angle, so sinh, cosh, tanh, coth, sech, csch and arsinh…artanh no longer convert under a non-radian angularUnit. Previously in degree mode \sinh(1) evaluated to \sinh(\pi/180) \approx 0.0175 instead of 1.1752.

  • Exact inverse-trigonometric values are returned in the current angular unit. In degree mode \arcsin(1) now evaluates to the exact integer 90 (previously the exact radian value \pi/2, disagreeing with .N(), which returned 90). Similarly 100 in grad mode and the exact rational 1/4 in turn mode; radian mode still returns \pi/2.

  • Arctan2 honors angularUnit, consistently with Arctan (it previously always returned radians): in degree mode Arctan2(1, 1) evaluates to the exact 45, with the quadrant corrections applied in the current unit (Arctan2(1, -1) → 135).

    Symbolic calculus (D, Integrate) remains radian-based regardless of angularUnit (no \pi/180 chain-rule factor); this is a known limitation.

Step-by-Step Explanations​

  • explain('D') handles higher-order and mixed partial derivatives. A new order option requests the n-th derivative (ce.parse('x \\sin x').explain('D', { variable: 'x', order: 2 })), and a receiver that is itself a D expression — including mixed partials such as D(f, x, y) — is traced through its whole differentiation sequence. The explanation differentiates one order at a time: each stage replays the textbook rule applications inside the remaining derivative operators, folds to the simplified derivative, then differentiates again.

  • explain('solve') traces systems of equations and alternatives. A List or And of equations is traced through the same solvers solve() runs: Gaussian elimination shows one step per eliminated variable and per back-substituted variable (solve.system.eliminate, solve.system.back-substitute, solve.system.parametric), and nonlinear 2×2 systems show the product–sum or solve-and-substitute strategy (solve.system.product-sum, solve.system.solve-for, solve.system.substitute). An Or of univariate equations is solved case by case (solve.case) with the roots merged. The solutions are identical to solve() — the trace is a pure observation channel. Systems of inequalities and mixed systems are not traced and throw a precise error. To support systems, the variable explain option now also accepts an array of unknowns.

Cortex Language (Experimental)​

  • Cortex ships as a new entry point @cortex-js/compute-engine/cortex. Cortex is a text-syntax programming language for scientific computing whose intermediate representation is MathJSON, evaluated by the Compute Engine. The entry point exports parseCortex() (Cortex text → MathJSON), serializeCortex() (MathJSON → Cortex text), and executeCortex() (parse and evaluate a program against a host-created engine):

    import { ComputeEngine, executeCortex } from '@cortex-js/compute-engine/cortex';
    const ce = new ComputeEngine();
    const { value } = executeCortex(ce, `
    let x = 1/2
    if (x < 1) { x + 1 } else { 0 }
    `);
    // value.toString() === '3/2'

    This is experimental: the syntax and semantics may change between releases.

Pipeline Operator​

  • A pipeline operator applies the expression on its left to the function on its right. x \rhd f (also x \triangleright f, x \vartriangleright f, x ⊳ f, or the plain-text shortcut x |> f) parses to f(x). A \square topic marker in the right-hand side names the position the piped value fills, so a stage can be a multi-argument call: x^2 = 4 \rhd \operatorname{Solve}(\square, x) is Solve(x^2 = 4, x). Stages chain left to right (4 \rhd \sqrt \rhd \ln is ln(√4)), a bare function command such as \ln, \lb or \sqrt acts as a function reference (12 \rhd \ln is ln(12)), and the prefix form (\rhd f, with no left-hand side) denotes the anonymous unary function _ ↦ f(_).

  • The unknown/variable argument of Solve, D, Series and the polynomial operators may now be omitted. It defaults to the input's single free variable, or to x when there are several free variables and one of them is x; with no inferable default the expression stays unevaluated. This enables point-free pipelines such as x^2 = 4 \rhd \operatorname{Solve} or x^2 \rhd \operatorname{D}. Applies to Solve, D, Series, PolynomialDegree, CoefficientList, PolynomialRoots, Discriminant, PolynomialQuotient, PolynomialRemainder, PolynomialGCD, Resultant, Cancel, PartialFraction and Apart (Factor already inferred its variable). For the two-input polynomial operators the default is inferred from both operands together.

LaTeX Parsing​

Notation coverage driven by a cross-genre corpus sweep (Hendrycks MATH, 15,546 fragments across all seven subjects including worked solutions; see docs/mathnet/math-genre-sweep.md), which took the measured clean-parse rate from 95.3% to 97.1%:

  • Text-styling commands. \textbf, \textit, \emph, \texttt, \textsf, and \textup parse their argument as a text run and produce an Annotated expression with the matching style (\textbf{Sizes} → ["Annotated", "'Sizes'", {dict: {fontWeight: "bold"}}]) that round-trips back to the same LaTeX. \textrm and \mbox parse like \text. \bold, \boldsymbol, and \bm are synonyms of \mathbf (\bold{v} → the symbol v_bold).

  • Vector-norm bars. The \| command is now recognized as a norm delimiter everywhere \Vert is: \|\mathbf{a}\|, \left\| b \right\|, and \|a\|^2 all parse to Norm.

  • TeX-primitive binomial. The infix {n \choose k} form parses to Binomial(n, k), joining the already supported \binom, \dbinom and \tbinom.

  • Bare mod annotations. x \pmod n with no preceding \equiv parses as Mod(x, n) (-811 \pmod{24} → ["Mod", -811, 24]), matching the existing \bmod behavior. Congruence chains followed by an implication now parse correctly: a+1 \equiv 4 \pmod 7 \implies a \equiv 3 \pmod 7 is Implies(Congruent(…), Congruent(…)) (the congruence previously disintegrated when \implies followed the modulus). \equiv now binds at comparison precedence, tighter than \implies (zero snapshot impact).

  • Mixed braced/unbraced fraction and binomial arguments. \frac1{-1}, \frac{900}7, \binom{n}k, \binom n{k+1} parse correctly. Each argument is now independently a group or a single token, per TeX semantics; previously both arguments were forced into the style of the first, and the mixed forms produced a missing error.

Issues Resolved​

  • Reading .latex (or .toString()) on the canonical, unevaluated form of a scalar×tuple product — ce.parse('3(1,2)').latex, ce.box(['Multiply', 2, ['Tuple', 1, 2]]).latex — no longer throws RangeError: Maximum call stack size exceeded. The pretty-JSON Multiply serializer round-trips through Product.asRationalExpression(), and the tuple-aware branch of canonicalDivide returned an inert Divide(expr, 1) instead of stripping the trivial divisor, sending the serializer into infinite recursion. Trivial /1 and /-1 divisors of tuple-typed expressions are now reduced.

  • Juxtaposing a scalar with a tuple-typed symbol now means scaling, not tuple construction: with z declared tuple<number, number>, 3z parses to ["Multiply", 3, "z"] (previously a spurious ["Tuple", 3, "z"]). Literal tuples (3(1,2)) were already handled; heterogeneous tuples such as tuple<string, number> still group as a Tuple.

  • Compiled broadcasts over a list operand now compute their values. The generated .map() callback read its element variable from the vars object instead of the callback parameter, so a compiled \sin([x, 2x]) returned [null, null] for every input. Compiled broadcast results now agree with evaluate().

  • \operatorname{csch}(x) now parses to the Csch function (previously a free symbol named csch, silently turning the expression into an implicit multiplication), joining the existing \csch command and matching \operatorname{sech}.

  • Constructing many ComputeEngine instances in a synchronous loop no longer balloons memory (~430 KB pinned per engine until the task yielded to the event loop, enough to exhaust the default V8 heap after a few thousand engines). Every constant definition subscribed to configuration changes through a new WeakRef(...), and the ECMAScript kept-objects rule pins each WeakRef target until the next microtask checkpoint. The tracker now holds its listeners directly; since it is owned by the engine, the engine and its listeners form a self-contained cycle that is garbage-collected as a unit.

  • A bare \ln or \log — with no argument, as in the pipeline 12 \triangleright \ln — now parses to the function symbol ("Ln", "Log"), consistent with \cos, \lg and \lb. It previously parsed to an empty function application, so piping a value into it produced a missing error instead of applying the function: ce.parse('12 \\triangleright \\ln').evaluate() now returns 2\ln 2 + \ln 3. The bare symbols also serialize back to \ln, \log and \lg (previously \ln()).

  • A bare \lb (binary log) now parses to the Lb function symbol, so 12 \triangleright \lb computes \log_2 12. It previously parsed to Log, silently computing the base-10 logarithm instead.

  • A log with a base but no argument (\log_2) now parses with the pipeline topic marker \square standing in for the argument: 8 \triangleright \log_2 fills the hole and computes \log_2 8 = 3 (composing with inverse superscripts too: 9 \triangleright \log_3^{-1} gives 3^9), and a standalone \log_2 displays as \log_2(\square). It previously parsed as \log_{10} 2 — the base was read as the argument — so piping into it silently discarded the piped value.

  • Likewise, a function with a superscript but no argument (\cos^2, \ln^{-1}, \lg^{-1}) holds a topic-marker hole: x \triangleright \cos^2 computes \cos^2 x, 12 \triangleright \ln^{-1} computes e^{12}, and a standalone \cos^2 displays as \cos(\square)^2. These previously produced a Power of the bare function symbol, which failed to type when piped into.

0.71.0 2026-07-08​

Differential Equations​

  • First-order nonlinear equations solve. (contributed by KingArth0r) DSolve now handles four classical first-order classes:

    • Separable equations return an implicit solution when no explicit form is available: y' = x/y gives \frac12 y(x)^2 = \frac12 x^2 + c_1.
    • Bernoulli equations y' = p(x)\,y + q(x)\,y^n reduce via the v = y^{1-n} substitution and return explicit solutions.
    • Homogeneous equations of the form y' = F(y/x) solve by the v = y/x substitution: y' = 1 + y/x gives y(x)/x = \ln x + c_1.
    • Exact equations M(x,y) + N(x,y)\,y' = 0 return the implicit potential: 2xy + y^2 + (x^2 + 2xy)\,y' = 0 gives x^2\,y(x) + x\,y(x)^2 = c_1.

    Implicit solutions are expressed in terms of y(x) itself. Equations outside the supported classes (e.g. the Riccati equation y' = x + y^2) stay inert.

  • Initial and boundary conditions are applied. Scalar conditions can be passed in a list alongside the equation: DSolve([y'' = -y, y(0) = 0, y'(0) = 1], y, x) returns y(x) = \sin x. Derivative conditions are recognized in both the Apply(Derivative(y, 1), x0) and flat D(y(x0), x) forms. Conditions also apply to supported implicit solutions (y' = x/y with y(0) = 1 gives \frac12 y(x)^2 = \frac12 x^2 + \frac12), and free parameters survive: y' = kx/y with y(0) = 2 gives \frac12 y(x)^2 = \frac12 k x^2 + 2 with k untouched. If the conditions cannot be applied to the solution class, the equation stays inert rather than silently dropping them.

  • Nonhomogeneous constant-coefficient equations of any order. The undetermined-coefficients method now covers polynomial, exponential, and sinusoidal forcing at any order (previously polynomial forcing was second-order only), including resonant cases, which retry the ansatz with powers of x: y'' - y = e^x gives c_1 e^x + c_2 e^{-x} + \frac12 x e^x, and y''' - y = \sin x and resonant y'' + y = \sin x both solve.

  • First-order linear homogeneous systems solve. Pass the equations and dependent functions as lists: DSolve([y' = z, z' = y], [y, z], x) returns the general solution built from the eigen-decomposition of the coefficient matrix. Systems with repeated — or numerically indistinguishable — eigenvalues stay inert rather than returning a degenerate basis.

  • NDSolve integrates first-order systems. Fixed-step RK4 now handles systems, including nonlinear ones, with the dependent functions and initial values given as lists: NDSolve([y' = z, z' = -y], [y, z], Limits(x, 0, 1), [0, 1], 200) produces samples as [x, [y, z]] pairs. Malformed or unsupported systems stay inert rather than returning partial results.

Recurrence Equations​

  • New RSolve operator. (contributed by KingArth0r) RSolve(equation, a, n) solves linear homogeneous constant-coefficient recurrences via the characteristic polynomial: geometric (a_{n+1} = 2a_n gives a(n) = c_1\,2^n), Fibonacci-style, repeated roots with n^k r^n modes (a_{n+2} + a_n = 2a_{n+1} gives a(n) = c_1 + c_2\,n), and complex roots (a_{n+2} = -a_n gives a(n) = c_1\,i^n + c_2\,(-i)^n). Initial conditions can be given in list form: RSolve([a(n+1) = 2a(n), a(0) = 3], a, n) gives a(n) = 3 \cdot 2^n. Nonhomogeneous and variable-coefficient recurrences stay inert.

0.70.0 2026-07-08​

Breaking Changes​

  • The published dist/ directory is reorganized into per-variant subdirectories. The flat layout — where the variant was encoded in each filename (compute-engine.min.esm.js, compute-engine.umd.cjs, …) — is replaced by esm/, esm-min/, umd/, umd-min/, and the unchanged types/. The variant marker moves from the filename into the directory, so a bundle is now <dir>/<name>.<ext>. The general mapping is <name>.esm.js → esm/<name>.js, <name>.min.esm.js → esm-min/<name>.js, <name>.umd.cjs → umd/<name>.cjs, and <name>.min.umd.cjs → umd-min/<name>.cjs; for example dist/compute-engine.min.esm.js is now dist/esm-min/compute-engine.js. Consumers importing via the bare package specifier (@cortex-js/compute-engine and its sub-paths such as @cortex-js/compute-engine/identities) are unaffected — the package exports map absorbs the move. Only deep imports that reach into …/dist/… directly, and pinned CDN URLs, need to be updated. Each esm*/ directory is now fully self-contained, with its own chunks/ subdirectory holding only that variant's shared chunks, so vendoring a build is now "copy the directory for the variant you use."

  • The non-minified builds are no longer published to npm. The package now ships dist/esm-min/, dist/umd-min/, and dist/types/ only. The non-minified esm/ and umd/ directories — about 60% of the unpacked package, and never referenced by the exports map — are now build-only artifacts: npm run build still produces them locally for development and debugging, but if you need a readable (non-minified) bundle, build from source.

Improvements​

  • The declaration build and typecheck now run on TypeScript 7 (the native compiler), cutting .d.ts emission from ~31s to ~5s and the full production build from ~45s to ~29s. TS 7.0 ships no programmatic API, so it is installed side-by-side: the module name typescript stays aliased to the TS 6 API (@typescript/typescript6) for ts-jest, typedoc, typescript-eslint and madge, while the native compiler (@typescript/native) drives the CLI. No consumer-facing change — the published declarations are type-identical; only cosmetic emission differences appear (single-quoted string literals, sorted numeric-literal unions, literal non-ASCII property keys instead of \uXXXX escapes).

0.69.1 2026-07-08​

Issues Resolved​

  • #318 Type declarations now resolve correctly in projects using "module": "nodenext"/"node16". The published .d.ts files used extensionless relative imports, which produced TS2834 errors (or collapsed every imported type to any with skipLibCheck). The build now rewrites the emitted declarations with explicit .js extensions and validates them against a nodenext consumer as part of every release build.

0.69.0 2026-07-08​

Breaking Changes​

  • \pm now parses to a Measurement, not PlusMinus. a \pm b parses to ["Measurement", a, b] — a value with an uncertainty (see below) — replacing the previous PlusMinus head that evaluated to the two-branch tuple (a−b, a+b). Consequences: solution sets that previously used \pm (e.g. quadratic roots) are now returned as an explicit List of the branches, and a numeric integral that reports an error estimate now returns ["Measurement", estimate, error] instead of a PlusMinus tuple. Prefix \pm b parses to ["Measurement", 0, b].

  • Loop no longer produces a list — comprehensions moved to the new Comprehension operator. ["Loop", body, ["Element", x, coll], …] is now an imperative for-each evaluated for effect: its value is Nothing (or the value carried by a Break/Return), and it no longer collects the body values into a List. The trailing-for comprehension syntax (x^2 \operatorname{for} x = [1...10]) now parses to ["Comprehension", body, ["Element", …], …], which returns exactly what the collecting Loop used to — for consumers of the parse tree this is a head rename. The undocumented arity-2 form ["Loop", body, collection] (body applied as a lambda to each element) has been removed: use Map, or an Element clause; a non-Element iterator argument is now an error.

  • scalar + point is now an error. Adding a scalar to a numeric tuple (1 + (2, 3)) previously broadcast the scalar over the components; points are now proper vectors in ℝⁿ (see below) and a scalar term does not broadcast into them. Add a tuple explicitly ((1,1) + (2,3)) instead. Multiplying or dividing a point by a scalar still scales it.

  • Comparing a list to a scalar is now elementwise. [1, 4, 4] = 4 previously evaluated to False (whole-list comparison against a scalar); it now broadcasts and evaluates to ["List", "False", "True", "True"], as do <, <=, >, >=, and !=. Comparing two collections is unchanged: Equal(L, M) remains a whole-value comparison ([1,2,3] = [1,2,3] → True).

Measurements and Uncertainty​

  • New Measurement type — values with a propagated uncertainty. Measurement(value, error) (written value \pm error) represents a measured quantity carrying a 1σ absolute uncertainty, and the uncertainty propagates through arithmetic using standard independent, first-order (quadrature) error propagation:
    • Algebraic and elementary operations propagate the error: (5 \pm 0.2)(3 \pm 0.1) → 15.00 \pm 0.78, \sqrt{4 \pm 0.2} → 2.000 \pm 0.050, \sin(1 \pm 0.1) → 0.841 \pm 0.054 (trig respects the engine's angular unit).
    • Measurements combine with units: (5.1 \pm 0.2)\,\mathrm{cm} is a measured quantity, and the error carries through quantity arithmetic and unit conversion (UnitConvert of (5.1 \pm 0.2)\,\mathrm{cm} to m → (0.0510 \pm 0.0020)\,\mathrm{m}). The bare form 5.1 \pm 0.2\,\mathrm{cm} (no parentheses) parses to the same thing: a unit on only one operand of \pm scopes over the whole measurement (a dimensionless value with a dimensioned error is never meaningful). An error in a different unit than the value (5.1\,\mathrm{cm} \pm 2\,\mathrm{mm}) stays as written.
    • Display follows the physics convention — the uncertainty is shown to two significant figures by default and the value is rounded to the same decimal place (5.134 \pm 0.021, 8.00 \pm 0.22). Controlled by the digits serialization option ({ significant: n }, { fractional: n }, "max"); .toMathJson() stays lossless.
    • Correctness note: propagation is independent — exact when each measured quantity appears once (A = L·W) or in a single operation (x^2), but it over/under-estimates when one measured variable is reused across an expression (x·x, x/(x+1)), which are treated as independent. See the Units guide for details and the simplify workaround.

Points and Tuples​

  • Numeric tuples are now points/vectors in ℝⁿ, distinct from lists. Arithmetic on tuples is componentwise vector arithmetic and stays a Tuple: (1,2) + (3,4) → (4,6), 3(1,2) → (3,6), (4,2)/2 → (2,1), -(1,2) → (-1,-2). This fixes (1,2)-(3,4), which previously produced a malformed nested list. tuple · tuple is an error (no implicit dot product — use Dot), and scalar + tuple is rejected (see Breaking Changes). Lists keep their existing broadcast semantics.

  • Tuple arithmetic and component access work symbolically for typed symbols. A symbol declared tuple<number, number> participates in vector arithmetic without a value, and its components are accessible with the .x/.y/.z member syntax, which parses to First/Second/etc. (P.x → ["First", "P"]). Component access on a point literal ((1,2).x → 1) also works.

  • Color functions broadcast over lists, so rgb and hsv applied to list arguments produce a list of colors, matching the other broadcastable numeric operators.

Lists and Collections​

  • Filtering a list with a condition in index position. L[L > 0] evaluates to the elements of L where the condition holds — the Desmos list-filtering notation. The condition may reference the list itself (L[L>0]), another list (L[d=4] where d is a list), or compute a positional mask from a Range (L[|[1...\operatorname{length}(L)]-i|>0] removes the i-th element). A condition may be combined with integer indexes. The mask applies positionally and truncates to the shorter of list and mask.

  • Relational operators broadcast over lists. [-1, 2, -3] > 0 evaluates to ["List", "False", "True", "False"], typed list<boolean>. Scalar and symbolic comparisons are unchanged (x > 0 stays symbolic). For = and != the elementwise form applies only when exactly one operand is a collection — comparing two collections remains a whole-value equality (see Breaking Changes).

  • Broadcast results now report an honest list<…> type. A broadcastable numeric operator applied to a list operand produces a list value, and its declared type now says so: Sin([t, 1]) is typed list<finite_number> (previously the scalar finite_number, contradicting the value), and [1,2] \cdot 2 / [1,2] + x report vector<2> rather than a scalar or a number | vector<2> union. Code that inspects .type before evaluating no longer needs to special-case list-broadcast expressions.

  • When broadcasts over a list-valued condition. A domain restriction whose condition is a finite list of booleans now masks element by element — the Desmos restriction semantics. x^2\{[1,2,3] > 0\} evaluates to [x^2, x^2, x^2], and with x = 2, x\{x \le [1,2,3]\} evaluates to [Undefined, 2, 2] (one masked branch per element: the value where the element condition is True, Undefined where False, a held When where the element is still symbolic). When the restricted expression is itself a list, the two are zipped elementwise, truncating to the shorter. Scalar restrictions (x^2\{x > 0\}) are unchanged, and the result type is lifted to list<…> only when the condition's type is a list of booleans.

Parsing and Serialization​

  • Fixed: bracket indexing after a symbol with \left[ delimiters. A\left[1\right] silently dropped the bracket group and parsed as bare A; it now parses to ["At", "A", 1] like A[1] always did. Indexing also works on parenthesized groups and function applications: (3,4)[1] and f(x)[i] parse to At expressions.

  • Numbers with a leading or trailing decimal dot parse correctly. .85x parses as 0.85 x, and a trailing-dot literal inside delimiters ((1., 2)) is accepted.

  • Scaling a list/vector by juxtaposition is a Multiply, not a Tuple. A scalar written next to a list- or vector-typed operand — including a scaled fraction whose numerator is a list or range, as in Desmos' 2\frac{[0,...,8]}{8} — now canonicalizes to Multiply (element-wise scaling). Previously such juxtapositions produced a spurious Tuple, which raised an incompatible-type error when the result was used in further arithmetic. Genuine tuples (2(3, 4)) and plain list literals ([1,2,3]) are unaffected.

  • Restriction braces attach across visual space. A \{...\} domain-restriction suffix now attaches to its base expression even when separated by spacing commands: s(t) = (1-t)^2(1+2t)\ \{t\ge0\}\{t\le1\} parses to a When with both conditions. The space-tolerance is specific to restriction braces: a space before an indexing bracket (x\ \left[1,2\right]) is still not an index access.

  • New inert Polygon operator. \operatorname{polygon}((0,0),(1,0),(0,1)) parses to ["Polygon", ...], an opaque geometric primitive like Triangle and Segment, for consumers that render it.

  • histogram, pdf, cdf, length, and nCr parse to Histogram, PDF, CDF, Length, and Choose. The lowercase \operatorname{...} forms used by Desmos are now aliases of the existing operators. The member form .length (S.\operatorname{length}) also maps to Length, joining .count, .max, .min, .total, and the .x/.y/.z component accessors. Histogram and BinCounts accept any number as their bin specification (a non-integer bin count is left unevaluated; translate a Desmos bin width to explicit bin edges at the import boundary).

  • New digits serialization option for significant-figures and decimal-place display control. Available on expr.toLatex(), expr.toMathJson(), and honored by expr.toString(), digits controls how many digits of a number are displayed (a formatting choice — it does not change the stored value or computation precision):

    • digits: { significant: n } rounds to n significant figures (ce.parse("\\pi").N().toLatex({ digits: { significant: 3 } }) → 3.14). Rounding is independent of notation (1500 at two significant figures stays 1500 in fixed notation; use notation: "scientific" for 1.5 \cdot 10^{3}), and exact integers, rationals, and radicals are shown in full — only inexact values are rounded.
    • digits: { fractional: n } shows n digits after the decimal point (toFixed semantics), and digits: "auto" / "max" behave as before.
    • The fractionalDigits option is deprecated in favor of digits. It continues to work (a numeric n is equivalent to digits: { fractional: n }); if both are provided, digits wins.
  • The pipeline operator |> supports a topic marker and a prefix form. A \square in the right-hand side marks where the piped value is substituted, so the right-hand side may be a multi-argument call: x^2 + 2x + 1 |> \operatorname{Solve}(\square, x) parses to Solve(x^2+2x+1, x). Without a marker the value is passed as the sole argument, as before (x |> f → f(x)). A prefix |> f (or |> \operatorname{Solve}(\square, x)) leaves the left-hand side implied and yields an anonymous unary function over the topic (Function(Apply(f, _), _)), which the caller applies to whatever value it wants to pipe in. \rhd, \triangleright, and ⊳ behave identically.

Runtime and Scoping​

  • Declare now accepts an optional initial value. The three-operand form ["Declare", symbol, type, value] declares the symbol with the given type, sets its initial value, and evaluates to that value (the previous form evaluated to Nothing). This matches the documented signature; earlier the value operand was silently dropped. The one- and two-operand forms are unchanged. A value-carrying Declare also compiles correctly (the initializer is emitted for the JavaScript and GLSL targets), not just when evaluated.

  • Declare can attach definition attributes via a trailing dictionary, including declaring constants. An optional final Dictionary operand carries any of type, value, constant, and holdUntil, mirroring the JavaScript ce.declare(name, def) API. For example, ["Declare", "c", "real", 299792458, ["Dictionary", ["KeyValuePair", "constant", "True"]]] declares an immutable constant (a later Assign to it is rejected), and holdUntil controls when the symbol's value is substituted (as for built-in constants such as Pi). A positional type/value takes precedence over the same key in the dictionary. This gives MathJSON a representation for constant declarations (e.g. the target for a const keyword in a surface language).

Control Flow​

  • Loop is now imperative control flow only (see Breaking Changes above), and ["Loop", body] is a real infinite loop: the body is evaluated repeatedly until it yields a ["Break", value?] (the loop's value) or a ["Return", …] (propagated), guarded by ce.iterationLimit and the evaluation deadline. Previously this form — documented as while(true) — evaluated the body only once. It compiles to while (true) { … } in JavaScript, and Loop with Element clauses compiles to plain for / for…of statement loops with no result array.

  • New Comprehension operator: value-producing list comprehensions. ["Comprehension", body, ["Element", x, xs], …] evaluates body for each combination of one or more Element clauses and collects the results into a List. Independent clauses produce a flat Cartesian product; a later clause's collection may reference an earlier binding ([…, ["Element", "x", ["Range", 1, 3]], ["Element", "y", ["Range", 1, "x"]]] iterates the triangle). Bound names do not leak. With a single clause it is equivalent to Map(xs, x ↦ body); unlike Map (lazy) it materializes its result. Compiles to JavaScript as nested array-collecting loops (not available on the GLSL/WGSL targets, which have no dynamic arrays).

  • Break and Continue are now registered operators. Break(value?) exits the enclosing loop immediately and its optional value becomes the loop's value; Continue() skips to the next iteration. Outside a loop both are inert.

  • Control flow now propagates out of Block statement results. A Break, Continue, or Return produced by a statement's result — e.g. ["If", cond, ["Break"]] — now short-circuits the enclosing Block and propagates to the enclosing loop or function, as the documentation always specified. Previously only a statement that was literally one of those heads short-circuited, so a conditional Break inside a block was silently discarded and the loop ran to the iteration limit. Consequences: the while-loop lowering ["Loop", ["Block", ["If", cond, ["Break"]], …body]] now terminates correctly, and a Block whose value is a Return evaluates to the ["Return", value] expression itself (unwrapped at the function application boundary), where it previously unwrapped eagerly.

  • If without an else branch is fixed. ["If", cond, then] — the documented two-operand form — failed to canonicalize (throwing Cannot read properties of undefined) and was left inert. It now canonicalizes and evaluates to Nothing when the condition is false.

  • Nested scopes now see the enclosing block's variables (lexical scoping fix). A Block, If branch, or Loop body nested inside a Block resolved symbols against a stale canonicalization-time scope, so it could not read the values of the enclosing block's locals: ["Block", ["Declare", "k", "integer"], ["Assign", "k", 7], ["Block", "k"]] evaluated to symbolic k instead of 7, a while-style ["Loop", ["Block", ["If", cond, …], …]] threw Condition must evaluate to "True" or "False", and an Element-clause loop whose body is a Block left the loop variable symbolic (Loop(Block(Assign(s, s + n)), Element(n, Range(1, 5))) produced 5n instead of accumulating 15). Nested scopes now resolve enclosing block locals, loop variables, and — inside a function body — the function's parameters and locals correctly, so while/for lowerings with block bodies evaluate as expected.

  • Re-evaluating a program with Declare statements no longer throws. Evaluating the same Block expression more than once — or a Declare inside a loop body, which re-executes every iteration — threw The symbol "…" is already declared in this scope on the second entry. A Declare statement now resets the binding it created on a previous run of the same scope. Genuine conflicts (redeclaring a function parameter, or ce.declare() on an explicitly declared symbol) still throw.

Benchmarks​

The numeric and symbolic state of this release is summarized below against the last packed comparator release (0.66.0), SymPy, math.js, and Mathematica — the reference baseline, since it is the broadest engine in the field. The tables are generated by the harness in benchmarks/ (node benchmarks/report_changelog.mjs); every result is verified numerically against an independent mpmath reference, never another tool. "CE 0.69.0" is this release.

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE 0.69.0CE 0.66.0SymPymath.jsMathematica
\pi^25.97.91761043.9
\sin 120202224425.2
\cos 120202244557.1
\ln 213813394,3153.8
e^{\pi}12232134,7874.0
\zeta(3)1,5423,395268—49
\Gamma(\tfrac13)830—354—214
\psi(\tfrac13)725—2,810—172

Biggest gains over 0.66.0: \ln 2 6.1× faster, \zeta(3) 2.2× faster.

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case; ✓ means it solves a case Mathematica can't. Compare the CE 0.69.0 and CE 0.66.0 columns to see what is new this release (a — under 0.66.0 next to a number under the current build). The CE + R/F column is the current build with the opt-in Rubi integrator + Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle.

OperationCE 0.69.0CE + R/FCE 0.66.0SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx6.8×3.1×7.5×0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx11×1.7×10.0×0.08×—1×
\int\frac{1}{x^3+1}\,dx6.3×0.9×6.7×0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—2.1×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—1.4×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—1.3×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.03×0.03×0.03×0.001×0.004×1×
Simplification
\sqrt{3+2\sqrt2}46×30×41×——1×
\sqrt6\,x+\sqrt2\,x98×58×97×3.1×19×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}55×25×56×3.1×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x9.7×6.0×5.1×2.1×—1×
\int_1^2\tfrac1x\,dx7429×7782×7935×90×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx459×153×586×2.5×—1×
Solving
x^4+x^2-1=00.3×0.2×0.1×0.06×—1×
x^3-x-1=01.9×2.0×0.2×0.04×—1×

Across the cases both solve, Compute Engine is a median 6.8× faster than Mathematica (up to 7429×).

Measured 2026-07-08 · Compute Engine 0.68.0 @ 5a2abce1 (current build) · published 0.66.0 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 for Mac OS X ARM · Node v22.13.1. Correctness is verified numerically against an independent mpmath reference, never another tool. Reproduce with npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

0.68.0 2026-07-05​

Breaking Changes​

  • The ESM builds are no longer single-file: they load a shared chunk from dist/chunks/. compute-engine.esm.js, compute-engine.min.esm.js and the corresponding integration-rules bundles are now built with code splitting, so the engine core is emitted once into a chunks/chunk-*.js file that both entry points import (this fixes instanceof failures when the integration-rules plugin is loaded alongside the main library, which previously carried its own duplicate copy of the engine). If you copy compute-engine.min.esm.js out of the package as a standalone file — for example to vendor it or serve it from your own static assets — you must now copy the chunks/ directory alongside it, preserving the relative layout. Installing the package from npm, importing it from a bundler, or loading it from a CDN that serves the whole package (jsDelivr, unpkg, esm.sh) is unaffected. The .umd.cjs builds remain self-contained single files if you need a copyable artifact.

    Note that the chunk is required even if you don't use the integration-rules plugin: it contains the shared engine core, not the rule data. To vendor the Compute Engine without the optional rule corpora, copy compute-engine.min.esm.js plus the chunks/ directory and omit integration-rules.* (the Rubi corpus) and identities.* (the Fungrim corpus) — neither is loaded unless you import it explicitly. Each entry point imports exactly one chunk, so if you only ship the minified build you only need one of the two chunk files — the smaller one (the minified chunk), or definitively the one named in the entry file's first import statement. Just remember the names contain a content hash that changes between releases. All other sub-path bundles (core, latex-syntax, numerics, compile, interval, identities) remain self-contained. Or.** AB \parallel CD is the parallelism relation, consistent with \perp → Perpendicular. Use \lor or \vee for disjunction (unchanged).

  • \rightarrow now parses to the mapping arrow To, not Implies. f: \mathbb{R} \rightarrow \mathbb{R} now parses as a function signature, matching \to. This reverses the mapping introduced for issue #156: \rightarrow-as-implication was far rarer in practice than \rightarrow-as-mapping. Use \Rightarrow, \implies, or \Longrightarrow for implication (unchanged).

New Operators​

  • Series, BigO, and Normal provide symbolic series expansion. Series(f, x, x0, n) returns the Taylor expansion of f in x about x0 (default x0 = 0) up to and including the power n (default n = 5), plus an explicit remainder term. x0 may be ±∞ for an asymptotic expansion in powers of 1/x.

    • Series(\sin x, x) → x - \tfrac{x^3}{6} + \tfrac{x^5}{120} + O(x^7); Series(\ln(\cos x), x) → -\tfrac{x^2}{2} - \tfrac{x^4}{12} + O(x^6); Series(\arctan x, x, +\infty) → \tfrac{\pi}{2} - \tfrac{1}{x} + \tfrac{1}{3x^3} - \dots. Coefficients are exact (Series(\sin x, x, \frac{\pi}{6}) gives \tfrac12, \tfrac{\sqrt 3}{2}, …), and an undeclared f yields the textbook form f(0) + f'(0)x + \dots.
    • At a pole the result is a Laurent expansion with a finite principal part: Series(\frac{1}{\sin x}, x) → \tfrac{1}{x} + \tfrac{x}{6} + \tfrac{7x^3}{360} + O(x^7), Series(\cot x, x) → \tfrac{1}{x} - \tfrac{x}{3} - \tfrac{x^3}{45} + \dots, and the special functions expand at their poles with exact coefficients — Series(\Gamma(x), x) → \tfrac{1}{x} - \gamma + (\tfrac{\gamma^2}{2} + \tfrac{\pi^2}{12})x + \dots, Series(\zeta(x), x, 1) → \tfrac{1}{x-1} + \gamma + O(x-1). Poles at ±∞ are handled too (Series(\frac{x^2}{x-1}, x, +\infty) → x + 1 + \tfrac1x + \tfrac1{x^2} + \dots). An essential singularity or branch point (e.g. Series(e^{1/x}, x), Series(\ln x, x)) is still left unevaluated rather than expanded incorrectly.
    • BigO(u) is the inert Landau remainder, serialized O\left(u\right) and parsed from \mathcal{O}(u) and \operatorname{O}(u). It is inert under evaluate/simplify; a numeric approximation (.N()) of any expression containing it is NaN.
    • Normal(expr) strips the BigO terms, yielding the compilable/plottable truncated polynomial: Normal(Series(\sin x, x)) → x - \tfrac{x^3}{6} + \tfrac{x^5}{120}.
  • TrigExpand, TrigToExp, and TrigReduce rewrite trigonometric and hyperbolic expressions. These are transformation verbs in the spirit of Expand/Factor and preserve exactness.

    • TrigExpand expands functions of sums and integer multiples of angles: TrigExpand(\sin(a+b)) → \sin a\cos b + \cos a\sin b and TrigExpand(\cos(2x)) → \cos^2 x - \sin^2 x (hyperbolic analogs, and \sec/\csc/\cot as reciprocals of the expanded \cos/\sin, are also handled).
    • TrigToExp rewrites trigonometric and hyperbolic functions in terms of the complex exponential, exactly: TrigToExp(\sin x) → -\tfrac{i}{2}e^{ix} + \tfrac{i}{2}e^{-ix}.
    • TrigReduce is the inverse of TrigExpand, rewriting products and integer powers as functions of multiple angles: TrigReduce(\sin^2 x) → \tfrac{1 - \cos 2x}{2} and TrigReduce(\sin x\cos x) → \tfrac{\sin 2x}{2}.
  • Probability distributions: NormalDistribution, BinomialDistribution, PoissonDistribution, UniformDistribution, ExponentialDistribution, consumed by the generic PDF, CDF, and Quantile operators. A distribution is a first-class value — assign it, pass it around, query it:

    • PDF(dist, x), CDF(dist, x) and Quantile(dist, p) evaluate to exact closed forms: CDF(NormalDistribution(0, 1), x) → \tfrac12\left(1 + \operatorname{erf}\tfrac{x}{\sqrt2}\right), an ordinary expression that can be simplified, differentiated, compiled and plotted. Exact arguments give exact results — PDF(BinomialDistribution(4, \tfrac12), 2) → \tfrac38 — and .N() numericizes at machine or arbitrary precision.
    • For discrete distributions PDF is the probability mass function, and Quantile (the least k with \operatorname{CDF}(k) \ge p) is computed by exact search: Quantile(PoissonDistribution(9), 0.95) → 14.
    • Mean, Variance, and StandardDeviation now also accept a distribution: Mean(NormalDistribution(\mu, \sigma)) → \mu, Variance(BinomialDistribution(n, p)) → np(1-p).
    • NormalDistribution(\mu, \sigma) takes the standard deviation (not the variance), and ExponentialDistribution(\lambda) the rate — the Mathematica and scipy conventions.
  • GammaRegularized and BetaRegularized — the regularized incomplete gamma and beta functions. GammaRegularized(a, z) is Q(a, z) = \Gamma(a, z)/\Gamma(a) and BetaRegularized(x, a, b) is I_x(a, b). They follow the exactness contract (special values fold — GammaRegularized(1, z) → e^{-z} — and exact arguments stay symbolic), evaluate numerically at machine and arbitrary precision, and compile to JavaScript and Python (scipy.special.gammaincc/betainc). The discrete distribution CDFs evaluate to closed forms in these functions, e.g. CDF(PoissonDistribution(\lambda), k) → \operatorname{GammaRegularized}(k+1, \lambda).

  • Covariance, PopulationCovariance and Correlation measure the relationship between two data sets. Each accepts either two equal-length collections or a single collection of (x, y) pairs (a scatter of points). Exact data gives exact results — Covariance([1,2,3,4,5], [2,4,5,4,5]) → \tfrac32 and the Pearson correlation of the same data is \tfrac{\sqrt{15}}{5}, exactly. Covariance uses the sample (n-1) convention, PopulationCovariance the population (n) convention, matching Variance/PopulationVariance. Parse aliases: \operatorname{cov} and \operatorname{corr}. Both compile to JavaScript and Python (np.cov/np.corrcoef).

  • LinearRegression and PolynomialFit compute least-squares fits. LinearRegression(xs, ys) (or a collection of points) evaluates to (intercept, slope), and PolynomialFit(data, degree) to the list of coefficients, constant term first. Exact data yields exact coefficients: points lying on 1 + x^2 fit at degree 2 to exactly [1, 0, 1], and rational data produces exact rational coefficients rather than floats. With a trailing variable argument the fitted expression is returned directly, ready to plot: PolynomialFit([(0,1), (1,2), (2,5), (3,10)], 2, x) → x^2 + 1.

  • Quantile computes empirical quantiles of data. Quantile(collection, p) interpolates the sorted data so that Quantile(xs, 1/4), Quantile(xs, 1/2) and Quantile(xs, 3/4) agree exactly with Quartiles and Median (Moore–McCabe convention), with general p interpolated through the order statistics in rank space. (Combined with the distribution form above, Quantile covers both the theoretical and the empirical case.)

  • Divides and NotDivides express divisibility. a \mid b parses to Divides(a, b) and p \nmid ab to NotDivides(...); both evaluate for concrete integers (Divides(3, 12) → True) and stay symbolic otherwise.

  • Geometry notation is transcribed as inert heads. \angle ABC → Angle(A, B, C) (also \varangle, ∠), \triangle ABC → Triangle(A, B, C), \square ABCD → Quadrilateral(A, B, C, D), A \perp B → Perpendicular, AB \parallel CD → Parallel, \widehat{ABC} → Arc, \overparen{BC} → OverParen, and \langle a, b \rangle → AngleBracket. These heads have no evaluation semantics — the Compute Engine does not model geometry — but they parse and serialize faithfully so downstream consumers (e.g. graphical clients) get the structure instead of an error. Angle and arc measures are typed as numbers, so \angle A + \angle B + \angle C = 180^\circ composes in arithmetic.

  • \sim parses to the generic similarity relation Tilde. It covers triangle similarity (\triangle ABC \sim \triangle DEF), asymptotic equivalence, and "is distributed as" (X \sim N(0, 1)); \nsim negates it, and \simeq now maps to the existing TildeEqual head (it previously had no LaTeX trigger).

Step-by-Step Explanations​

  • expr.explain() returns a structured, step-by-step explanation of a simplification — the textbook chain expression → step (with a reason) → … → result. Each step carries the expression state after the step, a stable machine id (the localization key for consumers), and a default English description; explain().result is always the same value simplify() returns. ce.parse('\\frac{x^2-1}{x-1}').explain() yields one step, "Cancel the common factors", ending at x + 1.
    • The step chain is curated by default (driver bookkeeping is filtered out); pass {verbosity: 'all'} for the raw trace (rule authoring, debugging). simplify() options (rules, costFunction, strategy) are honored.
    • The most frequently fired simplification rules ship with curated descriptions ("Apply the Pythagorean identity: sin²x + cos²x = 1", "Combine powers with the same base: xⁿ·xᵐ = xⁿ⁺ᵐ", …); other rules get a readable fallback derived from the rule id. registerStepLabels() lets a host application override or extend the descriptions.
    • expr.explain('solve') traces equation solving. Step values are equations — the state after each phase — so the chain reads like textbook working: 2x+1=5 → Move all terms to one side 2x-4=0 → Isolate the unknown x=2. The trace covers the solver's algorithmic phases (clearing denominators, squaring both sides, substitutions like u = eˣ with back-substitution, zero-product factoring, the quadratic formula, checking candidates and rejecting extraneous roots) and the root-template rules, which now carry stable solve.* ids. explain('solve').result is a List of the same roots solve() returns; the unknown is inferred or passed via options.variable. Systems of equations are not traced yet.
    • expr.explain('D') traces differentiation. Steps are whole-expression states in traversal order — each textbook rule (sum, product, quotient, power, chain, exponential, logarithmic differentiation, table lookups) first appears with its unresolved sub-derivatives as inert D(…) terms, which resolve step by step: D(x·sin x, x) → Apply the product rule x·D(sin x, x) + sin x → Differentiate using a known derivative x·cos x + sin x. The variable is inferred when unambiguous (or passed via options.variable), and the result always matches evaluating D(expr, variable).

Solving​

  • Solve accepts a domain for the unknown. Solve(x^2-5x+6=0,\; x \in 1..1000) restricts solutions to a collection: the equation is solved symbolically and the roots are filtered to the domain (an integer domain also discards non-integer roots up front). When the symbolic solver finds nothing and the domain is finite and reasonably sized, Solve falls back to enumeration with a compiled predicate, confirming every candidate exactly so float rounding never produces a wrong answer (budgeted, interruptible; an unaffordable search returns the expression unevaluated rather than a partial answer). The predicate is not limited to equations — any boolean condition works: Solve(2^n \equiv 1 \pmod{7},\; n \in 1..20) → [3, 6, 9, 12, 15, 18], and an extra condition can ride on the domain (n \in 1..100, n > 5-style, as in Sum indexing sets). The two-argument form is unchanged.

  • Multiple unknowns enumerate over the product of their domains. Solve(x^3+y^3=1729,\; x \in 1..12,\; y \in 1..12) → [(1,12), (9,10), (10,9), (12,1)] — a List of Tuples in unknown order, with the same budget, exact-confirmation, and interruption guarantees as the univariate case.

  • Integer equations are solved symbolically (diophantine solving). When every unknown ranges over integers, Solve recognizes linear equations in any number of unknowns and Pell-family equations x^2 - Dy^2 = N (including the elliptic case x^2 + |D|y^2 = N) and solves them in closed form — ported from SymPy's diophantine module and validated against its test suite. Over a bounded domain this reaches answers enumeration cannot: Solve(x^2-29y^2=1,\; x \in 1..10^5,\; y \in 1..10^5) → [(9801, 1820)] via continued fractions, where the 10^{10}-candidate sweep would be refused; an unsolvable equation is decided instantly (Solve(6x+9y=4,\; x \in \pm10^6,\; y \in \pm10^6) → []). With integer-typed unknowns and no domain — previously inert — Solve returns the parametric family: Solve(3n+4m=7, n, m) → [(4t-7,\; -3t+7)] with the fresh parameter t ranging over ℤ, and Pell equations yield their exact closed forms \bigl(\tfrac{(3+2\sqrt2)^t + (3-2\sqrt2)^t}{2}, \dots\bigr), and Pythagorean triples return the complete classical parametrization: Solve(x^2+y^2=z^2, x, y, z) → \bigl(t(t_1^2-t_2^2),\; 2t\,t_1 t_2,\; t(t_1^2+t_2^2)\bigr) and its leg-swap — every integer triple, including all signs, lies in one of the two families. Every concrete solution is exact-confirmed by substitution; half-bounded domains (e.g. n \ge 1 alone) are left unevaluated, and forms whose textbook parametrizations are provably incomplete (weighted coefficients, four or more squares) are declined rather than answered partially.

  • Periodic equations expand their root families over a bounded domain. Solve(\sin x = \tfrac12,\; x \in [0, 4\pi]) returns all four exact solutions \tfrac{\pi}{6}, \tfrac{5\pi}{6}, \tfrac{13\pi}{6}, \tfrac{17\pi}{6} — not just the principal values. Scaled arguments work too (\sin 2x = 1 over [0, 2\pi] → \tfrac{\pi}{4}, \tfrac{5\pi}{4}). Expansion applies when the unknown appears only inside trigonometric functions of linear arguments; each family member is verified by exact substitution, and unreasonably large expansions degrade gracefully to the principal roots.

  • assume() bounds now filter solutions. After assume(n > 0), Solve(n^2 = 16, n) (and expr.solve("n")) returns [4] instead of [4, -4]; assume(n \in 1..10), inequality, and \ne assumptions are honored the same way, conjunctively with any explicit domain. Roots are dropped only when an assumption definitely excludes them — symbolic roots that cannot be decided are kept.

Parsing Resilience​

The parser was hardened against a corpus of ~2,300 math fragments extracted from real olympiad problems (the MathNet dataset); the clean-parse rate on that corpus went from 85% to ~96%, and the one crash it exposed is fixed. See docs/mathnet/ for the corpus, the regression checker, and the work plan.

  • Ellipsis in a numeric context no longer throws. (1!)^2 + (2!)^2 + \dots + (2018!)^2 crashed with The type of the constant "ContinuationPlaceholder" cannot be changed (type inference attempted to narrow a constant). Inference is now a no-op on constants.

  • \cdots, \dotsb, \dotsc, \dotsm, and Unicode … parse as ellipsis. Previously only \dots/\ldots/... did; (2!+2)(3!+3) \cdots (2019!+3) now parses with the placeholder as an inert operand instead of erroring.

  • A trailing sentence period no longer breaks an equation. Input copied from prose often ends in ., ; or , (e.g. ... = z^2.). When — and only when — the parse would otherwise contain an error, the trailing punctuation is dropped and the input re-parsed. Valid input is unaffected: 5. still parses as the decimal 5.

  • Congruences parse and evaluate. a \equiv b \pmod{n} (also \bmod, the parenthesized (\bmod n), and the ASCII form n ≡ 1 (mod 3)) parse to Congruent(a, b, n), which evaluates for concrete integers (7 \equiv 1 \pmod{3} → True) and now accepts symbolic moduli (2^n \equiv 1 \pmod{p^{k+1}} stays symbolic instead of erroring).

  • Common Unicode math symbols are accepted: ≡ (congruence), ∈, ∉, ∪, ∩, ≈, ∠, and … — useful when input comes from plain-text sources rather than LaTeX.

  • Alignment environments parse as systems. \begin{aligned} a^2+ab+c=0 \\ b^2+bc+a=0 \end{aligned} (also align, gather, split, multline, eqnarray and their starred variants) parses to a List of the row expressions — the same convention as \begin{cases}, accepted by solve(). Alignment markers are transparent: x &= y is x = y.

  • Qualified number sets parse. \mathbb{R}_{>0} → PositiveNumbers, \mathbb{Z}_{\ge0} → NonNegativeIntegers, \mathbb{N}^* → PositiveIntegers, etc., and they round-trip to canonical LaTeX. A qualification with no named set (\mathbb{N}_{>1}) falls back to a faithful set-builder.

  • Structural odds and ends: A \backslash B parses as SetMinus (the common alternative spelling of \setminus); a standalone quantified condition \forall n \ge 1 parses instead of erroring; \underbrace mirrors \overbrace.

  • A symbol's inferred type narrows instead of erroring. When a free symbol's type was inferred from one use and a later use requires a more specific type, argument validation now narrows the inference (when sound) instead of producing an incompatible-type error. This fixes (A \setminus B) \cup (B \setminus A) — where B was inferred as a value and then rejected as a set — as well as -n!! (double factorial of an undeclared symbol) and a family of similar mixed-use expressions. Declared types are unaffected: passing a declared string where a set is required is still an error.

Packaging​

  • The integration-rules plugin shares code with the main library. The ESM builds of compute-engine and the opt-in @cortex-js/compute-engine/integration-rules entry point are now emitted with code splitting: the engine core lives in a shared chunk imported by both, instead of being bundled twice. This shrinks the combined download and fixes cross-bundle instanceof failures when a host mixed objects from the two bundles. The UMD builds remain self-contained single files.

Lenient parsing and string helpers​

  • The string helpers take a strict option. simplify(), evaluate(), N(), expand(), expandAll(), factor(), solve(), and compile() parse string input in lenient (non-strict) mode by default. Note that lenient mode is not a pure superset of strict LaTeX: unbraced multi-digit scripts change meaning — x^23 is x^{23} (not 3x^2), and x_23/a_12 are single multi-digit subscripts. Pass { strict: true } (e.g. N('x^23', { strict: true })) to restore the strict LaTeX grammar.

  • Lenient inverse functions, atan2, and letter runs parse correctly. sin^-1 x now means \arcsin x (the inverse function), not 1/\sin x (matching strict \sin^{-1}); sin^-2 x stays 1/\sin^2 x. atan2(1, 2) parses as Arctan2(1, 2), and acot/asec/acsc are recognized. A multi-letter run with an embedded Greek constant is segmented (2pix → 2\pi x, xpi → x\pi) instead of injecting a spurious imaginary unit, and an implicit subscript is accepted on a constant base (alpha2 → \alpha_2).

Differential Equations​

  • Repeated roots produce correct general solutions. DSolve now clusters numeric characteristic roots by multiplicity: y'''' + 2y'' + y = 0 gives (c_1 + c_2 x)\cos x + (c_3 + c_4 x)\sin x instead of a degenerate basis with spurious e^{\varepsilon x} factors, and repeated real roots keep their x e^{x} modes. A structural self-check returns the equation unevaluated rather than emit a basis with fewer independent solutions than the order.

  • No more corrupted solutions. Equations with variable coefficients on higher-order derivatives (e.g. x^2 y'' + x y' = x) previously returned a "solution" containing an internal Error node; they now stay unevaluated when the class is unsupported. Equations whose right-hand side references the dependent function with a transformed argument (e.g. y'(x) = y(2x)) stay unevaluated instead of returning an unevaluated integral as "solved".

  • Exponential forcing terms solve. Variation of parameters was silently disabled for exponential bases (an internal Wronskian stayed unsimplified): y'' - y = e^x now returns c_1 e^x + c_2 e^{-x} + \frac12 x e^x - \frac14 e^x, and y'' + y = e^x returns c_1 \cos x + c_2 \sin x + \frac12 e^x, instead of the equation unevaluated. Solutions are returned in collected form (no e^a \cdot e^b products or A\sin^2 u + A\cos^2 u pairs).

  • Parsed LaTeX input works end-to-end. ce.parse("y''(x)+y(x)=0") no longer canonicalizes the derivative of an undeclared function into an Error node: a derivative now reports a numeric result type, so prime/dot-notation equations flow from parse() through DSolve (\dot x + \ddot x expressions are likewise no longer corrupted). The implicit first-order form Apply(Derivative(y), x) is also recognized (order defaults to 1).

Evaluation​

  • Beta is exact and pole-aware. \mathrm{B}(a, m) with a positive integer argument reduces exactly (\mathrm{B}(2,3) = \frac{1}{12}, \mathrm{B}(-2,2) = \frac12), and arguments at gamma-function poles return \tilde\infty instead of a silently wrong finite value (\mathrm{B}(-1,2) previously returned -2.97\times10^{49}).

  • Multiplication by infinity respects sign information. x \cdot \infty stays symbolic when the sign of x is unknown, evaluates to -\infty when x is known negative, and to NaN when x is zero — it no longer collapses to +\infty unconditionally.

  • Inverse hyperbolic functions have values at their poles. \operatorname{artanh}(\pm 1) and \operatorname{arcoth}(\pm 1) evaluate to \pm\infty, \operatorname{arsech}(0) to +\infty, and \operatorname{arcsch}(0) to \tilde\infty, with result types that no longer claim a finite value at a pole.

  • Sum reports incompatible elements. Summing a collection containing a string returns a typed error instead of a silent NaN.

  • Sums and products over an infinite domain stay symbolic under evaluate(). An infinite domain has no exact value by truncation, so \sum_{n=1}^{\infty} \frac{1}{n^2} now evaluates to itself; .N() returns the truncated numeric approximation, as before. Previously evaluate() returned a silently truncated partial sum (off by \sim 10^{-4} for this example) — a float where the exactness contract promises an exact value.

  • Sums and products with symbolic bounds no longer evaluate to a number. \sum_{k=1}^{n} k with an unbound n evaluated to 50\,015\,001 — the sum truncated at an internal iteration cap of 10\,001 — under both evaluate() and .N(). It now stays symbolic (simplify() still produces the closed form \tfrac{n^2+n}{2}).

  • Expand computes constant powers. Expand((2+3i)^{1000}) returns the exact 557-digit Gaussian integer (matching SymPy's expand()), and Expand(2^{1000}) the exact integer; both previously returned unevaluated. Structural expansion of symbolic powers is unchanged, and powers too large for exact computation still stay symbolic.

  • Huge exact complex numbers are finite and print in full. An exact Gaussian integer with components beyond float64 range (e.g. (2+3i)^{1000}) reported isInfinity true, serialized as \tilde\infty in plain text, and had a NaN bignumIm — all artifacts of routing through the machine-float projection. It now types as finite_complex, prints its full digits, and bignumIm is exact.

  • Perfect-power radicands reduce. (997^3)^{1/6} = \sqrt{997}, 8^{1/6} = \sqrt2, 8^{1/4} = 2^{3/4}: when canonicalization folds a power into an opaque integer, the root now recovers the structure by perfect-power decomposition. In particular the zero-equivalence test \sqrt{997} - (997^3)^{1/6} evaluates to exact 0 (it previously leaked a float residue).

  • Logarithms reduce when the argument and base are powers of a common base. \log_8 32768 = 5, \log_8 2 = \tfrac13, \log_4 8 = \tfrac32 — exactly, honoring the exactness contract (\ln 2 and \log_8 10 stay symbolic).

  • Xor cancels repeated operands. a \oplus a = \mathrm{False}, so Xor(x, y, y) evaluates to x; cancellation composes with the existing True/False folding.

Linear Algebra​

  • 3×3 Eigenvalues returned wrong values — fixed. The analytic solver used a sign-flipped term in its depressed cubic, mirroring every eigenvalue about \operatorname{tr}/3: e.g. [[5,-3,-7],[-2,1,2],[2,-3,-4]] returned \{\tfrac{10}{3}, -\tfrac53, \tfrac13\} instead of \{1, -2, 3\}. (Spectra symmetric about their mean — like \{1,2,3\} — were unaffected, which is how it escaped notice.) Additionally, a complex-conjugate eigenvalue pair was returned as its real part twice (\{2, \pm i\} came back \{2, 0, 0\}); complex eigenvalues are now returned as complex numbers.

Rules and Pattern Matching​

  • Rule conditions must return a boolean. A condition function returning a non-boolean (e.g. the boxed symbol False, which is a truthy JavaScript object) no longer fires the rule; a one-time console warning identifies the malformed condition. Returning the boxed symbol True is accepted.

  • e and i work in string rules. Rules such as 'e^2 -> 7' now match: the constants are resolved to ExponentialE and the imaginary unit when the rule is parsed, instead of remaining inert symbols that could never match.

  • Explicit wildcards work in LaTeX match patterns. An object-form rule such as {match: '_a + 1', replace: '_a'} now parses _a/__a as wildcards instead of an implicit product.

  • A throwing condition no longer discards subexpression rewrites. If a rule condition throws, the rule is skipped at that node but successful rewrites of the operands are kept.

0.67.0 2026-07-03​

This release improves correctness and predictability across the public Compute Engine API: exact complex and integer arithmetic stays exact more often, partial derivatives and assumptions are more capable, LaTeX and lenient parsing round-trip more reliably, compiled output agrees more closely with interpreted evaluation, and arbitrary-precision arithmetic is substantially faster. It also fixes many cases where evaluate(), N(), simplify(), isEqual(), assume(), verify(), serialization, or compilation could return a wrong answer, lose exactness, hang, or silently accept invalid input.

Exact and Numeric Evaluation​

  • Exact complex arithmetic preserves exact values. Gaussian integer and rational complex values now stay exact through arithmetic: (1+i)^3 evaluates to -2+2i, (1+i)^{-2} to -\frac{i}{2}, \frac{1}{1+i} to \frac{1-i}{2}, \sqrt{3+4i} to 2+i, and \sqrt{-4} to 2i. Exact complex numbers also round-trip through MathJSON as ["Complex", re, im] with exact components.

  • Integer powers and large integers stay exact. Integer powers such as 2^{127} now evaluate to exact integers, negative integer powers produce exact rationals such as 2^{-2} = \frac14, and powers of Gaussian integers such as (1+i)^2 evaluate exactly. Very large exact integers are no longer rounded when used by IsPrime, IsOdd, IsEven, FactorInteger, Mod, or DigitSum.

  • Exact results are preserved more consistently. evaluate() no longer turns exact arguments into floats in cases such as \sqrt{-2}, \operatorname{Fract}(\frac12), \Re(\frac12), |1+i|, \log_2(\pi), Distance, and statistics functions. For example, \operatorname{Mean}([1,2,3,4]) now returns \frac52, and \operatorname{StandardDeviation}([1,2,3,4]) returns \frac{\sqrt{15}}{3}.

  • Special functions are more accurate. PolyGamma, Zeta, BesselI, BesselK, Airy functions, logarithms, roots, trigonometric functions near zeros and poles, LambertW, acos, erfInv, Hypergeometric2F1, Gamma, Beta, Fresnel integrals, and complex elementary functions have improved numeric accuracy, including at high precision.

  • Negative logarithms and complex logarithms are consistent. Inexact negative arguments now produce the principal complex value under both evaluate() and N(). Exact negative arguments stay symbolic under evaluate() and produce the principal complex value under N(). Logarithms with a complex argument and explicit base now agree between evaluate() and N().

  • Roots and radicals are more reliable. Exact perfect powers such as 64^{1/3} and (27/8)^{1/3} evaluate exactly, Root(64, 3).N() returns exactly 4, odd roots of negative numbers keep the real-root convention, and N(\sqrt{4y}) now returns 2\sqrt{y} instead of dropping the radical.

  • Sums and infinite sums behave better. Exact sums such as \sum_{k=1}^{5}\sqrt{k} now remain exact, while sums over infinite index sets such as \sum_{n \in \mathbb{Z}^+}\frac{1}{n^2} evaluate numerically when appropriate, remain symbolic when parameters prevent evaluation, and respect timeLimit.

Differentiation, Integration, and Simplification​

  • Partial derivatives of multivariate functions now work symbolically. D(f(x, y), x) now represents the partial derivative with respect to the first argument, mixed partials accumulate correctly, and multivariate chain, product, power, and quotient rules compose as expected. For example, D(f(x^2, y), x) returns a symbolic chain-rule result proportional to 2x.

  • Partial-derivative notation parses and evaluates. Forms such as \partial_x f(x,y), \frac{\partial}{\partial x} f(x,y), \frac{\partial^2}{\partial x \partial y} f(x,y), and \frac{\partial^2}{\partial x^2} f(x,y) now parse to D and evaluate correctly.

  • Derivative notation is more robust. Compact derivatives such as d/dx(f(g(x))) preserve unknown-function chain rules, higher-order derivatives round-trip through LaTeX, and \frac{d}{dx}[\sin x] treats the square brackets as grouping rather than a one-element list.

  • Several derivative rules are corrected. Variable-degree radicals such as Root(x, x) differentiate as x^{1/x}, \frac{d}{dx}\operatorname{Mod}(x,5) gives 1 almost everywhere, and D(\operatorname{arcoth}(x), x) returns \frac{1}{1-x^2}.

  • Definite integrals no longer return fabricated closed forms. If no closed-form antiderivative is found, evaluate() keeps the definite integral symbolic instead of substituting bounds into the integrand. N() still computes a numeric value.

  • Default simplification covers more identities. The sine addition identity \sin(x)\cos(y)+\cos(x)\sin(y)=\sin(x+y) now applies in the default simplify() path. Pythagorean identities such as \sin^2 x+\cos^2 x also simplify inside larger sums.

  • Simplification is more exact and branch-aware. Combining powers keeps exact exponents, for example x \cdot x^{\sqrt2} becomes x^{1+\sqrt2}. The simplification of \ln(x^2) now produces 2\ln(|x|) for real x, and identities that require real arguments no longer apply to symbols declared as complex.

  • Some unsafe rewrites were removed. simplify() no longer rewrites |\sin x| as \sin|x|, Arctan2 preserves the correct quadrant, rule conditions such as x \ne 0 require proof rather than assuming unknown symbols satisfy them, and alternating-binomial sum simplifications now check their validity bounds.

  • Differential equation solvers handle higher-order equations. (contributed by KingArth0r) DSolve now solves linear constant-coefficient homogeneous equations of any order via the characteristic polynomial — distinct real, repeated, and complex roots — for example y''(x) = y(x) → [y(x) = c_1·e^x + c_2·e^{-x}] and y''(x) + y(x) = 0 → [y(x) = c_1·cos(x) + c_2·sin(x)]. Roots are kept exact when the characteristic polynomial factors and fall back to numeric roots otherwise. It also solves second-order constant-coefficient nonhomogeneous equations (undetermined coefficients for polynomial forcing, variation of parameters otherwise) and second-order Cauchy–Euler equations. Integration constants are now named c_1, c_2, … (fresh names are chosen if those are already in use). Correspondingly, NDSolve now solves explicit higher-order initial value problems y⁽ⁿ⁾(x) = f(x, y, y', …, y⁽ⁿ⁻¹⁾) by reducing them to a first-order RK4 system, with the initial condition given as a list [y(x0), y'(x0), …]. Equations outside these classes remain inert.

Parsing and Serialization​

  • \binom is supported. \binom{n}{k}, \dbinom{n}{k}, and \tbinom{n}{k} parse to Binomial(n, k), and Binomial serializes back to \binom.

  • LaTeX parsing accepts more common notation. N(...) and D(...) parse as numeric evaluation and differentiation outside quantifier scopes, superscripts on \log, \ln, \lg, and \exp bind to the applied function, and ==, !=, chained \ne, mixed-direction inequality chains, parenthesized relations, and double negation such as x--y now parse with the expected meaning.

  • Lenient parsing is more useful. In lenient mode, digit suffixes parse as subscripts (x2 as x_2), bare known function names apply to the following factor (sin x), log2(8) means \log_2 8, [1,...,10] parses as a range, and \mathbb{Z}^+ parses as PositiveIntegers.

  • The public string helpers accept their documented syntax. simplify(), evaluate(), N(), expand(), expandAll(), factor(), solve(), and compile() now parse string input in non-strict mode, so expressions such as sqrt(5), sin(alpha), and x**2 work as documented.

  • verify() and assume() accept strings. ce.verify('x > 0') and ce.assume('$x > 0$') parse the predicate and report clear errors for unparseable input.

  • MathJSON .json serialization is lossless for more numbers. Exact large integers, 16- and 17-digit values, high-precision complex numbers, exact rational/radical values such as \frac{\sqrt3}{2}, and repeating decimals now round-trip without silently changing value.

  • LaTeX round-trips are improved. Repeating decimals serialize with an overline, sequence expressions no longer serialize as ambiguous adjacent numbers, set-builder notation attaches conditions to the comprehension, and toMathJson({exclude: ...}) honors exclusions for number literals.

Assumptions, Types, and Equality​

  • The numeric type hierarchy is more natural. real is now a subtype of complex, so real-typed symbols satisfy complex-typed signatures and guards. Union types flatten and canonicalize their member order, and type negation now distinguishes never from nothing.

  • Complex and non-finite type inference is more precise. Expressions such as \sqrt2 i, i^2, i/2, i^3, e^i, and \ln(-1) infer more accurate types. Non-finite values such as \tan(\frac\pi2), \Gamma(0), \zeta(1), \ln(0), 0 \cdot \infty, and k/0 no longer claim finite types when that is unsound.

  • Assumptions can prove more facts. Assumptions over signed integer and real sets refine both type and sign. Inequality bounds now affect equality checks, comparisons between bounded symbols, and verify(). Chained inequalities and equations with multiple roots are recorded correctly, contradictory assumptions are rejected atomically, and forget() clears values introduced by assume('x = 5') while preserving values set with assign().

  • Assumptions respect scope. Assumptions made inside a pushed scope no longer leak into parent scopes or continue to affect expression results after popScope().

  • Equality and ordering are more coherent. isSame is now an equivalence relation, isEqual returns undefined for indeterminate equality with free variables, equality and ordering share one tolerance, collection equality uses scalar tolerance semantics, and complex numbers are no longer ordered against real numbers.

  • Set, collection, and statistics behavior is corrected. Intersection, SymmetricDifference, Union, and SetMinus produce correct finite-set results, Reverse([1,2,3]) returns [3,2,1], Quartiles consistently uses the Moore-McCabe convention, and single-argument \operatorname{KroneckerDelta}(0) returns 1.

Compilation​

  • Compiled JavaScript, Python, GLSL, WGSL, and interval output now match the interpreter more closely. Equality uses the engine tolerance, Mod and Remainder use consistent conventions, chained relations evaluate middle operands once, dynamic 0^0 returns NaN, non-boolean Which and When conditions throw, and interval arithmetic matches interpreter conventions for branches, rounding, modulus, and odd roots of negative numbers.

  • Compilation fails closed when a target cannot represent an expression correctly. Unsupported or unsafe cases such as invalid shader constructs, reserved shader variable names, non-real values in real-only target helpers, multi-index sums or products that a target cannot express, and invalid constant folds now fail at compile time instead of emitting wrong code.

  • The Python target emits valid Python for more expressions. Conditional expressions, NaN, logical operators, chained relations, assigned symbols, vars, and target options now compile consistently with the JavaScript target and the interpreter.

Additional Resolved Issues​

  • Evaluation limits are honored more reliably. Hard limits such as nested-exponential limits, divergent infinite sums, and very large special function inputs now return promptly, remain symbolic, or throw a CancellationError when timeLimit or the recursion limit is exceeded. Examples include \lim_{x\to\infty} e^{e^{e^x}}/e^{e^{e^{x-1}}}, \Gamma(10^{300}), \zeta(\pm 10^{300}), \operatorname{Fib}(10^9), \binom{2 \times 10^9}{10^9}, and \operatorname{Subfactorial}(10^6).

  • simplify() honors more of its public contract. simplify({rules: null}) now applies no rewrite rules, as documented, and logarithmic simplifications such as \ln(a)/\ln(b) no longer reduce to an integer unless the identity can be verified exactly. simplify() also preserves exact exponents when combining powers, so x \cdot x^{\sqrt2} becomes x^{1+\sqrt2} rather than a decimal exponent.

  • Numeric comparison and formatting edge cases are fixed. Two large 15-digit values that previously compared in the wrong order now compare correctly, toPrecision(15) no longer corrupts 999999999999999, NaN has a deterministic place in canonical ordering, and high-precision toString(), .json, and toFixed() avoid long stalls on enormous exponents.

  • Substitution and collection operations are more complete. subs() now reaches into lists and tensors, for example Median([a,b,c]).subs({a: 1}). Finite set operations such as Intersection({1,2}, {2}) and SymmetricDifference now evaluate correctly, and Reverse([1,2,3]) returns [3,2,1] instead of throwing.

  • Strict and non-strict validation are more predictable. In strict mode, user-declared function signatures are enforced for closed arguments, numeric operators reject provably non-numeric operands such as Sin("hello"), big-op bounds are type-checked, and Map([1,2,3], "nf") is rejected. In non-strict mode, missing required arguments such as Sqrt() or Power(2) no longer crash.

  • Rule replacement is safer. Rule guards such as x \ne 0 must now be provable before they match, wildcard conditions such as :notzero no longer assume unknowns satisfy the condition, and failed sequence-wildcard matches no longer drop operands from the expression being transformed.

  • Special values and combinatorics are corrected. Choose and Binomial now share standard conventions, including Choose(2,3) = 0 and negative upper indices such as Binomial(-2,3) = -4. Argument(1+i) evaluates to \pi/4, several Digamma special values simplify when the Fungrim pack is loaded, and integer-domain functions such as Fibonacci(+Infinity) and MoebiusMu(Infinity) stay symbolic instead of throwing.

  • Modular arithmetic is consistent. Mod is floored everywhere, so Mod(-7, 3) returns 2, while Remainder uses round-to-nearest semantics. Exact rational inputs stay exact, for example Mod(\frac12, \frac13) returns \frac16.

  • Complex and matrix products no longer lose meaning. Multiplying a scalar by a complex literal such as ["Complex", 1, 1] preserves both real and imaginary parts, and symbolic matrix products preserve their written order, so a commutator such as MP - PM no longer collapses to 0 for declared matrix symbols.

  • Parsing rejects or preserves ambiguous forms more reliably. x^2^3 is now a parse error instead of an unintended list power, Sequence(1,2) no longer serializes as 1 2, parenthesized relations are treated as atomic operands inside larger chains, and a scalar or matrix next to a function- or matrix-valued symbol is parsed as multiplication rather than a tuple.

  • 0^0 and non-finite values are consistent across paths. evaluate(), N(), and compiled JavaScript now agree that 0^0 is NaN. Trigonometric poles such as N(\cot \pi) and N(\csc \pi) now return complex infinity rather than huge finite artifacts.

Performance​

  • LaTeX parsing is 15-28% faster. Parsing is faster on derivative, polynomial, matrix, and definite-integral inputs, with the same parse results as before.

  • Arbitrary-precision arithmetic is substantially faster. At 100 significant digits, addition, subtraction, multiplication, division, and comparison are now much faster than in 0.66.0, and high-precision ln, exp, Gamma, and related operations also benefit. The improvements are visible in both direct numeric work and symbolic operations that depend on arbitrary-precision arithmetic.

    Arbitrary-precision arithmetic at 100 significant digits (ns per operation, lower is better; warm median, distinct operands per call):

    opCE 0.67.0CE 0.66.0math.js¹Mathematica²
    add751522781,023
    sub911673291,212
    mul2023197,9841,025
    div5011,74811,8901,366
    cmp2919861984
    sqrt3,1634,01854,6961,055
    exp4,7958,698728,8761,682
    ln5,88732,398670,2061,353
    cos6,9147,4671,666,2922,059
    ¹ math.js BigNumber (decimal.js) at precision 100. ² Mathematica 14.3 timed inside the kernel with result caches disabled; its ~1 µs per-call dispatch floor dominates its small-op rows. CE and 0.66.0 from benchmarks/big-decimal/ops-results.json; reproduce with node benchmarks/big-decimal/run-ops.mjs and wolframscript -file benchmarks/big-decimal/ops-bench.wls.

    Symbolic operations (ms per call, lower is better; warm median, from the cross-library suite in benchmarks/REPORT.md):

    caseCE 0.67.0CE 0.66.0math.jsSymPyMathematica
    simplify √(3+2√2)0.070.09🟡 0.92🟡 3.563.28
    simplify √6·x + √2·x0.160.191.135.6918.0
    simplify (x²−1)/(x−1)0.100.15🟡 0.998.530.17
    d/dx √(1−x²)0.220.212.135.700.008
    d/dx xˣ0.040.041.831.800.005
    ∫ x eˣ dx0.080.09—6.530.57
    ∫ x/(x²+1) dx0.160.18—7.230.60
    lim sin(x)/x0.030.04—0.621.93
    lim (1+1/x)ˣ0.551.13—2.765.81
    solve x⁴+x²−1 = 01.884.65—8.560.55
    solve x³−x−1 = 00.111.18—5.730.23
    🟡 = value-correct but not fully simplified. — = not supported. SymPy 1.14 via sympify/evalf (per-call parse included, as for every string-based tool). All engines measured warm, per-call from source, same protocol (benchmarks/REPORT.md, "Methodology").
  • Integration, assumptions, polynomial solving, and factoring are faster. Rubi-backed integration spends less time on integrals it cannot solve, sign-related assumption queries respond faster, polynomial equations solve faster, and Factor handles common square-pattern cases more efficiently.

0.66.0 2026-06-28​

New Features​

  • Multiply now operates on vectors and matrices. Previously a product with any list/matrix operand was left unevaluated — even 2 * [1, 2, 3]. Multiply (i.e. *, \cdot, \times, and implicit products) now follows matrix-product / scalar-scaling semantics, matching Add's existing element-wise threading:

    • Scalar × tensor scales every element: 2 * [1, 2, 3] → [2, 4, 6], 2 * \begin{pmatrix}1&2\\3&4\end{pmatrix} → \begin{pmatrix}2&4\\6&8\end{pmatrix} (exact values are preserved, e.g. \frac12 [2, 4, 6] → [1, 2, 3]).
    • Two or more matrices/vectors form the matrix product, folded left-to-right in the written order: \begin{pmatrix}1&2\\3&4\end{pmatrix}\begin{pmatrix}5&6\\7&8\end{pmatrix} → \begin{pmatrix}19&22\\43&50\end{pmatrix}. The product is not commutative — operand order is preserved (including for matrix·vector vs vector·matrix), and vector·vector reduces to the dot product. This reuses the existing MatrixMultiply implementation.

    Element-wise (Hadamard) multiplication of two same-shape tensors is therefore not what * does; tensors of incompatible dimensions are left unevaluated, and symbolic operands of unknown shape are unaffected.

  • Hadamard (element-wise) product \odot. A new HadamardProduct operator, written \odot, multiplies two vectors or matrices of the same shape entry by entry: [1,2,3] \odot [4,5,6] → [4,10,18] and \begin{pmatrix}1&2\\3&4\end{pmatrix} \odot \begin{pmatrix}5&6\\7&8\end{pmatrix} → \begin{pmatrix}5&12\\21&32\end{pmatrix} (compare the matrix product *, which gives \begin{pmatrix}19&22\\43&50\end{pmatrix}). Operands of incompatible shape report an incompatible-dimensions error. It binds like multiplication and round-trips through LaTeX as \odot.

Resolved Issues​

  • Mixed chained inequalities keep their middle term. A chain combining different operators — e.g. 5 \le b \lt 7 — canonicalized to And(5 \le 7, b \lt 7), dropping b from the first link (so 3 \le 2 \lt 7 wrongly evaluated to True). It now canonicalizes to And(5 \le b, b \lt 7). Uniform chains (5 \le b \le 7) and the already-correct a \lt b \le c form are unchanged.

  • A transcendental of an exact constant expression stays symbolic. Per the exactness contract, evaluate() of a transcendental of an exact argument returns a symbolic result and only .N() numericizes. This held for number literals (sin(2) → sin(2)) but not for exact constant expressions: sin(\pi^2) numericized to -0.4303… instead of staying sin(π²) (and likewise cos(√2), etc.). These now stay symbolic under evaluate(); an inexact (float) argument such as sin(2.5) still numericizes.

  • An exact real added to the imaginary unit keeps its exact real part. \frac12 + i evaluated to 0.5 + i, and \frac34\sqrt3 + i to 1.299… + i — the exact real part was floatified when folded with i. Exact reals (rationals, radicals) are now preserved alongside the imaginary unit (1/2 + i, 3/4·√3 + i); .N() still numericizes, and inexact reals (1.5 + i) are unchanged.

  • Matrix/vector arithmetic preserves exact entries. A tensor with exact rational or radical entries was stored with a float64 element type, so element-wise operations silently produced floats — e.g. \begin{pmatrix}½&⅓\end{pmatrix} + \begin{pmatrix}½&⅓\end{pmatrix} returned [1, 0.666…] instead of [1, ⅔], and a matrix of √2 entries decayed to decimals. Exact entries now use the expression element type and stay exact; inexact (machine/decimal) values continue to use float64.

  • A^n is now the matrix power for an integer exponent. A power of a matrix was element-wise for non-negative exponents (A^2 squared each entry, A^0 gave a matrix of ones) yet A^{-1} already returned the inverse, and \begin{pmatrix}…\end{pmatrix}^2 did not evaluate at all. A^n is now the matrix power — repeated matrix multiplication — consistent with * being the matrix product: A^2 = A·A, A^0 is the identity, A^{-1} the inverse, and A^{-n} = (A^n)^{-1}. A non-square base reports expected-square-matrix. (Also fixes MatrixPower(A, n) for n < -1, which previously collapsed to A^{-1}.)

  • Element-wise functions now distribute over matrix/vector-valued sub-expressions. A broadcastable unary function applied to an operand that only becomes a collection after evaluation — e.g. \sqrt{AB}, \sin(AB), |AB| where AB is a matrix product — was left unevaluated, because broadcasting was decided from the raw (un-evaluated) operand. It now also broadcasts over the evaluated operand, so these distribute element-wise like \sqrt{M} on a literal matrix already did. (Add/Multiply keep their dedicated tensor handling.)

  • Juxtaposed matrices now form the matrix product. Writing two matrices next to each other (\begin{pmatrix}…\end{pmatrix}\begin{pmatrix}…\end{pmatrix}), or a scalar next to a matrix (2\begin{pmatrix}…\end{pmatrix}), previously produced a Tuple instead of a product, because the Matrix(…) wrapper is not reported as an indexed collection. The invisible (implicit) operator now treats matrix operands as multiplication, consistent with */\cdot/\times.

  • Negate (and hence Subtract) of a matrix-valued product is distributed correctly. A negation whose operand only became a vector/matrix after evaluation — e.g. Negate(Multiply(A, B)) from A B - A B — was left undistributed, so the following Add/Subtract misclassified it as a scalar and broadcast it over the other matrix, yielding a bogus higher-rank result. Matrix subtraction (e.g. the commutator AB - BA) now evaluates correctly.

  • A \textcolor wrapping a bare operator now parses as that operator. Input such as x \textcolor{red}{=} y previously failed — the = could not be parsed as a standalone group, producing a Tuple around an expected-closing-delimiter error. The color command is now transparent in operator position, so x \textcolor{red}{=} y parses as Equal(x, y) (and likewise for +, <, \le, \times, …). Because MathJSON has no way to annotate a lone operator glyph, the operator's color is dropped; coloring an operand (\textcolor{red}{y}, \textcolor{red}{x+1}) is unchanged and still yields an Annotated.

  • One-sided \left( … \right. enclosures now parse. \right. (and the \bigr./\Bigr./… variants) is a TeX null delimiter: a fence with no visible closing glyph. Previously a one-sided group such as \sin\left(x\right. was rejected, leaking the \left out as an unexpected-command error; it now parses the same as \sin\left(x\right) (→ Sin(x)). The null open form (\left.…\right|, used by EvaluateAt) and ordinary two-sided delimiters are unchanged.

  • Summation/product indices written as a \le range are now recognized. An index set of the form \sum_{1 \le i \le 10} i^2 (and the one-sided \sum_{i \le 10}) is now turned into the expected Limits, so the index i is bound by the sum instead of falling through to the imaginary unit. The example above now evaluates to 385 rather than staying symbolic with i → Complex(0, 1). This mirrors the existing handling of i \ge 1 and i = 1; strict < chains are not yet treated as index sets.

0.65.0 2026-06-28​

New Features​

  • Differential equation solvers. (contributed by KingArth0r) Two new functions in the calculus library provide an initial slice of ordinary differential equation (ODE) support:

    • DSolve(eq, y, x) — symbolic solver for first-order linear scalar equations of the form y'(x) + p(x)·y(x) = q(x). It returns a List of solutions, each an Equal expression for y(x), introducing an integration constant C (a fresh name is chosen if C is already in use). For example, DSolve(y'(x) = y(x), y, x) → [y(x) = C·e^x] and DSolve(y'(x) + y(x) = x, y, x) → [y(x) = x - 1 + C·e^{-x}]. Nonlinear or higher-order equations are left unevaluated (inert).

    • NDSolve(eq, y, limits, y0, steps?) — numerical solver for explicit scalar first-order initial value problems y'(x) = f(x, y), y(x0) = y0, using a fixed-step fourth-order Runge–Kutta (RK4) method. It returns a List of [x, y] sample pairs over the interval given by limits (a Limits or Tuple of (x, x0, x1)); the number of steps defaults to 100. It handles integrands with no elementary antiderivative (e.g. a Gaussian IVP whose solution is expressed with Erf).

    This slice is intentionally narrow so the API and result shape can get feedback before broader ODE support (adaptive RK45, systems, higher-order reductions, stiff and implicit solvers) is added.

  • \keyword{…} command for control-flow and logic keywords. Keyword constructs — if/then/else, for/from/to/do, where, such that, and, or, iff, for all, there exists, break, continue, return — can now be written with a dedicated \keyword{…} command, for example:

    \keyword{if} x > 0 \keyword{then} 1 \keyword{else} 0

    Unlike \text{…}, \keyword{…} keeps the input in math mode, and unlike \operatorname{…} it is rendered with symmetric keyword spacing. The existing \text{…} and \operatorname{…} spellings continue to work, and all three parse to the same expression. Multi-word keywords are written as a single token (e.g. \keyword{for all}). \keyword{otherwise} / \keyword{else} also serve as the default-branch marker inside a cases environment.

    A new keywordStyle serialization option — "text" (default), "keyword", or "operatorname" — selects which spelling is emitted when serializing If, Loop, Break, Continue, and Return back to LaTeX. The default preserves the previous \text{…} output.

0.64.0 2026-06-27​

New Features​

  • Expanded number-theory library. A set of standard number-theoretic functions has been added to the number-theory library. Integer arguments use arbitrary-precision (bigint) arithmetic, and long-running cases honor the evaluation deadline.

    Factorization & divisors:

    • FactorInteger(n) — prime factorization as a list of [prime, exponent] tuples ordered by ascending prime: FactorInteger(360) → [(2, 3), (3, 2), (5, 1)]. Following Mathematica's conventions, FactorInteger(0) → [(0, 1)], FactorInteger(1) → [(1, 1)], and a negative integer carries its sign in a leading [-1, 1] tuple.
    • PrimeFactors(n) — the sorted distinct prime factors: PrimeFactors(360) → [2, 3, 5].
    • Divisors(n) — the sorted positive divisors: Divisors(12) → [1, 2, 3, 4, 6, 12]. Divisors(0) is left unevaluated.
    • Radical(n) — the square-free kernel (product of distinct primes): Radical(360) → 30.
    • PrimeNu(n) / PrimeOmega(n) — the number of prime factors without / with multiplicity (ω and Ω).
    • MoebiusMu(n) — the Möbius function μ(n).
    • DivisorSigma(k, n) — the divisor function σ_k(n) (generalizes the existing Sigma0/Sigma1).
    • IsSquareFree(n) — whether n is square-free.
    • IsPerfectPower(n) — whether n = a^b for integers a, b ≥ 2.

    Primes:

    • NthPrime(n) — the nth prime (1-based): NthPrime(10) → 29. (Mathematica names this Prime, but in the Compute Engine Prime denotes derivative notation, so the prime-number function is NthPrime.)

    • NextPrime(n) / NextPrime(n, k) — the smallest prime greater than n; with k, the kth prime after n (or the |k|th before it when k < 0).

    • PrimePi(n) — the prime-counting function π(n): PrimePi(10) → 4.

    • RandomPrime(n) / RandomPrime(m, n) — a random prime in the range.

      Primality for these uses exact 6k±1 trial division for small n and switches to Miller–Rabin above 2³² (deterministic for the supported range), so NextPrime and RandomPrime are fast even for very large arguments.

    Modular arithmetic & GCD:

    • PowerMod(a, b, m) — modular exponentiation a^b mod m; a negative b uses the modular inverse (undefined when a and m are not coprime).
    • ExtendedGCD(a, b) — the GCD with Bézout coefficients, as (g, x, y).
    • ChineseRemainder(residues, moduli) — solves a system of simultaneous congruences (moduli need not be coprime).
    • MultiplicativeOrder(a, n) — the order of a modulo n; PrimitiveRoot(n) — the smallest primitive root mod n.
    • JacobiSymbol(a, n) / LegendreSymbol(a, p) — the Jacobi and Legendre symbols.

    Other primitives:

    • IntegerSqrt(n) — the integer (floor) square root.
    • CarmichaelLambda(n) — the reduced totient λ(n).
    • LucasL(n) — the nth Lucas number; CatalanNumber(n) — the nth Catalan number.
    • BernoulliB(n) — the nth Bernoulli number as an exact rational, with the convention B₁ = -1/2.
    • ContinuedFraction(x, n?) / FromContinuedFraction(list) — the continued-fraction expansion of a number (exact for rationals) and its inverse.
    • IntegerDigits(n, base?, length?) / FromDigits(list, base?) — the digits of n in a given base, and its inverse. DigitCount(n, base?, digit?) — digit-occurrence counts; DigitSum(n, base?) — the digit sum.
  • IsPrime is now reliable for large integers. Primality was previously left unevaluated above ~10¹⁵ and could silently round integers beyond 2⁵³ to a wrong machine value. IsPrime (and IsComposite) now route through a single deterministic Miller–Rabin implementation shared with the number-theory library, so e.g. IsPrime(2^61 - 1) correctly returns True. (The previous duplicate Miller–Rabin code, which used random bases and overflowed for large inputs, has been removed.) Relatedly, the internal toInteger helper now returns null instead of a precision-lost value for integers beyond the safe-integer range, so this class of silent-rounding bug cannot recur in the operators that use it for counts and indices.

  • Factorial2, Subfactorial, and BellNumber no longer round a non-integer argument. These are defined only on integers; in non-strict mode they previously rounded a non-integer (e.g. Factorial2(5.5) returned 6!!). They now stay symbolic for non-integer arguments. (In strict mode the (integer) signature already rejected such inputs.)

  • N(expr, precision) evaluates to a requested number of significant digits. The N function (and the ["N", expr] MathJSON form) now accepts an optional precision argument: ["N", "Pi", 50] returns π to 50 significant digits. When the requested precision exceeds the engine's working precision, the working precision is raised to match — and kept, since display precision is a global setting. When it is at or below the working precision, the result is rounded to that many significant digits without changing the global precision (N(1/3, 4) → 0.3333).

  • New linear-algebra operators.

    • Dot(a, b) — vector inner product / matrix product (Mathematica's .): Dot([1,2,3], [4,5,6]) → 32.
    • Cross(a, b) — cross product of two 3-vectors.
    • MatrixRank(m) — the rank (number of linearly independent rows/columns) via the rank–nullity theorem.
    • MatrixPower(m, n) — a square matrix raised to an integer power (the repeated matrix product A·A·…, with negative powers using the inverse). Distinct from ["Power", m, n], which threads element-wise.
    • CharacteristicPolynomial(m, x?) — the monic characteristic polynomial det(x·I − A) (variable defaults to x): [[1,2],[3,4]] → x² − 5x − 2.
    • RowReduce(m) — the reduced row echelon form (RREF) of a matrix.
    • IsSymmetric(m) / IsDiagonal(m) / IsSquareMatrix(m) — matrix-shape predicates returning True/False.

Resolved Issues​

  • ["N", expr] now numerically evaluates its operand. The N operator holds its argument unevaluated and previously called .N() on the still unbound operand — a no-op for symbolic constants — so ["N", "Pi"] returned Pi unchanged (and ["N", ["Sqrt", 2]] returned Sqrt(2)) instead of a numeric value. The operand is now bound before evaluation, making ["N", expr] equivalent to expr.N().

0.63.0 2026-06-26​

New Features​

  • LaTeX parse errors carry their source location. (contributed by zojize) The Error expressions produced by the LaTeX parser now include a sourceOffsets: [start, end] character range identifying where in the input the error occurred, so a consumer can map a parse error back to the offending span — e.g. to highlight an invalid token in a mathfield. Offsets are zero-based and end-exclusive into the serialized LaTeX (tokensToString); for input that round-trips through the tokenizer unchanged — editor-generated LaTeX, with no comments, Unicode normalization, or macro expansion — they match the original input string. Missing-operand errors (an empty \sqrt{} or \frac{}{}) use a zero-width range at the position where the token was expected. The new Parser.sourceOffsets(startToken, endToken?) helper lets custom dictionary entries attach a range to errors they raise. The raw parser output (LatexSyntax().parse()) always carries these offsets, so an Error node is now emitted in object form ({ fn: ["Error", …], sourceOffsets }) rather than the bare ["Error", …] array whenever a range is available — a consumer matching expr[0] === "Error" should also handle expr.fn?.[0] === "Error". Through the boxed path (ce.parse(latex).toMathJson()), source offsets are opt-in metadata like latex and wikidata: included with metadata: ['sourceOffsets'] or metadata: 'all', and omitted from the default serialization.

  • Long numerators over a single power serialize with an inline solidus. When prettifying, a large numerator divided by a single power of a small base now serializes as (3x^4+2x^3+x+5)/x^{23} instead of the tall, lopsided fraction \frac{3x^4+2x^3+x+5}{x^{23}}. This rounds out the existing prettify heuristics, which already factor a small denominator out of a large numerator (\frac{1}{x}(…)) and write a small numerator over a large denominator with a negative exponent ((a)(…)^{-1}). The new form applies when the numerator is large and the denominator is a single power of a small base — base^{k} with an integer exponent k ≥ 2 (/x^{23}), a square (/x^2), or a square root (/\sqrt{x}). Lone powers (\frac{1}{x^{23}}), products in the denominator (a·x^n), compound bases ((x+1)^{23}), and all other shapes are unchanged. As with the other rewrites, it is disabled by prettify: false.

  • Double-quoted string literals in LaTeX. "hello" now parses to a string (previously " was an unexpected-token). Content is read verbatim up to the closing quote, with LaTeX commands normalized to Unicode like \text{…} ("\alpha" → α); there is no escaping (use \text{…} for a string that must contain a "). Strings still serialize back to \text{…}. A " inside \unicode{…}/\char remains a hex prefix and is unaffected.

  • Dictionary values can be read by key with At. ["At", dict, "key"] (string key) now returns the value of that entry in a dictionary — e.g. ["At", { dict: { height: 42 } }, "height"] → 42. A missing key yields Nothing. Previously At was restricted to indexed (positional) collections and rejected dictionaries with an incompatible-type error; its value type is now indexed_collection | dictionary. In LaTeX, the postfix bracket form accepts a string key, so \mathrm{data}["height"] (or \mathrm{data}[\text{height}]) parses to ["At", "data", "height"]. Dot-notation also works when the base is a symbol declared as a dictionary: \mathrm{data}.height → ["At", "data", "height"] (the key is an alphabetic, space-free name; for a dictionary base, .x / .real are key lookups, not First / Real component access). Positional indexing of indexed collections is unchanged.

  • BoxedExpression.referencedFunctions and BoxedExpression.references. Two accessors aimed at dependency graphs (e.g. notebooks). The operator head of a function application — the f in f(x) or g(x) := f(x) + 1 — is not a symbol of the expression, so it appears in neither symbols nor freeVariables; referencedFunctions recovers those applied user-function names (excluding built-in operators, constants, and names bound by an enclosing scope, using the same predicate freeVariables applies to ordinary symbols). references is the complete in-edge set — freeVariables ∪ referencedFunctions, minus defines — so it pairs with defines (the out-edges) to build a use/def graph in one call. Subtracting defines drops self-references, so a recursive g(x) := g(x - 1) reports no dependency on itself.

  • ce.declare() refines an auto-declared binding instead of throwing. Parsing auto-declares the names it encounters (a free variable a in a + 1, a called function f in f(x)), recording an inferred binding. Calling ce.declare(name, …) for such a name now refines that inferred binding rather than throwing "… already declared in this scope" — which is exactly what the inferred flag is for. This lets a declare-first workflow parse cells to discover names and then declare them on the same engine. Re-declaring an explicit binding still throws, and a name bound to a value (e.g. a function argument) is still a genuine conflict.

Resolved Issues​

  • canonical and structural options are now honored by parse(), expr(), and function(). These methods only consulted the form option when deciding how to box their result, so the documented canonical / structural shortcuts were silently ignored: ce.parse(latex, { canonical: false }) returned a canonical expression (and, as a side effect of canonicalization, auto-declared its symbols), and ce.function('Power', ops, { structural: true }) returned canonical Root instead of a structural Power. The keys now resolve the same way form does, with an explicit form taking precedence. As part of this, ce.assume() now canonicalizes its predicate so the assumption machinery always sees a normalized form (e.g. Negate(ImaginaryUnit) folded to the complex literal -i) regardless of how the caller boxed it.

0.62.1 2026-06-22​

New Features​

  • indexStyle serialization option for collection indexing. The At operator (e.g. ["At", v, 1]) can now be serialized either as a subscript (v_1, M_{i,j}) or with programming-style brackets (v[1], M[i,j]). Like the other style options (fractionStyle, rootStyle, …) it is a callback (expr, level) => 'subscript' | 'bracket', settable engine-wide via ce.latexOptions.indexStyle or per-call via expr.toLatex({ indexStyle }). The default is 'subscript'.

Resolved Issues​

  • Collection indexing (At) now serializes to valid, round-tripping LaTeX. ["At", v, 1] previously serialized to \lbrack v, 1\rbrack — i.e. the list [v, 1], which re-parsed as ["List", v, 1], silently changing the meaning on a serialize→parse cycle. It now serializes as v_1 (or v[1] with indexStyle: 'bracket'), both of which parse back to At.

  • Accents and decorations serialize with brace notation and round-trip. OverHat, OverVector, OverTilde, OverBar, UnderBar, the over-arrows, OverBrace, etc. had no serializer and fell back to function-call notation — \hat{x} came back out as \hat(x), which re-parsed to ["Multiply", x, ["OverHat"]] instead of ["OverHat", x]. They now serialize as \hat{x}, \vec{v}, \overline{x}, … and round-trip correctly, including when subscripted (\hat{x}_0).

  • Subscripted single-letter symbols serialize with an italic base instead of an upright one. When a symbol name carried a subscript (e.g. a_1, x_n, S_t), the serializer chose its font style from the decorated string rather than the base: the subscript inflated the token count, so the multi-character rule wrapped the whole thing in \mathrm{…} and rendered the base letter upright (\mathrm{a_1}). A single-letter variable with a subscript is now rendered italic, as a variable should be — a_1 serializes to a_1, not \mathrm{a_1}. The font style is now decided from the base alone: multi-letter bases are still upright with the wrapper enclosing the whole symbol, so descriptive subscripts stay roman (speed_max → \mathrm{speed_{max}}), and explicit style modifiers (\mathbf, \mathbb, …) are unchanged. Greek single-letter bases are likewise rendered with their default (italic) style.

0.62.0 2026-06-20​

Resolved Issues​

  • Arbitrary-precision sums of three or more terms no longer collapse to machine precision. BigNumericValue.add had a fast path that, when adding to a zero value, cloned the other operand through a constructor that reads its machine real part (decimal.toNumber()), silently truncating a full-precision bignum to ~16 significant digits. The exact (rational/radical) arithmetic path was unaffected, and two-term sums were unaffected, so this only surfaced when summing three or more inexact values at a precision above machine: ExactNumericValue.sum folds those starting from a zero accumulator, and the very first 0 + xᵢ step lost all extra precision. The degradation was invisible when the terms were of similar magnitude (the result was merely capped at ~16 digits), but became a wrong answer under cancellation — e.g. numerically evaluating a high-order symbolic derivative at a point (large factorial-scale terms cancelling to a small value) returned garbage at any working precision. The zero-accumulator path now reads the full-precision real part, matching the non-zero path. Coefficients were always computed exactly; only the final numeric summation was affected.

  • High-order derivatives are reduced instead of blowing up. The Derivative operator applies the differentiation rules iteratively, and the quotient and product rules square the denominator at each step, so the r-th derivative of a quotient carried an x^(2ʳ)-scale denominator — e.g. the 75th derivative of sin(x)/x came back over x^(2⁷⁵). The result was mathematically exact (the integer coefficients are computed exactly), but the enormous exponent made it unusable and overflowed to NaN when evaluated at a point. Derivative of order ≥ 2 now runs a single simplification at the end, cancelling the common factors back to a linear-degree denominator (x^(2⁷⁵) → x⁷⁶). It is applied once, not per step, so it is cheap (~30 ms at order 75) and leaves first derivatives and the existing low-order results unchanged.

  • interval-glsl is now outward-rounded, making it a sound standalone exclusion oracle in float32 (preview). As shipped in 0.61.0 the _iv_* ops clamped to the sentinel range but rounded to nearest, so an operation — or the cell box itself — could come back slightly narrower than the true range. At a boundary that is enough to flip the exclusion verdict for a box the curve only grazes (e.g. the unit circle's tangent corner at (1, 0)), violating the containment contract that the GLSL interval must contain the interval-js (float64) result — a spuriously narrow interval can exclude a box the curve actually passes through. Every inexact operation now widens its result outward (lo toward −∞, hi toward +∞) before the clamp: by ~1 ulp for the correctly-rounded ops (+ − ×, Square), and by a larger relative margin for the GLSL ES built-ins that are not correctly rounded — 8 ulp for /, Sqrt, Exp/Ln/Log, and inverse trigonometry, and 32 ulp for Power (x^n with n ≥ 3, and fractional powers such as the astroid x^{2/3}). Crucially, the cell box that compileExclusionShader's main() builds is itself outward-rounded (via the new _iv_widen_box): the float32 mix that constructs it rounds to nearest and is the actual source of the grazing miss, which per-op widening alone cannot fix (with exact endpoints the op chain is exact). That box pad is scaled to the domain extent, not the edge value, since that is what bounds the mix error — a value-relative pad would vanish for a box edge near 0 in a wide domain. Widening only ever moves a bound outward, so it cannot break soundness; the empty (lo > hi) / entire (±IV_INF) encodings, the finite IV_INF sentinel, the per-op clamp, and exact empty-propagation are all preserved. Sin/Cos remain best-effort (see below).

  • freeVariables / unknowns no longer report the bound variables of Function literals and integrals. A function literal leaked its own parameters, and Integrate / Limit leaked their variable — e.g. freeVariables of f(x) := x^2 + b wrongly included the parameter x, and a definite integral leaked its integration variable. They now return only genuinely free symbols ([b, f] for that definition, [] for ∫ sin(x) dx), while a free coefficient is still reported (∫ a·sin(x) dx → [a]). Sum / Product were already correct, and symbols is unchanged (it still includes bound variables). This is a behavior change for code that relied on the previous, over-inclusive result.

  • Runaway user-function recursion now throws a catchable CancellationError instead of a native RangeError. A recursive definition with no reachable base case (e.g. f(x) := f(x-1) + 1) previously overflowed the JavaScript call stack with an uninformative RangeError. recursionLimit — previously defined but never enforced — is now applied to user-function application: exceeding it throws a CancellationError with cause: 'recursion-depth-exceeded', consistent with how timeLimit and iterationLimit are surfaced. The default recursionLimit is now 256 (was a nominal, unenforced 1024), chosen to fire below the native stack limit on typical engines; raise ce.recursionLimit for legitimately deep recursion. Iterating a user function (e.g. \sum f(i)) is not counted as recursion. (A sufficiently complex single call can still exceed the native stack before the limit is reached, so a robust caller catches RangeError as a backstop.)

  • Integrate binds only the integration variable in its canonical integrand. ∫ a·sin(x) dx previously canonicalized to Integrate(Function(body, a, x), …), listing the free coefficient a as a spurious integrand parameter; it is now Integrate(Function(body, x), …). Introspecting the integrand (expr.op1) therefore reports a as free, and the integrand is a proper single-variable function. Evaluation is unchanged.

  • Nested (multivariate) integrals now parse and evaluate correctly. \int_1^2\int_3^4 x y \, dx \, dy previously attached all the trailing differentials to the innermost integral, leaving the outer integrals with a Nothing integration variable — so the expression could not evaluate. Each \int now consumes only its own differential (the innermost dx pairs with the innermost \int, the next dy with the next), producing a properly nested Integrate where every level carries its own variable and limits (\iint / \iiint still bind 2 / 3 variables at one level). Combined with the definite-integral evaluator now applying the limits to a parametric antiderivative (e.g. ∫_3^4 k·x dx → 7/2·k; the symbolic f(b) - f(a) was previously left as an unevaluated EvaluateAt), nested definite integrals evaluate to a value: ∫_1^2∫_3^4 x·y dx dy → 21/4.

  • Multiple-integral and contour-integral serialization round-trips. \iint / \iiint (and \oiint / \oiiint) now serialize back to the compact sign with a single region subscript (\iint_{D}\!…) instead of a stack of \ints, so a flat multiple integral round-trips to the same structure. A separate long-standing bug that emitted the literal text \ointundefined for any \oint with a region (its limit is a 3-element Tuple, serialized to MathJSON as Triple, which the serializer did not recognize) is also fixed: \oint_V f(s)\,ds now serializes as \oint_{V}\!f(s)\, \mathrm{d}s.

  • 1^x simplifies to 1 for any finite exponent. A symbolic or function exponent (e.g. 1^{n+1}, 1^{\sin x}) previously left Power(1, x) un-reduced because the canonicalizer bailed before its base-1 rule. 1^x → 1 now (matching SymPy / Mathematica); only a genuinely infinite or NaN exponent stays indeterminate (1^∞ → NaN, unchanged).

New Features​

  • interval-glsl: public outward-rounding helpers and an opt-in absolute trig pad (preview). The widen helpers _iv_widen / _iv_widen_t / _iv_widen_pow / _iv_widen_sc / _iv_widen_box, and their epsilons IV_EPS / IV_EPS_FN / IV_EPS_POW / IV_BOX_EPS, are a stable, public part of the emitted preamble: a renderer that builds its own cell box (instead of using compileExclusionShader) outward-rounds it by calling _iv_widen_box(vec2(lo, hi), extent) per axis, where extent is the domain extent for that axis (the box pad is domain-scaled, not value-relative). The preamble is now emitted for any expression with free variables (not only ones that reference an _iv_* op), so those helpers are always available — e.g. for an axis line f = x. GLSL ES Sin/Cos carry an absolute, implementation-defined error (≈2⁻¹¹ in the worst case; macOS ANGLE→Metal differs) that no relative pad can cover. A new trigAbsPad option (default 0, off) on compile(), IntervalGLSLTarget.compileExclusionShader(), and the new IntervalGLSLTarget.getPreamble() adds an absolute Sin/Cos pad, so a trigonometric implicit curve can be a strictly-sound standalone oracle at the cost of fatter trig intervals.

  • BoxedExpression.defines. A new accessor returning the symbols an expression defines: the target of a top-level Assign / Declare (a in a := 3, f in f(x) := …), recursing through Block. It complements freeVariables (the symbols an expression references) — together they let tooling build a definition/use dependency graph, with references = freeVariables minus defines.

  • ComputeEngine.appliedNonFunctions(latex). Returns the symbols written in function-application syntax f(…) in latex that are not functions in the current scope, and so parse as implicit multiplication (f·x) or are left unresolved. The check is scope-aware (a symbol declared as a function is not reported) and has no side effects. Useful for flagging a likely call to an undefined function — e.g. warning that f(x) was read as f·x.

0.61.0 2026-06-17​

New Features​

  • interval-glsl compilation target (preview). A GPU compilation target that evaluates an expression with interval arithmetic in GLSL — each value is a vec2 (lo, hi) — so a robust implicit-curve renderer can run its per-cell exclusion test (lo > 0 || hi < 0) on the GPU instead of CPU-side via interval-js. (Reinstates the interval-glsl target removed in 0.52, with a simpler vec2-only representation — the GPU acts as an exclusion oracle and the CPU keeps curve extraction — instead of the former status-flag struct.) compile(expr, { to: 'interval-glsl' }) emits _iv_* helper calls plus a preamble library. Coverage: arithmetic, integer and positive rational powers, Abs, Sqrt, Exp, Ln/Log/Lb, trigonometry / inverse trigonometry (Sin, Cos, Tan, Arcsin, Arccos, Arctan, with interval range reduction), and the step / rounding family (Floor, Ceil, Round, Truncate, Fract, Sign, Heaviside, Mod, Min, Max) — covering polynomial, rational, algebraic, trigonometric, and lattice/periodic implicit curves (conics, lemniscate, astroid, superellipse, trig lattices, floor/mod grids, …). Jump-discontinuity functions return a tight, sound value-range enclosure (so cells can still be excluded), with discontinuity classification left to the CPU; only genuine poles widen to the full range. A head that is not yet supported (e.g. hyperbolic functions) is reported in the result's unsupported field, so a caller can fall back to another target per-expression. Values use a finite ±∞ sentinel and a lo > hi encoding for the empty (domain-undefined) interval, propagated through every operation; domain-restricted functions (sqrt/ln/asin/rational pow of an out-of-domain argument) yield empty, and a pole (zero-spanning denominator, tan asymptote) yields the full range. Parity with the interval-js target is verified against a shared corpus. IntervalGLSLTarget.compileExclusionShader() emits a complete, self-contained fragment shader (preamble + an _implicit interval evaluator + a reference main that derives each fragment's cell box and applies the exclusion test) ready to drop into a WebGL2 renderer.

Resolved Issues​

  • A function parameter now shadows a same-named constant. A parameter named like a constant (i, e, Pi/\pi, …) was rewritten to the constant while the function body was canonicalized, so the binding was lost — λi. 2i applied to 5 returned 2i (the imaginary unit doubled) instead of 10. Parameters now shadow whatever their name means in the enclosing scope — a constant, an assigned variable, or nothing — which is standard lexical scoping. A free symbol that is not a parameter is unchanged (i outside a parameter is still the imaginary unit), and closure capture is preserved (λi. λz. (z + i) captures i correctly).

  • compile() no longer emits a dangling reference to a symbol that has an assigned value (GLSL, WGSL, JavaScript, and interval-JS targets). When an expression referenced a symbol with an assigned value in the engine (ce.assign("a", 1.5)), compile() emitted a bare a — an undeclared GLSL identifier (a shader that silently fails to compile) or a bare JS global (a ReferenceError when the compiled function is called) — even though the symbol is omitted from expr.unknowns and folded by evaluate(). The value is now folded into the generated code (sin(a·x) → sin(1.5 * x)), making compile(), evaluate(), and unknowns consistent. This also folds user-declared constants (ce.declare("c", { value: 3 })), and applies on the direct-target compile(expr, { target }) path as well. A symbol supplied through the compile() vars option is never folded — the mapping always wins, so a per-frame GLSL uniform / JS argument keeps updating the result without recompiling — and a genuinely free symbol is unchanged.

  • compile() folds a symbolic assigned value correctly, parenthesizing it and resolving the free symbols it references. When a symbol was assigned an expression rather than a number (ce.assign("b", ce.parse("c + 1"))), folding b into a larger expression had two bugs: the compound value was spliced in without parentheses, so b · x compiled to c + 1 * x (i.e. c + x) instead of (c + 1) * x — a silently wrong result (2·b → 2 * c + 1, b² → (c + 1 * c + 1)); and the inner free symbol c, hidden behind b's value and therefore absent from expr.unknowns, was emitted as a bare global (ReferenceError on the JS target). The folded value is now parenthesized for its context, and a free symbol reachable only through a folded value routes through the normal free-symbol plumbing (_.c on the JS / interval-JS targets; a uniform on GPU) and is reported in the result's freeSymbols.

  • GPU compilation rejects non-finite numbers instead of emitting a non-compilable shader. GLSL and WGSL have no infinity or NaN literals, but compile() emitted Infinity.0 / NaN.0 for a ±∞ or NaN value (e.g. from a literal \infty or a constant-folded 1/0) and reported success: true — a shader that silently fails to compile on the GPU. Such values now throw a clear error from the GLSL/WGSL targets (so the free compile() falls back to success: false with a diagnostic), consistent with how other GPU-unsupported constructs are handled. The JavaScript target is unchanged (Infinity / NaN are valid there).

  • The JavaScript compilation target now lowers the exponential, trigonometric, and logarithmic integrals. SinIntegral (Si), CosIntegral (Ci), ExpIntegralEi (Ei), and LogIntegral (li) compile to _SYS runtime helpers, matching the existing support for Erf, FresnelS, Gamma, BesselJ, etc. These are the closed forms the antiderivative engine emits (e.g. ∫ sin x / x dx = SinIntegral(x)), so an "evaluate then compile" pipeline — such as plotting ∫ f dx from its closed form — no longer throws Unknown operator and falls back to numeric sampling. (GLSL/WGSL shader approximations of these are not yet provided.)

  • The JavaScript compilation target now lowers the elliptic, AGM, and hypergeometric kernels. AGM, EllipticK, EllipticE, EllipticF, EllipticPi, Hypergeometric2F1, Hypergeometric1F1, Erfi, and Choose compile to _SYS runtime helpers. Like the integral functions above, these are closed forms evaluate()/.N() produces (e.g. a pendulum period or an arc length reduces to an elliptic integral), so they can now be plotted from the closed form rather than re-sampled numerically. EllipticE and EllipticPi keep their arity-overloaded complete/incomplete forms, and AGM accepts the one-argument AGM(z) = AGM(1, z) shorthand. (Real-valued like the other special functions on this target; GLSL/WGSL not provided.)

Improvements​

  • compile() results now report their external references. A CompilationResult carries two new fields so a caller can check that a result is self-contained declaratively, instead of executing or GPU-compiling the code to discover a dangling reference:

    • freeSymbols — the identifiers the generated code references that the caller must supply at run time (JS vars-object keys / GLSL uniforms). These are the free symbols as codegen sees them: assigned values and constants are folded out, bound variables (lambda parameters, Sum/Product/ Integrate/Loop indices, Block locals) are excluded, and vars-mapped symbols are always included. Unlike expr.unknowns, it also surfaces a free symbol reachable only through a folded value (e.g. b assigned c + 1 exposes c). Use it to build a uniforms / vars mapping that is guaranteed consistent with the emitted code.
    • unsupported — operator heads the target cannot lower (no operator/function mapping, not a structural form). On a failed compile() this is populated alongside a human-readable error, so an unlowerable operator (e.g. SinIntegral on the GLSL target) surfaces as success: false with a machine-readable list rather than only a thrown exception.

    Built-in targets populate freeSymbols (and an empty unsupported) on every successful compile. The direct getCompilationTarget(name).compile(expr) path still throws on a genuinely unsupported operator (so the engine-level compile() can fall back to interpretation); the unsupported / error fields are how the engine-level compile() reports that condition without a throw.

0.60.0 2026-06-16​

Behavior Changes​

  • isFinite is now known for finite symbolic constants. Expressions such as √π, 1/π, and π^π report expr.isFinite === true (previously undefined), because finiteness is propagated through Sqrt, Root, Power, and Divide of finite operands. Cases that are genuinely indeterminate (e.g. 1/x for an unconstrained x) still report undefined.

  • Exact transcendental expressions now remain symbolic under evaluate(). For example, ln(2) remains ln(2) instead of becoming 0.693…. Use .N() or { numericApproximation: true } when a numeric approximation is wanted. Inexact inputs still evaluate numerically, and known exact values such as cos(π) = -1 and arctan(1) = π/4 still simplify. As a result, definite integrals also preserve exact results, such as ∫₁² 1/x dx = ln(2) and ∫₀¹ 1/(1+x²) dx = π/4.

  • (aⁿ)ᵐ no longer folds to aⁿᵐ based solely on an odd inner exponent. This combine was unsound on the principal branch: when a < 0 and m is not an integer, the two sides differ by a phase. For example (x³)^{1/2} now stays √(x³) (which is 8i at x = -4) instead of becoming the inequivalent x^{3/2} (-8i), and it is again confluent with the √(x³) form. The fold still applies when the base is non-negative or the outer exponent is an integer. (Roots are unaffected: (x³)^{1/3} = x still holds, since odd-index roots use the real-root convention.)

  • Logarithms are no longer combined across a branch cut. ln(a) + ln(b) → ln(ab) (and the log and subtraction variants) is only valid on the principal branch; for arguments on the negative real axis the two sides differ by a multiple of 2πi. For example ln(-2) + ln(-3) no longer simplifies to the inequivalent ln(6) (its true value is ln(6) + 2πi). The combine still applies to positive and unconstrained-symbolic arguments. The guard consults the analytic-property store's branch-cut records (see Special Functions).

  • e^{iθ} stays in exponential form under evaluate() for a symbolic angle. Euler's formula e^{iθ} → cos θ + i·sin θ is now applied only when θ is a constant that reduces to a closed form (e^{iπ/2} = i, e^{iπ} = -1, e^{ln y} = y are unchanged); for a symbolic angle, e^{ix} stays e^{ix} — a basis change is not an evaluation, and it no longer differs from the previous inconsistency where (e^{ix})² expanded while e^{ix} did not. Convert to trigonometric form on demand with the new strategy expr.simplify({ strategy: 'trig' }).

  • N() at a known pole now returns ComplexInfinity instead of NaN. When a function is evaluated numerically at a pole recorded in the new analytic-property metadata store (see Special Functions), the result is ComplexInfinity rather than NaN or an unevaluated expression — for example Digamma(0).N() and Digamma(-2).N(). Functions whose kernels already returned an infinity at their poles (such as Gamma) are unchanged.

Benchmarks​

The numeric and symbolic gains in this release are summarized below against the last release (0.59.0), SymPy, math.js, and Mathematica — the reference baseline, since it is the broadest engine in the field. The tables are generated by the harness in benchmarks/ (node benchmarks/report_changelog.mjs); every result is verified numerically against an independent mpmath reference, never another tool. "CE 0.60.0" is this release.

Numeric performance (200-digit precision)​

Median time per call, in microseconds — lower is better. — means the tool returned no usable result at that precision.

ExpressionCE 0.60.0CE 0.59.0SymPymath.jsMathematica
\pi^215201741073.9
\sin 125612204295.2
\cos 124602224557.1
\ln 2873023394,3743.7
e^{\pi}313982144,7714.6
\zeta(3)3,419—264—49
\Gamma(\tfrac13)1,867427,938341—212
\psi(\tfrac13)1,689404,3002,831—169

Biggest gains over 0.59.0: \psi(\tfrac13) 239× faster, \Gamma(\tfrac13) 229× faster, e^{\pi} 13× faster (it no longer recomputes \ln e on every call), \ln 2 3.5× faster, \sin 1 / \cos 1 ~2.5× faster. The elementary functions widen further at 1000+ digits (e.g. \ln 2 ≈ 21× faster, where it now also leads SymPy and mpmath). 0.59.0 could not reach 200 digits for \zeta(3) (it was capped near machine precision); math.js has no arbitrary-precision ζ/Γ/ψ. Mathematica's native bignum kernel is faster still on these constants.

Symbolic capability & performance​

Each cell is how many times faster than Mathematica that engine is on the case (Mathematica ÷ engine, so higher is better; Mathematica itself is 1×). — means the engine can't do the case. Compare the CE 0.60.0 and CE 0.59.0 columns to see what is new this release (a — under 0.59.0 next to a number under CE 0.60.0). The CE + R/F column is CE 0.60.0 with the opt-in Rubi integrator and Fungrim identities loaded (loadIntegrationRules / loadIdentities), on the same minified bundle: sometimes it improves performance, sometimes it hurts it, but the overall effect is improved coverage.

OperationCE 0.60.0CE + R/FCE 0.59.0SymPymath.jsMathematica
Antiderivatives
\int\frac{1}{\sqrt x}\,dx1.5×3.7×—0.5×—1×
\int\frac{x}{\sqrt{1-x^2}}\,dx2.5×2.6×—0.09×—1×
\int\frac{1}{x^3+1}\,dx2.2×11×—0.3×—1×
\int\frac{\sqrt x}{1+x}\,dx—3.7×—0.1×—1×
\int\frac{x}{(1+x)^{1/3}}\,dx—3.9×—0.01×—1×
\int\frac{x^2}{(1+x)^{1/3}}\,dx—4.1×—0.007×—1×
Derivatives
\tfrac{d}{dx}\sqrt{1-x^2}0.01×0.03×0.01×0.001×0.004×1×
Simplification
\sqrt{3+2\sqrt2}11×20×———1×
\sqrt6\,x+\sqrt2\,x28×65×30×3.3×18×1×
Evaluation
\lim_{x\to0}\tfrac{\sin x}{x}9.2×23×—3.1×—1×
\lim_{x\to\infty}(1+\tfrac1x)^x1.6×1.6×—2.1×—1×
\int_1^2\tfrac1x\,dx1996×1907×—92×—1×
\int_{-\infty}^{\infty} e^{-x^2}\,dx106×428×—2.5×—1×
Solving
x^4+x^2-1=00.07×0.08×—0.06×—1×
x^3-x-1=00.08×0.1×—0.04×—1×

Across the cases both solve, Compute Engine is a median 3.7× faster than Mathematica (up to 1996×). The — entries under 0.59.0 show what is new this release: limits, exact definite/improper integrals, and polynomial solving. The bottom three antiderivative rows are integrals the base engine still leaves unevaluated but the opt-in Rubi rules solve. Mathematica still leads on raw derivative and root-finding latency (the <1× rows), where its native kernel is hard to beat.

Measured 2026-06-16 · SymPy 1.14.0 · math.js 15.2.0 · Mathematica 14.3.0 · Node 22 · verified against mpmath. Reproduce: npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.

Calculus​

  • Limit can now return exact symbolic results. This includes direct substitution, indeterminate quotients, rational functions at infinity, dominant-term analysis, and exponential forms. Examples include lim(x→0) sin(x)/x = 1, lim(x→∞) (1+1/x)^x = e, and lim(x→∞) arctan(x) = π/2. Limits that cannot be determined reliably fall back to numeric evaluation or remain unevaluated. NLimit remains numeric.

  • Limits no longer return a wrong value at a special-function pole. A limit whose expression contains a special function (Gamma, Digamma, PolyGamma, Zeta, …) evaluated at one of its poles — e.g. lim(x→-1) (x+1)·Digamma(x) — previously substituted the pole as a finite value and returned a confident wrong result (0). Such limits now stay unevaluated (or are recovered numerically where sampling allows) rather than reporting a false value.

  • Symbolic integration supports many more integrands, including:

    • Gaussian integrals and quadratic exponentials using Erf and Erfi
    • Fresnel integrals
    • Sine, cosine, exponential, and logarithmic integrals
    • Products of polynomials, exponentials, and trigonometric functions
    • More radical and quadratic-root integrands
    • Powers of secant, cosecant, tangent, and cotangent
    • Reverse power-chain forms such as ∫ln(x)/x dx = ½ln²(x)
    • Products with symbolic exponents that previously failed or timed out
    • Powers and radicals of a linear function, e.g. ∫√(1+x) dx, ∫x√(1+2x) dx, and ∫(a+bx)^p dx
    • Radical powers of a polynomial via the reverse chain rule, e.g. ∫x√(1−x²) dx = −⅓(1−x²)^{3/2}
    • Quotients by a sum of two square roots, e.g. ∫1/(√(a+bx)+√(c+bx)) dx, by conjugate rationalization
    • Absolute value of a linear argument, e.g. ∫|x| dx = x|x|/2 and ∫|ax+b| dx = (ax+b)|ax+b|/(2a) (valid for all x)
  • Rational-function integration is more exact and complete. Partial fractions now preserve rational and radical coefficients for a wider range of denominators, including x³+1, x⁴+1, x⁴-1, and biquadratic polynomials. Several cases that previously returned incomplete results, floating-point coefficients, or no result now return exact antiderivatives.

  • More improper integrals evaluate correctly. Exact results now include Gaussian, rational, and Fresnel integrals over infinite intervals. Numeric integration of convergent oscillatory integrals is also more reliable, while divergent or low-confidence cases remain unevaluated instead of returning a misleading finite value.

  • Fixed incorrect or missing antiderivatives for sin²(ax+b), cos²(ax+b), √x, 1/√x, 1/√(1-x²), and related forms.

  • New Residue(f, x, a) operator computes the residue of f at x = a (the coefficient of (x-a)⁻¹ in its Laurent expansion). It detects the pole order and evaluates exactly via the symbolic limit engine, e.g. Residue(1/(x²-1), x, 1) → 1/2, Residue(eˣ/(x-1)², x, 1) → e, and Residue(cot(x), x, 0) → 1. Residues of Gamma, Digamma, and Zeta at their poles use closed forms gated by the analytic-property store, e.g. Residue(Gamma(x), x, -2) → 1/2 and Residue(Zeta(s), s, 1) → 1 — including in a product or quotient with an analytic cofactor, such as Residue(Gamma(x)/(x-5), x, -2) → -1/14.

Algebra and Solving​

  • solve handles equations between two different inverse-trigonometric functions by applying tan to both sides to clear them, then solving the resulting algebraic equation. For example arcsin(x) = arctan(x) → 0 and arccos(x) = arctan(x) → √((√5−1)/2). As part of this, √(f(x)) = g(x) with a non-linear right-hand side now solves too (e.g. √(1−x²) = x²).

  • New Solve operator. Solve(equation, unknown) returns the list of solutions of an equation for an unknown, using the same solver as the expr.solve() method — for example ["Solve", ["Equal", "x^2", 1], "x"] returns ["List", 1, -1]. The equation may be an Equal expression or a bare expression read as = 0; the arguments are held, so the equation is no longer prematurely reduced to a boolean.

  • solve now handles general cubic, quartic, and higher-degree polynomials. Exact roots are still preferred; when no supported exact form is available, real roots are returned as numeric approximations.

  • Absolute-value equations solve more reliably. This includes equations such as |x| = 2, |x-1| = 2, non-linear arguments such as |x²-3| = 1, and equations with an absolute value on both sides.

  • solve handles more transcendental and substitution equations. Equations with equal exponential bases reduce by their exponents (e^{2-x²} = e^{-x} → -1, 2; 2^x = 2^3 → 3); a·sin(x) + b·cos(x) = 0 solves via the tangent (sin x = cos x → π/4); equations that are polynomials in a root of the unknown solve by substitution (2√x + 3·⁴√x = 2 → 1/16); and a single square root with a non-constant coefficient is eliminated by squaring (x = 1/√(x²+1)).

  • Biquadratic and sparse-power equations return exact roots. Polynomials whose exponents share a common factor — such as x⁴ + x² − 1 — are solved by substituting u = x² (or x³, …), so the roots are exact radicals (±√((√5−1)/2)) instead of numeric approximations.

  • solve handles equations that are polynomials in a single nonlinear generator, by substituting u = g(x) for a logarithmic, exponential, trigonometric, or radical generator g, solving for u, and inverting. For example (ln x)² = 4 → e², e⁻², e^{2x} − 3eˣ + 2 = 0 → 0, ln 2, and √(ln x) = ln√x → 1, e⁴.

  • solve factors a zero product. When an equation is a product whose factors each involve the unknown — such as ln(x)·(x − 1) = 0, or an already-factored (x + 1)·cos³(3x) = 0 — its roots are the union of the roots of each factor.

  • GCD now finds common polynomial factors for univariate and multivariate polynomials. Integer operands retain their existing behavior; use PolynomialGCD() when an explicit polynomial result of 1 is needed for coprime inputs.

  • New Resultant(a, b, x) operator computes the resultant of two polynomials with respect to a variable (the Sylvester-matrix determinant). It is zero exactly when the polynomials share a common factor, e.g. Resultant(x² - 1, x - 1, x) → 0 and Resultant(x² + 1, x² - 1, x) → 4. Symbolic coefficients are supported: Resultant(x² + a, x + b, x) → a + b².

  • Polynomial factorization is more complete and reliable. In particular, Factor(xⁿ-1) now returns polynomial factors without introducing branch-dependent radicals, and the public factor() function once again factors expressions such as x²+5x+6.

  • Nested radicals are simplified when possible, for example √(3+2√2) = 1+√2.

Special Functions​

  • Added numeric evaluation for:

    • Complete and incomplete elliptic integrals: EllipticK, EllipticE, EllipticF, and EllipticPi
    • The arithmetic-geometric mean AGM
    • Hypergeometric2F1, Hypergeometric1F1, and AppellF1
    • Jacobi theta functions and the Dedekind eta function
    • Erfi, SinIntegral, CosIntegral, ExpIntegralEi, and LogIntegral
  • Gamma now accepts a second argument, the upper incomplete gamma function Γ(s, z) = ∫_z^∞ tˢ⁻¹ e⁻ᵗ dt (e.g. ["Gamma", s, z]). It is evaluated numerically for real and complex arguments, including negative and fractional orders s (Gamma(-4, 2), Gamma(1/2, -1)), and honors the exactness contract: it stays symbolic under evaluate() and reduces Γ(s, 0) to the ordinary Γ(s). Use .N() for a numeric value. The one-argument Γ(z) is unchanged.

  • Hypergeometric2F1 now supports analytic continuation across most of the complex plane, rather than being limited to its defining power series.

  • Zeta and Gamma now honor the requested precision. At high ce.precision, numeric evaluation of Zeta, Gamma, GammaLn, Beta, Digamma, Trigamma, and PolyGamma previously stalled near machine precision (e.g. Zeta(3) was correct to only ~16 digits regardless of precision). They now return the full requested precision — Zeta uses the Cohen–Villegas–Zagier acceleration, and all of these kernels compute with guard digits.

  • EulerGamma (γ) now honors the requested precision. It was previously a fixed ~858-digit constant, so at higher ce.precision it silently stopped at ~858 correct digits (making identities such as Digamma(1) = -γ appear wrong past that point). It is now computed on demand to the full working precision.

  • Gamma and the polygamma family are dramatically faster at high precision (~340× at 300 digits — Gamma(1/3) ≈1.9 s → ≈5 ms; ~130× at 1000 digits). The Stirling-series kernels (Gamma, GammaLn, Digamma, Trigamma, PolyGamma) were both shifting their argument just short of where the series converges (running far more terms than needed) and letting intermediate products grow in size without bound; the shift, term count, and per-step rounding are now chosen so the series converges quickly with bounded-size arithmetic. Results are unchanged to full precision.

  • The Identities Library has been updated from 1,350 to 1,376 verified rules, including corrected Jacobi theta identities.

  • Modular and theta-function identities now discharge under Im(τ) > 0. The upper-half-plane condition guarding these identities is expressed as the part inequality Im(τ) > 0, so they apply once you assume(Im(τ) > 0) (previously an opaque τ ∈ HH set membership was required). A new LaTeX shorthand, \mathbb{C}^+ (also \C^+), denotes the open upper half-plane: z \in \mathbb{C}^+ canonicalizes to Im(z) > 0. As a side effect three further identities became available — the derivative of the modular j-function and the θ₁/θ₂ logarithmic derivatives — recovered because the inequality form is verifiable where the opaque set was not.

  • EisensteinE(s, τ) now evaluates numerically. The normalized Eisenstein series of even weight s ≥ 2 gets a numeric kernel (Lambert-series q-expansion in the upper half-plane), joining JacobiTheta/DedekindEta. For example EisensteinE(4, i).N() is 1.45576…, EisensteinE(2, i).N() is 3/π, and EisensteinE(6, i).N() is 0 (an elliptic fixed point). Exact arguments stay symbolic under evaluate(); the kernel requires Im(τ) > 0.

  • New analytic-property metadata store. ce.functionProperties(name) exposes per-operator analytic properties drawn from the Fungrim corpus — poles, zeros, branch points and cuts, residues, and holomorphic/meromorphic domains. For example ce.functionProperties('Gamma')?.poles is the set NonPositiveIntegers. Convenience accessors (poles, zeros, branchCuts, holomorphicDomain, …) return the unconditional record of each kind; parametric records (such as residues that depend on parameters) are available via entries. This also powers pole-aware N() (see Behavior Changes).

Numeric Evaluation​

  • Arbitrary-precision elementary and transcendental functions are substantially faster, especially at hundreds or thousands of digits. High-precision π and trigonometric functions are no longer limited to about 2,350 digits. Square root is roughly twice as fast at 1,000+ digits (a giant-steps integer square root), the natural logarithm switches to the faster arithmetic–geometric-mean method from around 700 digits (previously ~1,250), and a power no longer recomputes the logarithm of its base on every call — at 1,000 digits Exp(x).N() is about three times faster, and a repeated base such as 2^x or 10^x about 2.8 times faster. Results are unchanged.

  • Odd roots of negative real numbers now use the real-root convention, so Root(-8, 3) and (-8)^(1/3) evaluate to -2.

  • N() of a non-unit rational power of a negative base no longer returns NaN. Previously only unit fractions worked (they route through Sqrt/Root); (-4)^{3/2}, (-8)^{2/3}, and similar fell through to Math.pow(negative, non-integer) = NaN. They now follow the same branch conventions as the roots above: an even denominator takes the principal complex value ((-4)^{3/2} = -8i, consistent with Sqrt(-4) = 2i), and an odd denominator the real root ((-8)^{2/3} = 4, (-8)^{5/3} = -32, consistent with (-8)^{1/3} = -2).

  • Exact evaluate() of a non-unit rational power of a perfect power now reduces. When x^{p/q} has a real base and its q-th root is an exact perfect power, it reduces to an exact value (8^{2/3} = 4, 27^{2/3} = 9, (-8)^{5/3} = -32), extending the unit-fraction behavior (8^{1/3} = 2) to non-unit numerators and matching N(). Non-perfect powers (2^{2/3}) and the negative even-root branch ((-4)^{3/2}, complex) stay symbolic under evaluate().

  • N() now fully evaluates applied functions and constants such as e, i, and expressions in Euler form.

  • Complex equality and arbitrary-precision complex square roots are more robust in the presence of small rounding errors.

Collections and Matrices​

  • Take, Drop, Slice, and Count now operate on matrix rows consistently. For example, Count(matrix) returns the number of rows.

  • Join now preserves list order, duplicates, and all elements when joining lists. Joining sets continues to produce a deduplicated set.

  • Sums and products over ranges from -∞ to a finite bound, or from -∞ to ∞, now iterate over an appropriate finite approximation instead of an empty range.

Resolved Issues​

  • Significant performance boost when many boxed expressions are involved in computations, due to improved handling of configuration changes and listener management.

  • Long-running evaluation is interruptible. Collection operations, number-theory functions, limits, differentiation, simplification, and integration now respect ce.timeLimit more consistently. Operations that cannot finish in time either throw CancellationError or return the best numeric estimate available, as appropriate.

  • Fractional powers and radicals now preserve the correct principal complex branch. This fixes several unsafe transformations involving negative or unknown-sign values, including x/√(x²), negative factors under roots, products and quotients raised to fractional powers, and 1/√u.

  • Infinity arithmetic is more reliable for finite symbolic denominators, while indeterminate forms such as ∞/∞ remain indeterminate.

  • Numeric limits now reject overflow, catastrophic cancellation, oscillation, and other low-confidence results instead of returning spurious values.

  • Fixed hangs and crashes when factoring certain sums, simplifying expressions with radical coefficients, or mixing non-finite rational values with arbitrary-precision integers.

  • ce.number() now throws a helpful error when passed a MathJSON expression array; use ce.expr() for expressions.

  • Fixed incorrect simplification or evaluation of 2^i, division by a floating-point zero coefficient, and several exact expressions involving negative radicals.

  • Fixed a rational function such as 1/(x(x²+x)) wrongly simplifying (and integrating) to 0 when its factored denominator contained factors sharing a common root. The partial-fraction solver now detects the inconsistent system instead of returning a spurious all-zero decomposition.

  • Factor is more complete: it now extracts a common monomial factor (e.g. x³+x² → x²(x+1), 3x⁴+2x³ → x³(3x+2)) and fully factors already-factored products and powers, so partial-fraction decomposition sees irreducible factors with correct multiplicities.

  • Partial-fraction decomposition now uses exact arbitrary-precision integer arithmetic, so decompositions of higher-degree denominators no longer lose precision (the previous machine-integer solver overflowed past 2⁵³ and could return wrong coefficients).

  • Rational functions with repeated linear or irreducible-quadratic factors now integrate to a closed form via full partial-fraction decomposition — e.g. ∫1/(x²(x+1)) dx and ∫1/(x(1+x²)²) dx, which previously returned an unevaluated integral.

  • Nested powers serialize to LaTeX and round-trip correctly. A Power whose base is itself a Power — i.e. (aᵇ)ᶜ — was serialized as a^{bᶜ}, which re-parses as a^(bᶜ), a different expression. It now serializes as {aᵇ}^ᶜ, so e.g. (x³)^{2/5} round-trips instead of becoming x^{3^{2/5}}.

  • GLSL/WGSL compilation no longer declares int/i32 for a Block's local bindings. An integer-valued local (e.g. ["Assign", "r", 3]) was declared as int r; while its value was emitted as a float literal (r = 3.0;), producing non-compilable shader code that also poisoned downstream float arithmetic. Scalar locals are now declared as float/f32 — consistent with the always-float number literals and scalar shader math — and an explicit ["Declare", "r", "complex"] type is honored. Complex locals still declare as vec2/vec2f.

  • Loop now compiles to JavaScript that returns its collected values. A value loop such as Loop(i², Element(i, Range(1, 5))) compiled to a for-loop IIFE with no return, so it evaluated to undefined at runtime instead of the [1, 4, 9, 16, 25] the interpreter produces. The compiled loop now collects each iteration's value and returns the array. Imperative loops that mutate an outer accumulator or use Break/Continue/Return are unchanged.

  • Integrate now compiles to JavaScript that returns a numeric estimate. For the common \int x^2 dx parse shape (where the integrand is a Function expression), the integrand was wrapped in a double lambda ((x) => ((x) => x*x)), so the Monte-Carlo estimator never called the inner function and returned NaN; it now compiles to a single lambda and returns the estimate (e.g. ∫₀¹ x² dx ≈ 0.333). Integration bounds are also no longer floored, so non-integer limits such as ∫₀^0.5 integrate over the correct interval.

0.59.0 2026-06-10​

This is a significant update to the Compute Engine.

The headline feature of this release is a large collection of curated mathematical identities, the Identities Library:

// When the Identities Library is loaded, CE can prove that...
console.log(parse("\\arctan(2-\\sqrt{3})").simplify().latex);
// ➔ "\frac{\pi}{12}"

// Declare that n is a positive integer...
ce.declare("n", "integer");
ce.assume(parse("n > 0"));

// ...and the parity identity applies:
console.log(parse("\\sin(\\pi n + \\frac{\\pi}{2})").simplify().latex);
// ➔ "(-1)^n"

Read more about the Identities Library in the dedicated guide.

This release also includes a large collection of performance improvements and Resolved Issues across the library.

This release includes some breaking changes.

Breaking Changes​

  • replace() no longer eagerly canonicalizes the complete result. The requested form, or the form produced by the rule, applies to replaced subexpressions. Call .canonical on the result to restore the previous behavior.

  • Fixed-size numeric collections now infer dimensioned types. For example, [1, 2, 3] is now vector<3> instead of list<number>, and a 3×3 numeric collection is matrix<3x3>.

Features​

  • Curated mathematical identities: the new opt-in loadIdentities() API loads over 1,300 guarded simplification rules and special values derived from Fungrim. Identities can be selected by topic, class, or purpose, and rules apply only when their side conditions can be proven.

    import { ComputeEngine } from '@cortex-js/compute-engine';
    import { loadIdentities } from '@cortex-js/compute-engine/identities';

    const ce = new ComputeEngine();
    loadIdentities(ce); // Or: loadIdentities(ce, { topics: ['gamma'] })
    ce.parse('\\Gamma(\\frac12)').simplify(); // → √π

    The loader is synchronous and idempotent per engine. Importing the identities subpath is required, so applications that do not use it incur no bundle cost.

    Simplifying with the full Identities Library loaded is now substantially faster: simplify() runs at roughly 1.2–1.3× the unloaded baseline (previously ~1.6×). The many guarded rules that share a common arithmetic head — Multiply, Add, Divide, … — are dispatched together per head instead of one at a time, so the per-rule overhead on every arithmetic node is paid once per head rather than once per rule. Results are unchanged.

  • More control over replacements:

    • ReplaceOptions.form controls the form of replacement expressions: 'canonical', 'structural', 'raw', or a specific canonical transform. The previous canonical option is deprecated and remains available as an alias for this release.
    • ReplaceOptions.direction selects left-to-right or right-to-left traversal for order-sensitive rules.
    • Custom rules can now match user-defined function operators in replace() and simplify({ rules }).
  • Improved algebra:

    • solve() now handles quadratics with symbolic coefficients, including x^2 - a x + 1 = 0 and the general a x^2 + b x + c = 0. (#300)
    • Factor infers the variable of a univariate polynomial and preserves extracted numeric content: Factor(x^2 + 5x + 6) returns (x+2)(x+3), and Factor(6x + 9) returns 3(2x + 3). (#309)
  • Parsing improvements:

    • Two-argument \arctan(y, x) and \tan^{-1}(y, x) now parse as Arctan2.
    • LaTeX input is normalized to Unicode NFC, so decomposed identifiers parse like their precomposed equivalents.
    • A trailing bare \ and trailing visual spacing commands are tolerated.
    • Multi-character subscripted identifiers such as D_{etectsize} no longer collide with Euler derivative notation.

Resolved Issues​

  • Numeric evaluation and arithmetic:

    • Corrected complex powers, reciprocals, roots, and logarithms, including i^2, i^i, negative complex exponents, and even roots of negative reals.
    • Restored arbitrary-precision accuracy for roots, exp(), ln(), mod(), gammaln(), and large integer conversion. Very small real results such as Power(10, -100).N() are no longer rounded to zero.
    • Exact floor(), ceil(), and round() no longer lose digits beyond 2^53. Large decimal powers no longer report false overflow.
    • Division by zero, NaN * 0, infinity comparisons, and signed infinities now behave consistently across numeric representations.
    • Corrected Arctan2 quadrants, ln(Root(a, b)), non-integer logarithm bases, exact radicals such as sqrt(8), and division of Gaussian integers.
  • Special functions and statistics:

    • Added complex Gamma and GammaLn evaluation. Gamma and factorial poles at non-positive integers now return ComplexInfinity, while factorials of positive non-integers evaluate through Gamma(x + 1).
    • Improved Erf/Erfc to machine precision and corrected small-argument gammaln().
    • Corrected GCD, LCM, Congruent, Subfactorial, negative-index Fibonacci, IsOctahedral, Multinomial, and BellNumber.
    • Corrected skewness, kurtosis, interquartile range, histogram/bin endpoints, and exact combinatorial calculations.
  • Simplification, comparison, and assumptions:

    • Indeterminate comparisons now remain unknown instead of becoming false; this also improves sign inference, Boole, and KroneckerDelta.
    • Fixed equality handling for unordered expressions and multi-variable equation equivalence.
    • Prevented invalid simplification of rational powers such as (-x)^(3/4).
    • Set membership now remains undecided when a symbol's type is unknown, and Subset, SubsetEqual, Superset, and empty-set relations use the correct direction.
    • Symbolic common factors are now recognized, and unresolved derivatives remain symbolic instead of recursing indefinitely.
  • Collections, matrices, and tensors:

    • Corrected Rest, Slice, Drop, Cycle, Position, SetFrom, TupleFrom, Filter, Zip, and compiled Reduce behavior.
    • Determinants now work for matrices of any supported size, with exact integer results; inverses work beyond 2×2.
    • Corrected matrix row access and the isUpperTriangular, isDiagonal, and isTriangular predicates.
    • Incompatible tensor broadcasts now throw instead of producing invalid data; diagonal() respects its axis arguments, and mixed real/complex dtype joins preserve precision.
  • Types and serialization:

    • Dimensioned list and matrix type strings now parse and round-trip, including unknown dimensions, spaces, parenthesized element types, and single ^N dimensions.
    • Corrected union reduction, never subtyping, narrowing of disjoint types, bare matrix handling, numeric literal subtyping, and invalid range validation.
    • String literals now remain strings after MathJSON round-trips, dictionary conversion retains every entry, and function literals can be applied directly.
    • Plain symbols no longer report themselves as empty finite collections.
  • LaTeX parsing and serialization:

    • Corrected scaled/big delimiters, nested \text{...}, repeating decimals beginning with ., digit-like symbol names, prefixed-symbol errors, and unbalanced environment names.
    • Multiplication signs are now emitted where juxtaposition would merge numeric factors, for example 3 \times 2^2 instead of 32^2. (#302)
    • Re-declaring a parser symbol with the same type no longer reports a conflict.
  • Compilation:

    • Corrected JavaScript compilation of symbolic Range, compound-bounded interval Sum/Product, and interpreted fallback for multi-argument lambdas.
    • Corrected Python parentheses for (a^b)^c.
    • Corrected GLSL/WGSL output for Degrees, complex multiplication, Gamma/Factorial/Beta/Erf, and If/Which/When.
    • Corrected symbolic derivatives of Arcsec and Arccsc.
  • Interval arithmetic:

    • Restored conservative enclosures for multiplication involving zero and infinity, negative-modulus mod, clamp, binomial, gcd, lcm, gamma, gammaln, sinc, and Fresnel integrals.

0.58.0 2026-05-12​

Added​

  • \operatorname{count}(L) lowercase alias — function-call form now parses to ["Length", L], matching the existing dot-notation form (L.\operatorname{count}) and the other lowercase aliases (mod, var, shuffle, repeat, join).

  • Repeat(value, count) 2-arg form — Repeat now accepts an optional integer count and evaluates to a finite list of count copies of value. The 1-arg Repeat(value) keeps its existing infinite-sequence semantics. Materialization is gated by ce.maxCollectionSize; larger values stay lazy (still accessible via .at() / iterator).

  • ce.maxCollectionSize — new configurable cap (default 10_000) on the number of elements a collection may have when materialized into a concrete List. Assigning <= 0 or Infinity disables the cap (matching iterationLimit and recursionLimit).

  • Sum(L) collection-reducer form — Sum now accepts a single collection argument and reduces to the sum of its elements: ["Sum", ["List", 1, 2, 3, 4, 5]] // ➔ 15. The big-op form Sum(body, [i, a, b], …) is unchanged. The Sum head is now preserved through canonicalization (previously rewritten to Reduce(L, "Add", 0)), so L.\operatorname{total} round-trips cleanly with latexOptions.dotNotation = true. The async path throws CancellationError on signal abort.

  • At extended with boolean-mask and integer-list indices — At(L, mask) where mask is a finite collection of True/False returns the elements of L where the mask is True. At(L, indices) where indices is a finite collection of integers returns a sublist picked at those positions; out-of-range positions are filtered. Integer indices (At(L, 2)) and string keys (At(d, "key")) work as before.

  • Function-application broadcasting for user-defined lambdas — when a user function with scalar-typed parameters is applied to a finite indexed collection, CE now broadcasts the call elementwise. For ce.assign('f', ce.parse('x \\mapsto x^2 + 1')), the expression ["f", ["List", 1, 2, 3]] evaluates to ["List", 2, 5, 10]. Multi-arg functions broadcast with zip semantics, mixing scalars and lists naturally. The inferred default for \mapsto lambdas is scalar parameters, so most user functions broadcast by default. To opt out, declare an explicit list parameter type via ce.declare(name, '(list<X>) -> Y').

  • List type for mixed-kind and mixed-dimension elements — widen() now builds a structural union when the common supertype would otherwise collapse to a lossy generic category (scalar, value, list, tuple, dictionary, …). Consumers can detect heterogeneous lists by inspecting expr.type.toString():

    • [1, 2, 3] → list<number> (precise)
    • [1, "hello", 3] → list<finite_integer | string> (union)
    • [(1,2), (1,2,3)] → list<tuple<finite_integer, finite_integer> | tuple<finite_integer, finite_integer, finite_integer>> (mixed dimension)
    • [] → list<nothing> (empty)
  • ce.expr(true) / ce.expr(false) — JS boolean primitives now box to the True / False symbols (previously fell through to Undefined).

  • Length operator definition — ce.operatorInfo('Length') now returns a valid entry. The evaluator returns an integer count for finite collections and leaves the expression unevaluated for non-collection or infinite inputs.

  • Library entries for Complex, Colon, Prime — ce.operatorInfo() now returns introspection data for these heads (previously undefined). Complex boxing is unchanged — ["Complex", re, im] still produces a BoxedNumber.

  • ce.symbolInfo(name) — new public API parallel to ce.operatorInfo(), for introspecting constants and declared variables. Returns { kind: 'constant' | 'variable', type: BoxedType } for symbols like Pi, True, ExponentialE, ImaginaryUnit. Returns undefined for unknown names and for operator heads. Added SymbolInfo type to the public type surface.

    • Note: Infinity is registered as PositiveInfinity / NegativeInfinity; Undefined has no value definition.
  • ce.normalizeIdentifier(latex) — new public helper that converts a LaTeX identifier string to its canonical MathJSON name without side effects. Examples: R_{3} → R_3, f_{Bm} → f_Bm, \theta_x → theta_x. Inputs that aren't identifiers ('1 + 2', empty string) return ''. Useful in importer pipelines that need to call ce.declare() with normalized names before parsing referencing rows.

  • First/Second/Third compile entries — component access (p.x, p.y, p.z) now compiles cleanly. JS uses [0]/[1]/[2] index access; GLSL/WGSL use .x/.y/.z swizzles, assuming the argument compiles to a vec2/vec3/vec4. 5+-element tuples (which compile to float[N] arrays) aren't supported.

  • Range GPU compile entry — Range(lo, hi[, step]) with compile-time-constant bounds emits an inline float[N](...) (GLSL) or array<f32, N>(...) (WGSL) literal. Non-constant bounds throw a clear error directing the caller to materialize on the JS host and upload as a uniform. Sequence count is capped at 256 elements per call site.

  • Variance/GCD/Median GPU compile entries — GLSL+WGSL parity with their JS counterparts.

    • Variance is inlined (no size limit).
    • GCD uses a preamble function implementing the Euclidean algorithm.
    • Median is supported for list sizes 2–8; lists with 9+ elements throw.
  • Random GPU compile with deterministic seed — Random(seed) in GLSL/WGSL compiles to a hash-based pseudorandom. Random() (no args) in GLSL falls back to a gl_FragCoord-derived seed (fragment-shader only); in WGSL it throws — callers must provide an explicit seed.

    • The fract-sin hash exhibits banding near seed ≈ kπ. For high-quality shader random, use a more robust hash (e.g. PCG or xxHash).
    • JS-side Random is unchanged (still Math.random, non-seeded). A seeded JS form will land in a future release.
  • toSignedFunction() — new method on BoxedExpression for implicit-surface rendering and region classification:

    • Equal(a, b) → a - b (zero on the surface)
    • Less(a, b) / LessEqual(a, b) → a - b (negative when relation holds)
    • Greater(a, b) / GreaterEqual(a, b) → b - a (negative when relation holds)
    • NotEqual(a, b) → a - b
    • Non-relation expressions return undefined.

    Strictness and direction are encoded in expr.operator. Note that CE canonical form normalizes GreaterEqual to LessEqual(b, a) (and similarly Greater to Less), so callers will typically see the Less/LessEqual operator on parsed expressions — the signed-function semantics are preserved.

  • BoxedExpression.getInterval(symbol) — new method for extracting domain bounds from restriction expressions. Returns IntervalBounds with lower/upper/lowerStrict/upperStrict for When(e, cond), And(c1, c2, …), and bare comparison expressions; returns undefined for unsupported shapes. Useful for 2D-plot domain derivation (e.g. clipping y = f(x)\{0 < x < 5\} to [0, 5]). Added IntervalBounds type to the public type surface.

  • Compact piecewise \{cond_1 : val_1, …, default\} — now parses to Which(c_1, v_1, …, True, default), the same head CE produces for \begin{cases}…\end{cases}. Disambiguated from set-builder \{x : type\} by inspecting the LHS of the top-level Colon: comparison/boolean heads (Less, Greater, Equal, And, Or, Not, …) → piecewise branch; otherwise → set-builder. Normal set literals (\{1, 2, 3\}) and set-builder via \mid are unchanged.

Fixed​

  • Linspace endpoint inclusion — Linspace(a, b, n) now produces n points evenly spanning [a, b] inclusive of both endpoints (matching NumPy, Julia, and MATLAB). Previously the last sample fell short of b (e.g. Linspace(0, 1, 5) yielded 0, 0.2, 0.4, 0.6, 0.8 instead of 0, 0.25, 0.5, 0.75, 1). Linspace(a, b, 1) is the degenerate case and returns just a. The contains check is now tolerance-based (was an exact % test that failed for typical floating-point values).

  • Heterogeneous-list type rendering — lists containing mixed kinds or mixed-dimension tuples previously rendered their type as "[object Object]" in some paths (BoxedDictionary.type, collectionElementType). Types are now constructed programmatically. Lists containing tuples, sets, dictionaries, records, or strings are no longer misclassified as numeric BoxedTensors.

Known issues​

  • JS Loop compile produces undefined — the imperative for-loop IIFE generated for Loop(body, Element(i, Range(lo, hi))) has no return statement, so the compiled function returns undefined rather than the list of body values. Tracked for a future release.

  • JS Integrate compile produces NaN — when args[0] is a Function expression (the common \int x^2 dx parse shape), compileIntegrate produces a double-lambda, so _SYS.integrate receives a function-returning function. Tracked for a future release.

0.57.0 2026-05-10​

Added​

  • verbatim opt-in for toLatex() — expr.toLatex({ verbatim: true }) returns the original LaTeX source captured at parse time when the expression was parsed with preserveLatex: true. Falls back to normal re-serialization if no verbatim is available (e.g. for synthetic or transformed expressions). The default behavior of expr.latex and expr.toLatex() is unchanged — verbatim is strictly opt-in. Useful for round-tripping authored LaTeX (e.g. p.x, \sin(x)) without rewriting it to canonical form.

    • Verbatim is set only on the top-level boxed expression produced directly by ce.parse(..., { preserveLatex: true }). Canonicalization, simplify(), evaluate(), subs(), and ce._fn() produce fresh expressions with verbatimLatex === undefined.
    • Function expressions whose operator has a custom canonical handler (e.g. Sin, Add) currently do not preserve top-level verbatim through canonicalization — the handler reconstructs the result without threading metadata. Atoms (symbols, numbers) and functions without custom canonical handlers (e.g. First) do preserve it. Use form: 'structural' to skip canonical handlers when verbatim preservation matters.
  • dotNotation serialization option — when enabled (default off), member-access heads serialize to dot notation rather than function-call form: First(p) → p.x, Length(L) → L.\operatorname{count}, etc. Useful for round-tripping editor-authored dot-notation back to its source form. Set via ce.latexOptions.dotNotation = true or per-call expr.toLatex({ dotNotation: true }). Only applies to arity-1 forms; multi-operand forms (e.g. Sum with an index range) keep their standard serialization.

    • Serializer-only. The flag lives in SerializeLatexOptions and has no effect on parsing. All input forms continue to parse as before regardless of the flag: |L|, \operatorname{count}(L), L.\operatorname{count}, \operatorname{length}(L) all still parse to ["Length", L] whether dotNotation is on or off. The flag only decides which form the serializer emits.
  • Component access (p.x, L.\operatorname{count}, z.\operatorname{re}) — dot notation now parses to existing semantic heads at parse time. No generic accessor head was introduced.

    • Recognized members and their AST mapping: x/y/z → First/Second/Third; real/re → Real; imag/im → Imaginary; count → Length; total → Sum; max → Max; min → Min.
    • Disambiguation: after a terminated integer or decimal, . followed by a letter or \operatorname{...} is component access, not a decimal point. Examples: 1.x parses as ["First", 1] (not a malformed decimal); 1.5.x parses as ["First", 1.5].
    • Only \operatorname{...} and bare-letter identifiers are recognized after .. \mathrm{...} is not accepted (deliberately tight).
    • Third is a new operator (parallels First/Second) with signature (any) -> any. First/Second were widened from (collection) -> any to (any) -> any so component access on a non-collection (e.g. 1.x) defers type-checking to evaluation; evaluation returns an Error expression for incompatible types.
  • Restriction braces (expr\{cond\}) — trailing brace predicates parse to a new When head.

    • f(x)\{0 < x < 2\} → ["When", ["f", "x"], ["Less", 0, "x", 2]].
    • Stacked restrictions canonicalize: expr\{c_1\}\{c_2\} → ["When", expr, ["And", c_1, c_2]]. Downstream simplification, evaluation, interval intersection, and compilation see a single canonical shape regardless of source form.
    • Disambiguation from set literals is positional: standalone \{1, 2, 3\} continues to parse as a Set; <expr>\{cond\} parses as a When restriction. Allowed left operands include function calls, tuples, list/set literals, bare symbols, subscripted symbols, member access, power expressions, and chained restrictions.
    • Evaluator semantics: When(e, True) evaluates e; When(e, False) returns Undefined; indeterminate cond holds the form.
    • Serializer round-trips to the stacked-brace form (not \wedge inside one set of braces) so authored source and re-serialized output stay visually consistent.
    • JS and GLSL compilation: ternary (cond ? e : NaN).
  • List-range ellipsis ([1...9], [0, 0.1, ..., 1]) — ranges inside list literals parse to the existing Range head.

    • Endpoint-only form: [a...b] → ["Range", a, b]. Triggers ..., \ldots, and \dots are all accepted.
    • Inferred-step form: [a_0, a_1, ..., a_n] → ["Range", a_0, a_n, step] where step = a_1 - a_0 is inferred from the first sample pair. Intermediate samples are validated against a_0 + k·step within ce.tolerance; inconsistent samples produce a parse error.
    • The float idiom [0, 0.1, 0.2, ..., 1] is supported (tolerance-aware comparison; 0.1 + 0.1 ≠ 0.2 exactly but is accepted within tolerance).
    • Outside [...] brackets, \ldots/\dots/... continue to parse as the ContinuationPlaceholder symbol. The trigger is bracket context.
  • For-comprehensions ((x, y) \operatorname{for} x=L_1, y=L_2) — the Loop head now accepts multiple Element clauses, evaluated as nested loops with later bindings seeing earlier ones in scope.

    • Loop(body, Element(x, L_1), Element(y, L_2), ...) produces an indexed_collection<T> of body evaluations, in row-major order.
    • For independent bindings this is the Cartesian product: (x, y) \operatorname{for} x = [1...2], y = [1...2] → 4 tuples.
    • For dependent bindings later clauses see earlier: (x, y) \operatorname{for} x = [1...3], y = [1...x] → 6 tuples (triangle, not Cartesian).
    • Precedence: \operatorname{for} binds looser than , and =, tighter than ;. So (x + y) \operatorname{for} x = L_1, y = L_2 parses with body x + y and two bindings.
    • Bound names do not leak into the enclosing scope (uses Scope.noAutoDeclare).
    • Legacy single-Element form continues to round-trip via the existing \text{for } i \text{ from } a \text{ to } b \text{ do } body syntax. Multi-Element comprehensions serialize to the \operatorname{for} form.
  • Range type is now dynamic — element type narrows based on the step argument: integer step (or no step) yields indexed_collection<integer>; non-integer step yields indexed_collection<number>. Previously the type was always indexed_collection<integer>, which was incorrect for float-step ranges.

  • When head — new conditional-value operator. When(expr, cond) returns expr when cond is true, Undefined when cond is false, and holds when cond is indeterminate. Used by restriction-brace parsing (see above) but also usable directly.

  • ce.operatorInfo(head) — new method on ComputeEngine for introspecting registered operator heads. Returns { kind: 'function' | 'opaque', signature?: BoxedType } or undefined.

    • 'function' — head has an evaluate handler or a collection handler (lazy producers like Range, Linspace, Tuple work via the latter).
    • 'opaque' — head is declared with a signature but has neither (e.g., Triangle, Sphere, GeometricVector).
    • undefined — no operator definition (constants like Pi and unknown heads).
    • Lets external tooling classify heads by capability without maintaining a parallel list of supported operators.
  • tolerance in ParseLatexOptions — populated automatically from ce.tolerance when parsing through ce.parse(). Used by list-range sample validation; available to other parse handlers that need tolerance-aware comparison.

Fixed​

  • Loop with Element clause — single-Element Loop(body, Element(i, range)) previously did not produce a list of body evaluations (the iteration path for Element form had a bug). The new variadic evaluator correctly yields a List of body values for each iteration.

0.56.0 2026-03-10​

Added​

  • First-class color values — colors are now typed values with a dedicated color primitive type and per-colorspace constructor heads, rather than anonymous tuples.

    • Constructor heads: Rgb, Hsv, Hsl, Oklab, Oklch. Each takes 3 components plus an optional alpha. Channels follow each colorspace's own conventions (RGB: 0–1 sRGB; HSV/HSL: hue in degrees, S/V/L 0–1; Oklab/Oklch: standard ranges).
    • LaTeX: \operatorname{rgb}(...), \operatorname{hsv}(...), \operatorname{hsl}(...), \operatorname{oklab}(...), \operatorname{oklch}(...), parsing and serialization both directions.
    • Conversions: AsRgb, AsHsv, AsHsl, AsOklab, AsOklch convert any color to the named space (identity if already there).
    • ColorDelta(a, b) — perceptual color difference (ΔE_OK, Euclidean distance in OKLab). Wide-gamut inputs are not clipped before measurement.
  • JavaScript compile-target support for color values — all color constructors, the As* converters, ColorDelta, and Distance are supported. At runtime a color is a 3- or 4-element OKLCh array ([L, C, H] or [L, C, H, alpha]), matching the GPU target's vec3/vec4 representation, so values move between JS, GLSL, and WGSL without conversion.

  • Distance(p1, p2) — Euclidean distance between two points represented as tuples. Accepts any positive dimension; mismatched dimensions return a typed error. LaTeX trigger \operatorname{distance}(p1, p2).

  • Geometric primitive heads — Triangle, Sphere, Segment, and GeometricVector are now recognized as typed function heads (no evaluator, preserved structurally for downstream consumers). LaTeX triggers \operatorname{triangle}, \operatorname{sphere}, \operatorname{segment}, \operatorname{vector}(p1, p2). GeometricVector is distinct from the existing Vector (column-vector construction).

  • To head registered — \to already parsed to ["To", a, b] but was classified as unsupported-operator; it is now a known typed head.

  • Function-style aliases — lowercase \operatorname{...} forms common in Desmos-style notation now parse to their existing capitalized operators: \operatorname{mod} → Mod, \operatorname{var} → Variance, \operatorname{shuffle} → Shuffle, \operatorname{random} → Random, \operatorname{repeat} → Repeat, \operatorname{join} → Join.

  • ce.latexOptions — new mutable, engine-wide bag of LaTeX parse/serialize options (e.g. decimalSeparator, digitGroupSeparator). Available as a constructor option and as a read/write property:

    const ce = new ComputeEngine({ latexOptions: { decimalSeparator: '{,}' } });
    // or post-construction:
    ce.latexOptions = { decimalSeparator: '{,}' };

    These options are merged into every ce.parse() and expr.toLatex() call. Precedence (most-specific wins): LatexSyntax instance defaults < ce.latexOptions < per-call options. Previously, options like decimalSeparator could only be changed per call post-construction (and expr.latex could not be customized at all).

Changed​

  • Color('...') now returns an Oklch head instead of a 0–1 sRGB Tuple. The string parser still accepts the same set of CSS-style inputs.
  • ColorMix now returns an Oklch head and mixes in OKLCh directly, preserving out-of-gamut chroma. Hue interpolation takes the shortest path around the wheel; mixing with an achromatic endpoint carries the other endpoint's hue (matches CSS Color 4 color-mix).
  • ContrastingColor now returns an Rgb head (was: 0–1 sRGB Tuple).
  • Colormap now returns Oklch heads — either a List(Oklch, ...) or a single Oklch for position-sampling.
  • ColorToString with 'oklch' format serializes typed color inputs without an sRGB round-trip; out-of-gamut chroma serializes losslessly. 'hex'/'rgb'/'hsl' paths are unchanged.
  • Color-consuming signatures tightened — (any, any) → (color | string | tuple, color | string | tuple) for ColorDelta, ColorContrast, ColorMix, ContrastingColor, ColorToString, ColorToColorspace. The As* converters take (color) -> color.

Migration notes​

Code that consumed the tuple output of Color('...'), ColorMix, ContrastingColor, or Colormap now sees a typed color head. To get the previous 0–1 sRGB shape, wrap with AsRgb:

// Before: const tuple = ce.expr(['Color', "'red'"]).evaluate(); // [r, g, b] in 0-1
// Now (equivalent 0-1 sRGB):
const rgb = ce.expr(['AsRgb', ['Color', "'red'"]]).evaluate();
// rgb is ['Rgb', r, g, b] with channels 0-1

Rgb head components are 0–1 sRGB across all layers (engine, JS compile, GPU compile).

Fixed​

  • Super-linear parse time on deeply-nested parametric expressions — ce.parse() could exhibit exponential blowup on inputs like nested rotation matrices \left(\cos(\theta)\cdot S+\sin(\theta)\right) (depth 6 took ~44s). Two underlying causes were addressed: the type/sign cache on BoxedFunction was effectively disabled (causing every .type access to recurse through all operands), and parseEnclosure was speculatively trying matchfix definitions whose close-delimiter token wasn't even present in the input. Parse time on the affected inputs is now linear.

  • ce.parse() ignored the injected LatexSyntax instance's decimalSeparator — ce.parse() hardcoded decimalSeparator: '.', silently overriding any value configured on a LatexSyntax passed via the constructor's latexSyntax option. The injected instance's configured separator now takes effect end-to-end.

  • expr.toMathJson({ metadata: ['latex'] }) was silently dropped — passing a metadata array of specific fields (e.g. ['latex'] or ['wikidata']) was ignored; only metadata: 'all' worked. The array form now correctly populates the requested fields.

  • expr.toMathJson({ shorthands: ['all'] }) disabled all shorthands — the ['all'] array form had the opposite of its intended effect. The string form 'all' and explicit lists like ['function'] were unaffected.

0.55.6 2026-03-08​

Resolved Issues​

  • LaTeX parsing: \lim with postfix operators — \lim_{x\to 0}\left(x\right)^x now correctly parses as Limit(x^x) instead of Power(Limit(x), x). The \lim parser was using parseArguments('implicit') which stripped the delimiters and left the ^x unconsumed; it now uses parseExpression so postfix operators are included in the limit body.

  • LaTeX parsing: style, size, and color switch commands — \displaystyle, \textstyle, \scriptstyle, \scriptscriptstyle, \tiny..\Huge (10 size commands), and \color{...} were silently discarded during parsing. They now produce Annotated expressions that preserve the styling information and round-trip correctly through serialization. Added \scriptstyle / \scriptscriptstyle serialization support (previously only \displaystyle and \textstyle were handled).

  • LaTeX parsing: set-builder notation — \{x \in \R \mid x > 0\} now parses to ["Set", expr, ["Condition", cond]]. Registered \mid as an infix operator (Divides, precedence 160). The serializer round-trips set-builder notation correctly.

  • LaTeX serialization: Complement — ["Complement", "A"] now serializes to A^\complement instead of falling back to the generic function form. Removed stale @todo comments about a non-existent multi-argument case.

  • LaTeX parsing: spacing commands — \hspace{dim}, \hspace*{dim}, \hskip, and \kern are now consumed during parsing (previously caused "unexpected token" errors). These are treated as visual spacing and skipped.

  • LaTeX serialization: HorizontalSpacing math classes — the 2-argument form ["HorizontalSpacing", expr, "'bin'"] now serializes to \mathbin{expr} (and similarly for rel, op, ord, open, close, punct, inner). Previously the second argument was silently dropped.

  • LaTeX serialization: redundant parens on matchfix operators — wrap() no longer adds parentheses around Abs, Floor, Ceil, Norm, and other matchfix expressions that already have visible delimiters.

  • LaTeX serialization: tabular environments — default environment serializer now renders matrix bodies (List of Lists) with & column separators and \\ row separators instead of nested function calls.

  • LaTeX serialization: matchfix delimiter scaling — default matchfix serializer now respects groupStyle to choose between bare delimiters, \left..\right, or \bigl..\bigr scaling.

  • LaTeX parsing: Greek symbols in string groups — \alpha, \beta, etc. in parseStringGroupContent() (used by \begin/\end, color arguments) are now interpreted as their Unicode equivalents instead of passing through as raw LaTeX commands.

0.55.5 2026-03-06​

Resolved Issues​

  • Deep-zoom fractal precision — emulated-double (dp) and perturbation (pt) shaders now compute per-pixel coordinates from v_uv and viewport uniforms instead of the shader template's single-precision mix(), which lost distinguishability at high zoom levels.
  • Perturbation theory: absolute vs delta coordinates — the perturbation Mandelbrot/Julia handlers were passing absolute single-precision coordinates to the shader instead of the small delta from the reference center. Fixed by introducing _pt_delta() which computes the per-pixel offset from viewport uniforms.
  • compile() free function dropped hints — the hints option (viewport center/radius) was accepted but silently not forwarded to the language target. Fixed in compile-expression.ts.

New Features​

  • BigDecimal export — the arbitrary-precision decimal class is now exported from the public API for use by plot engines and other consumers that need precision beyond float64.
  • HighPrecisionCoord type — new union type (number | string | { hi: number; lo: number }) for passing extended-precision viewport coordinates through the compile API. The viewport.center option now accepts this type instead of plain [number, number].

0.55.4 2026-03-06​

Resolved Issues​

  • #254 LaTeX parsing: interval notation with \lbrack/\lparen — parsing \lbrack5,7) or \left\lbrack5,7\right) now correctly produces an Interval expression. Previously, when the open delimiter was a LaTeX command (e.g., \lbrack), the parser incorrectly required the close delimiter to also be a LaTeX command (e.g., \rparen instead of )), causing mismatched-delimiter intervals to fail.
  • LaTeX parsing: invalid symbols in \mathrm{} and related prefixes — invalid content inside \mathrm{}, \operatorname{}, etc. (e.g., \mathrm{=} or \mathrm{DavidBowie👨🏻‍🎤}) now produces the correct invalid-symbol error instead of cascading parse errors. Also fixed matchPrefixedSymbol leaking parser state on failure, and emoji sequences are now properly recognized inside symbol prefixes (e.g., \operatorname{😎🤏😳🕶🤏}).

New Features​

  • High-precision Mandelbrot/Julia compilation — the GPU compilation targets (GLSL, WGSL) now support three precision tiers for fractal rendering, selected automatically based on viewport hints:
    • Single float (zoom < 10^6x): existing implementation, no overhead
    • Emulated double (zoom 10^6x–10^14x): double-single (float-float) arithmetic using Dekker/Knuth algorithms, ~48-bit mantissa from two 32-bit floats
    • Perturbation theory (zoom > 10^14x): reference orbit computed on CPU at arbitrary precision via BigDecimal, GPU iterates only the small delta from the reference, with glitch detection and single-float rebase fallback
  • Viewport-aware compile API — compile() accepts optional hints: { viewport: { center, radius } }. The compiler auto-selects the precision strategy and returns staleWhen thresholds for cheap staleness checking by the plot engine.
  • CompilationResult extensions — new optional fields: staleWhen (plain data staleness predicate), uniforms (scalar shader uniforms), textures (typed texture data with format/dimensions for GPU upload).

0.55.3 2026-03-05​

Improved​

  • Compilation: constant folding — Add, Multiply, Subtract, Negate, Divide, Power, Sqrt, and Root handlers now fold numeric literals at compile time and eliminate identity values.
    • x + yi compiles to vec2(x, y) instead of vec2(x, 0.0) + (y * vec2(0.0, 1.0))
    • 2 + 3 → 5.0, x + 0 → x, x * 1 → x, x * 0 → 0.0
    • Power(x, 2) → (x * x) for simple operands, pow(f(x), 2.0) for complex expressions to avoid duplicate computation
    • Power(x, 0.5) → sqrt(x), Power(x, 0) → 1.0, Power(x, -1) → (1.0 / x)
    • Sqrt(4) → 2.0, Root(x, 2) → sqrt(x)
  • isComplexValued uses expression type system instead of hard-coded operator list.
  • Integer arguments in GPU fractal functions emit as 200 instead of int(200.0).
  • Type-based optimizations — compilation handlers now use expression type information for better code generation:
    • Floor/Ceil/Round/Truncate are no-ops when the operand is integer-typed
    • Abs is a no-op when the operand is provably non-negative
    • Power(x, 2) only expands to (x * x) for simple operands (symbols, literals) — function calls like Power(Sin(x), 2) use pow/Math.pow to avoid duplicate evaluation
    • Integer Mod with non-negative dividend uses plain % instead of the Euclidean double-mod formula
    • GPU variable declarations infer i32/int type for integer-typed locals

Resolved Issues​

  • Abs signature: return type is now real instead of propagating the input type (which incorrectly returned complex for complex inputs).
  • Compilation fallback: uses pushScope/assign pattern instead of crashing when receiving a vars object.

New Features​

  • Mandelbrot and Julia operators in JavaScript and GPU compilation targets.

0.55.2 2026-03-04​

Resolved Issues​

  • \text{} flush bug: \text{a$x$b} now correctly produces ["Text", "'a'", "x", "'b'"]. Previously the text before and after inline math were merged due to a missing flush() call in parseTextRun.
  • # / * parsed as valid symbols: Bare # and * tokens were incorrectly accepted as valid symbol names because they match the Unicode Emoji property (keycap base characters). They now produce unexpected-token errors as expected. The fix excludes ASCII characters from the emoji regex in symbol validation.
  • Text operator type: The Text operator now has return type string instead of expression.
  • \textcolor inside \text{}: \textcolor{red}{RED} inside \text{} now correctly parses the body as text ('RED') instead of switching to math mode and treating each letter as a separate symbol.
  • parseSyntaxError token consumption: Non-command tokens (like #, &) are now consumed when producing errors, preventing potential parser loops.
  • parseSymbolToken hardening: Raw tokens are pre-validated against \p{XIDC} before being consumed as symbols, providing defense-in-depth against future isValidSymbol regressions.

New Features​

  • Text promotion: When InvisibleOperator canonicalization encounters a Text expression or a string operand, it now absorbs all operands into a single Text expression. For example, a\text{ in $x$ }b canonicalizes to ["Text", "a", " in ", "x", " ", "b"] instead of producing a Tuple.
  • Text infix keywords: \text{and}, \text{or}, \text{iff}, and \text{if and only if} are now recognized as infix operators that produce And, Or, and Equivalent expressions respectively, following the existing \text{where} pattern.
  • Additional text keywords: \text{such that} (maps to Colon), \text{for all} (maps to ForAll), and \text{there exists} (maps to Exists) are now recognized as operators.
  • Text serializer: Text expressions now round-trip back to proper \text{...} LaTeX with inline $...$ for math sub-expressions, instead of falling through to the default \mathrm{Text}(...) output.
  • Text evaluate handler: Evaluating a Text expression now concatenates all operands into a single string.

0.55.1 2026-03-04​

Resolved Issues​

  • After parse('f(x):=\\sin(x)'), the symbol f is now immediately recognized as having type function. Previously its type remained unknown until the Assign expression was explicitly evaluated.
  • 2f(x) and 2f \left(x\right) now both correctly parse as ["Multiply", 2, ["f", "x"]] when f is a known function symbol. Previously, a space before \left caused the parser to produce a Tuple instead of Multiply, and expressions whose return type was any (e.g., calls to generically-typed functions) were also misclassified as Tuple.
  • Expressions involving operators that return expression type (such as D, Simplify, Annotated) are now correctly treated as multiplicable in juxtaposition contexts. For example, 2f'(x) now produces ["Multiply", 2, ["D", ...]] instead of Tuple.
  • The D (derivative) operator now returns a numeric type when its body is numeric, instead of always returning the generic expression type.
  • Undeclared symbols followed by parenthesized multi-argument expressions (e.g., 2g(x,y)) are now auto-declared as functions in all invisible operator paths, not just the two-operand path.

0.55.0 2026-03-04​

Breaking Changes​

  • ce.box()/box() renamed to ce.expr()/expr() (ce.box() remains as a deprecated wrapper).
  • Removed ce.latexDictionary getter/setter; configure dictionaries through new LatexSyntax({ dictionary: [...] }).
  • Removed ComputeEngine.getLatexDictionary(); import dictionary constants from package exports.
  • Removed deprecated type guard aliases: isBoxedExpression, isBoxedNumber, isBoxedSymbol, isBoxedFunction, isBoxedString, isBoxedTensor (use isExpression, isNumber, isSymbol, isFunction, isString, isTensor).
  • Removed LibraryDefinition.latexDictionary; LaTeX dictionaries now live in the latex-syntax module.

Resolved Issues​

  • #295 The parse() free function now accepts the form options object, so parse("\\frac{10}{2}", { form: "raw" }) return ["Divide", "10", "2"].
  • Undeclared symbols followed by parenthesized numeric expressions are now interpreted as multiplication, not implicit function calls (for example, q(2q) -> 2q^2). Function-call behavior remains for explicitly declared function symbols and non-numeric argument forms.

New Features​

  • Modular package exports for smaller bundles: @cortex-js/compute-engine/core, @cortex-js/compute-engine/compile, @cortex-js/compute-engine/latex-syntax, @cortex-js/compute-engine/numerics, and @cortex-js/compute-engine/interval (with existing sub-paths still available, including math-json).
  • New standalone LatexSyntax API (class + parse()/serialize() helpers) for LaTeX ↔ MathJSON without a ComputeEngine instance.
  • New ILatexSyntax interface exposed via IComputeEngine.latexSyntax to allow custom LaTeX parser/serializer implementations.
  • All 16 LaTeX domain dictionaries are now exported individually, plus the combined LATEX_DICTIONARY.
  • Parser type is now exported from the main package for typed custom LatexDictionaryEntry parse handlers.

Changed​

  • ComputeEngine now accepts an injectable latexSyntax dependency.
    • Full package imports still auto-create a LaTeX syntax instance.
    • Core-only imports do not bundle LaTeX support; parse(), .latex, and toLatex() require an injected LatexSyntax.
    • MathJSON serialization omits optional LaTeX metadata when no LaTeX syntax is present.
  • decimal.js has been replaced with a native bigint-backed BigDecimal implementation, reducing dependency surface and bundle size.
  • BigDecimal add(), sub(), and mul() are now exact; rounding is limited to operations that require it (div(), non-integer pow(), transcendentals).
  • Numeric string/LaTeX serialization now respects precision settings: .latex/.toString() round to ce.precision, while .json/toJSON() remain lossless.
  • High-precision special functions (bigGamma, bigGammaln, bigDigamma, bigTrigamma, bigPolygamma, bigZeta) now scale with BigDecimal.precision; integer Gamma values are exact.

0.54.0 2026-02-26​

  • New expr.polynomialCoefficients() method: Returns the coefficients of a polynomial expression in descending order of degree, or undefined if the expression is not a polynomial. Auto-detects the variable when the expression has exactly one unknown. Subsumes isPolynomial (check !== undefined) and degree computation (length - 1).

  • polynomialCoefficients() now accepts an array of variables: Pass ['x', 'y'] to verify the expression is polynomial in all listed variables. Coefficients are decomposed by the first variable.

  • New expr.polynomialRoots() method: Returns the roots of a polynomial expression, or undefined if not a polynomial. Handles degree 3+ polynomials with rational roots via the Rational Root Theorem.

  • New Polynomial CAS function: Constructs a polynomial from a coefficient list (descending order) and a variable. Inverse of CoefficientList: Polynomial([1, 0, 2, 1], x) evaluates to x³ + 2x + 1.

  • Improved Factor for degree 3+ polynomials: Factor now uses the Rational Root Theorem to factor polynomials with integer coefficients and rational roots. Previously only handled degree ≤ 2.

  • Improved Factor with content extraction: Factor now extracts the GCD of integer coefficients before applying other strategies. For example, Factor(6x² + 12x + 6, x) now produces 6(x+1)².

  • New PartialFraction CAS function: Decomposes rational expressions into partial fractions. Supports distinct and repeated linear factors, irreducible quadratic factors, and improper fractions (polynomial division performed first). Example: PartialFraction(1/((x+1)(x+2)), x) → 1/(x+1) - 1/(x+2).

  • New Apart CAS function: Alias for PartialFraction.

  • New PolynomialRoots CAS function: Returns the roots of a polynomial as a set. Example: PolynomialRoots(x² - 5x + 6, x) → {2, 3}.

  • New Discriminant CAS function: Returns the discriminant of a polynomial of degree 2, 3, or 4. Supports symbolic coefficients. Example: Discriminant(x² - 5x + 6, x) → 1.

  • simplify() auto-decomposes partial fractions: When a Divide expression has a denominator already in factored form (product or power) and the decomposition is simpler, simplify() automatically applies partial fraction decomposition.

  • Breaking: CoefficientList now returns descending order: The CAS function CoefficientList now returns coefficients from highest to lowest degree (e.g., [1, 0, 2, 1] for x^3 + 2x + 1), matching the new polynomialCoefficients() method and common external conventions. Previously it returned ascending order.

  • expr.match() now accepts string patterns with auto-wildcarding: Pass a LaTeX string like 'ax^2+bx+c' and single-character symbols are automatically treated as wildcards. Results use clean unprefixed keys ({a: 3, b: 2, c: 5}) with self-matches filtered out. useVariations and matchMissingTerms default to true for string patterns.

  • expr.match() now accepts MathJSON arrays directly: Pass a raw MathJSON pattern like ['Add', '_a', '_b'] without calling ce.box() first.

  • New matchMissingTerms option for match(): When enabled, expressions with fewer operands than the pattern can still match by treating missing terms as identity elements (0 for Add, 1 for Multiply). For example, 3x^2+5 matches the pattern ax^2+bx+c with b = 0. Enabled by default for string patterns.

  • Non-strict parsing: implicit superscript for letter+digit: In non-strict mode, a single letter immediately followed by a digit 2–9 is parsed as an exponent: x2 + y2 → x^2 + y^2. Handles common copy-paste from web pages. Only digits 2–9, only single ASCII letters, and only when adjacent (no space).

0.53.1 2026-02-25​

  • timeLimit now reliably interrupts long-running evaluations: Factorial, Sum, Product, Loop, and Reduce all respect the timeLimit property and throw CancellationError when the deadline is exceeded. Previously, generators yielded too infrequently (every 1,000–50,000 iterations), allowing a single gen.next() call to block for longer than the timeout. All generators now yield every iteration. The Factorial handler no longer silently swallows CancellationError, and withDeadline/withDeadlineAsync now use try/finally to always reset the engine deadline.

  • Fixed GPU compilation of Sum, Product, Loop, and Function: These constructs no longer leak JavaScript-specific syntax (IIFEs, let, while, arrow functions, { re, im } objects) into GLSL/WGSL output. Sum/Product with small constant bounds are unrolled inline; larger ranges emit native for loops. Loop emits a GPU for loop with int/i32 index. Function (lambda) now throws a clear error for GPU targets. Block-level Declare statements infer vec2/vec2f type from subsequent complex-valued assignments.

  • Added GLSL/WGSL compilation for Heaviside, Sinc, FresnelC, FresnelS, BesselJ: These five special functions now compile to GPU shader targets. FresnelC/FresnelS use a three-region rational Chebyshev approximation (ported from Cephes/scipy) with a shared _gpu_polevl helper. BesselJ uses power series, Hankel asymptotic, and Miller's backward recurrence depending on the argument range. Both GLSL and WGSL preambles are emitted on demand.

  • Fixed GLSL/WGSL block expression compilation: Block expressions (produced by \coloneq / semicolon blocks) now emit valid GPU shader code instead of JavaScript syntax. Variable declarations use float x (GLSL) or var x: f32 (WGSL) instead of let x, and blocks are emitted as plain statements instead of JavaScript IIFEs. compileFunction correctly formats multi-statement bodies.

  • Fixed \; in \text{where} clauses: Visual spacing commands like \;, \,, \quad, etc. between comma-separated bindings in where-clauses are now correctly skipped instead of being parsed as HorizontalSpacing expressions wrapped in InvisibleOperator.

  • Fixed require() returning empty exports on Node 22+ (#292): Because the package sets "type": "module", Node treated the UMD .js files as ESM, breaking the UMD factory pattern. The UMD builds now use a .cjs extension so Node always treats them as CommonJS.

0.53.0 2026-02-21​

Runtime and Scoping​

  • True lexical scoping for Function expressions: Functions now capture their defining scope and resolve free variables from that scope chain (not the call site), with a fresh child scope on each call.

  • BigOp scope pollution fixed: Sum, Product, and other big operators now only declare their index variable locally. Other names are declared in the enclosing scope via noAutoDeclare.

  • Closure capture for nested functions: Returned functions now correctly capture outer parameters across multiple nesting levels.

  • EvalContext.values removed: Symbol values now live only in BoxedValueDefinition.value. The per-frame shadow map and withArguments option were removed.

  • forget() now resets values set by assume(): forget('x') now clears values introduced by assume('x = ...') (value reset to undefined), in addition to clearing assumptions.

Expressions and Equality​

  • expand() now returns the input expression instead of null: Both the free function and internal expand()/expandAll() now return the original expression when no expansion is possible.

  • New .toRational() method: Returns [numerator, denominator] integers for rational expressions, or null otherwise.

  • New .factors() method: Returns multiplicative factors as a flat array by decomposing Multiply and Negate structurally.

  • .is() now tries expansion: After structural comparison, .is() expands both sides before numeric fallback, catching forms like (x+1)^2 and x^2+2x+1.

  • .is() is now symmetric: a.is(b) === b.is(a) now holds across all expression types.

LaTeX Parsing​

  • Parse \mleft/\mright delimiters: Alternative delimiters from the mleftright package are now treated like \left/\right.

  • Parse \color in math mode: \color{...} is now recognized in math mode; the color argument is consumed so the following math parses normally.

  • Parse : and \colon as infix operators: Outside quantifier contexts, a bare :/\colon now parses as Colon (e.g. f:[a,b]\to\R), without affecting := assignment or quantifier syntax.

  • Parse \dfrac, \tfrac, and \cfrac as fractions: These variants now parse the same as \frac.

Fractals​

  • New Mandelbrot and Julia functions: Added built-in escape-time fractal operators. Mandelbrot(c, maxIter) and Julia(z, c, maxIter) return a smooth, normalized value in [0, 1] (1 for interior points, fractional for escaping points via log₂(log₂(|z|²)) smoothing). Both evaluate in JavaScript and compile to GLSL/WGSL.

0.52.1 2026-02-19​

Expressions​

  • Exact number literal check: Use isNumber(expr) && expr.isExact to test for exact numeric literals.

  • raw form preserves subtraction: x-1 now parses as ["Subtract", "x", "1"] (instead of ["Add", "x", -1]) when using raw form.

Parsing and Blocks​

  • Fix ;\; parsing in semicolon blocks: Spacing commands after semicolons (\;, \,, \quad, etc.) no longer create spurious Nothing operands.

  • Fix \text{if} parsing with \; spacing: \text{if}\;...\;\text{then}\;...\;\text{else}\;... now parses correctly as If.

  • Block serializer now uses ; : Serialization emits ; (not ;\; ) to avoid reintroducing spacing-related parse issues on round-trip.

  • Block compiler filters Nothing operands: The Block compiler now removes Nothing symbols and empty compile results before generating code.

  • Subscripted variable names in blocks: Names like r_1 are treated as compound symbols (not Subscript) when the base is not a known collection.

  • Non-strict parser supports exponents on bare functions: In strict: false mode, forms like sin^2(x) and cos^{10}(x) now parse correctly as powers.

  • Unicode superscript/subscript digits supported: Superscript and subscript Unicode digits now normalize to ^{...} / _{...} in parsing.

Compilation​

  • Selective GLSL interval preamble: interval-glsl now emits only used helper functions (plus dependencies), typically reducing preamble size by 60-80%.

  • Selective WGSL interval preamble: interval-wgsl now applies the same used-only preamble strategy.

  • Fix recursive GLSL gamma helper: Replaced recursive _gpu_gamma() reflection logic (illegal in GLSL) with a non-recursive implementation.

Equality​

  • .is() now works with assigned variables: Numeric fallback now applies to expressions with no free variables, including variables with assigned values.

  • .is() now accepts an optional tolerance: A per-call tolerance can override engine.tolerance for numeric comparison.

0.52.0 2026-02-18​

New Features​

  • Smart .is() / exact .isSame() separation: The .is() and .isSame() methods on expressions now have distinct roles:

    • .isSame(v) — Fast exact structural check. No evaluation, no tolerance. Now accepts primitives (number, bigint, boolean, string) in addition to Expression. This is the method used internally throughout the engine.

    • .is(v) — Smart check with numeric evaluation fallback. Tries .isSame() first; if that fails and the expression is constant (no free variables), evaluates numerically and compares within engine.tolerance. For literal numbers, behaves identically to .isSame() — tolerance only applies to expressions that require evaluation.

    This resolves a common pain point where ce.parse('\\cos(\\pi/2)').is(0) returned false because .is() was purely structural. Now it returns true:

    ce.parse('\\sin(\\pi)').is(0); // true (evaluates, within tolerance)
    ce.parse('\\cos(\\frac{\\pi}{2})').is(0); // true
    ce.number(1e-17).is(0); // false (literal number, no tolerance)
    ce.parse('x + 1').is(1); // false (not constant, no fallback)
  • numericValue() convenience helper: New standalone function that combines the isNumber() guard with .numericValue access. Returns the numeric value if the expression is a number literal, or undefined otherwise. Useful for safely extracting numeric values without verbose ternary patterns:

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

    // Before
    const val = isNumber(expr) ? expr.numericValue : undefined;

    // After
    const val = numericValue(expr);
  • Stochastic equality check for expressions with unknowns: expr.isEqual() now uses a stochastic fallback when symbolic methods (expand + simplify) can't prove equality. Both expressions are evaluated at 50 sample points (9 well-known values + 41 random) and compared with relative+absolute tolerance. This detects equivalences like sin²(x) + cos²(x) = 1, (x+y)² = x²+2xy+y², and sin(2x) = 2sin(x)cos(x) that were previously returned as undefined. Singularities (NaN at a sample point) are skipped rather than treated as disagreements. The check also works when the two expressions have different unknowns (e.g. x - x + y vs y).

  • expr.freeVariables property: New property on BoxedExpression that returns the free variables of an expression — symbols that are not constants, not operators, not bound to a value, and not locally scoped by constructs like Sum or Product. Semantically identical to expr.unknowns.

  • New interval-js compilation functions: Added Binomial, GCD, LCM, Chop, Erf, Erfc, Exp2, Arctan2, and Hypot to the interval-js compilation target, with corresponding interval arithmetic implementations.

  • GLSL/WGSL variable exponent support: The interval GLSL and WGSL targets now support Power with variable exponents (e.g. (-1)^k, x^n). Previously these threw at compile time. Added ia_pow_interval() to both GPU library preambles using four-corner exp(exp * ln(base)) evaluation with special cases for point-integer exponents and (-1)^n.

  • Factorial, Gamma, GammaLn for GLSL/WGSL interval targets: Added ia_factorial (via ia_gamma(x+1)) to both GPU targets. Added ia_gamma (Lanczos approximation) and ia_gammaln (Stirling asymptotic) to the WGSL target, matching existing GLSL implementations.

Resolved Issues​

  • parse() with form: 'structural' ignored the structural flag: The structural option from formToInternal() was dropped in parseLatexEntrypoint(), making ce.parse(s, { form: 'structural' }) behave identically to { form: 'raw' } (unbound, unsorted). Now correctly produces a bound, structural expression.

  • Partial canonicalization with 'Flatten' form folded numerics: Using ce.parse(s, { form: ['Flatten', 'Order'] }) unexpectedly evaluated numeric operands (e.g. 3×2+1 became 7) because flattenForm() used ce.function() which defaults to full canonical mode. Now uses ce._fn() to preserve operand structure. This enables structural comparison of expressions modulo commutativity and associativity without numeric evaluation — useful for checking the method used to solve a problem rather than just the numeric result:

    const a = ce.parse('3\\times2+1', { form: ['Flatten', 'Order'] });
    const b = ce.parse('1+2\\times3', { form: ['Flatten', 'Order'] });
    a.isSame(b); // ➔ true (same structure, different order)

    const c = ce.parse('7', { form: ['Flatten', 'Order'] });
    a.isSame(c); // ➔ false (different structure)
  • Sum/Product with symbolic bounds compiled incorrectly: Expressions like \sum_{k=0}^{n} f(k, x) where the upper bound is a variable produced loops that iterated 10001 times instead of using the variable n. The compilation extracted bounds via normalizeIndexingSet() which converted symbolic bounds to NaN and fell back to a hardcoded limit. Now bounds are extracted as expressions and compiled to code (e.g. Math.floor(_.n) for JS, Math.floor((_.n).hi) for interval-js). This fixes Taylor series patterns like \sum_{k=0}^{n} \frac{(-1)^k x^{2k+1}}{(2k+1)!} for both JS and interval-js targets.

  • Interval (-1)^k returned empty instead of correct value: The powInterval() function required positive bases for variable exponents, causing (-1)^k patterns in summations (e.g. Taylor series) to fail at runtime. Now correctly delegates to intPow() when the exponent is a point interval with an integer value, preserving even/odd parity. Also handles the case where base is -1 and the exponent spans multiple integers by returning the conservative interval [-1, 1].

  • Factorial missing from interval-js compilation target: Expressions containing n! (e.g. \frac{(-1)^k x^{2k+1}}{(2k+1)!}) failed interval-js compilation with success: false. Added Factorial and Factorial2 interval functions and compilation handlers.

  • expr.unknowns included bound variables: Scoped constructs like Sum, Product, Integrate, and Block bind index variables in a local scope, but expr.unknowns was reporting them as free unknowns. For example, \sum_{k=0}^{10} k \cdot x returned ["k", "x"] instead of ["x"]. Now correctly excludes locally bound variables from the result.

  • Symbolic upper bounds missing from expr.unknowns: In expressions like \sum_{k=0}^{M} k \cdot x, the symbolic upper bound M was incorrectly excluded from unknowns because the scope's bindings map captured all symbols referenced during canonicalization. Now extracts bound variables structurally from Limits/Element/Assign/Declare expressions, so only true bound variables are excluded. This also fixes Block expressions where locally assigned variables (via Assign or Declare) were reported as unknowns.

  • Integrate with symbolic bounds compiled incorrectly: Same issue as Sum/Product — compileIntegrate() used normalizeIndexingSet() which converted symbolic bounds to NaN. Now uses extractLimits() and compiles bounds as expressions.

  • Interval piecewise test fix: Fixed test that incorrectly accessed result.lo directly instead of unwrapping the IntervalResult envelope (result.value.lo). The piecewise() function correctly returns IntervalResult objects.

0.51.1 2026-02-15​

Features​

  • #172 Degrees-Minutes-Seconds (DMS) notation: Parse and serialize geographic angle notation such as 9°30'15". The LaTeX parser now recognizes arc-minute (', \prime) and arc-second (", \doubleprime) symbols when they follow a degree symbol, producing Add(Quantity(…, deg), Quantity(…, arcmin), …) expressions that evaluate and simplify through the existing unit system. Negative angles (e.g. -45°30') are fully supported for latitude/longitude coordinates.
  • dmsFormat serialization option: Set dmsFormat: true in SerializeLatexOptions to serialize angle quantities as DMS notation (e.g. Quantity(9.5, deg) → 9°30').
  • angleNormalization serialization option: Normalize angles during serialization with '0...360' (useful for bearings) or '-180...180' (useful for longitude). Default is 'none'.
  • realOnly compilation option: Pass { realOnly: true } to compile() to automatically convert complex { re, im } results to real numbers — returns re when im === 0, NaN otherwise. Useful for plotting and other contexts that only need real-valued output.
  • Sinc function: Unnormalized cardinal sine sinc(x) = sin(x)/x with sinc(0) = 1. Includes LaTeX parsing via \operatorname{sinc}, JavaScript and interval-arithmetic compilation targets.
  • Fresnel integrals (FresnelS, FresnelC): Numeric evaluation using Cephes rational Chebyshev approximation, LaTeX parsing via \operatorname{FresnelS} / \operatorname{FresnelC}, JavaScript and interval-arithmetic compilation targets.
  • Heaviside step function: H(x) = 0 for x < 0, 1/2 for x = 0, 1 for x > 0. LaTeX parsing via \operatorname{Heaviside}, JavaScript and interval-arithmetic compilation with singularity detection at zero.

LaTeX Syntax​

  • Which compilation: \begin{cases} expressions now compile to JavaScript and interval-js targets as chained ternary operators with NaN fallback when no condition matches.
  • Sum/Product compilation: \sum_{k=a}^{b} and \prod_{k=a}^{b} expressions with numeric bounds now compile to JavaScript loops with accumulator variables, including complex number support.
  • Loop compilation: Loop, Break, Continue, and Return operators compile to JavaScript for loops wrapped in IIFEs with standard control flow keywords.
  • Inline If syntax: Parse \text{if } C \text{ then } A \text{ else } B (or \operatorname{if}) to ["If", C, A, B] expressions.
  • where syntax: Parse E \text{ where } x \coloneq V to Block expressions with implicit variable declarations.
  • Semicolon block syntax: Semicolons (;, \;) act as statement separators, building Block expressions with auto-declared variables when assignments are present.
  • for loop syntax: Parse \text{for } i \text{ from } a \text{ to } b \text{ do } body to ["Loop", body, ["Element", "i", ["Range", a, b]]].

Resolved Issues​

  • Interval-JS compilation for Gamma functions: Added missing gamma and gammaln exports and implementations in the interval-arithmetic library.
  • Interval-JS graceful fallback: The interval-js target no longer throws when encountering unsupported functions. Unsupported operators now produce { success: false } at compile time, and runtime errors return { kind: "entire" } instead of propagating.
  • CompilationResult.run type signature: The TypeScript type for run now correctly reflects the actual calling convention ((...args: unknown[])) instead of the previous misleading (...args: (number | {re, im})[]).
  • Loop compilation for interval-js target: Loop counter now uses raw numbers (not _IA.point()) for the for statement, with loop index references properly wrapped in the body. Conditions in if/break/ continue statements inside loops use scalar comparisons instead of interval comparison functions.

Other Changes​

  • Updated color palettes
  • Deduplicated runtime helper object (SYS_HELPERS) shared between ComputeEngineFunction and ComputeEngineFunctionLiteral in compilation target
  • Centralized sinc implementation in numerics/special-functions.ts (shared by library evaluation and JS compilation runtime)
  • Removed dead args === null checks in compilation base class

0.51.0 2026-02-14​

Colors​

  • New colors library: Four MathJSON operators for color manipulation and color space conversion, available as the "colors" library category.
  • Color: Parse a color string (hex 3/6/8-digit, rgb(), hsl(), named CSS color, transparent) into a canonical sRGB Tuple with components normalized to 0-1. Alpha is included as a fourth component when not equal to 1.
  • Colormap: Sample named visualization palettes. Three variants: no second argument returns the full palette as a List; integer n >= 2 resamples to n evenly spaced colors; real t in [0, 1] interpolates at position t using OKLCh color space with shorter-arc hue interpolation. Includes 8 sequential palettes (viridis, inferno, magma, plasma, cividis, turbo, rocket, mako), 6 categorical palettes (graph6, spectrum6, spectrum12, tableau10, tycho11, kelly22), and 12 diverging palettes (roma, vik, broc, rdbu, coolwarm, ocean-balance, plus reversed variants).
  • ColorToColorspace: Convert an sRGB color (string or Tuple) to components in "rgb", "hsl", "oklch", or "oklab" (alias "lab"). Preserves alpha when present.
  • ColorFromColorspace: Convert color space components back to a canonical sRGB Tuple. Accepts the same color space names as ColorToColorspace.
  • ColorToString: Convert a color (string or sRGB Tuple) to a formatted string. Supports optional format argument: "hex" (default), "rgb", "hsl", or "oklch" for CSS-style output. Alpha is included when not equal to 1.
  • ColorMix: Blend two colors in OKLCh space with an optional ratio (default 0.5). Accepts color strings or sRGB Tuple values. Interpolates lightness and chroma linearly, hue with shorter-arc interpolation.
  • ColorContrast: Compute the APCA contrast ratio between a background and foreground color. Returns a positive value for dark-on-light and negative for light-on-dark.
  • ContrastingColor: Choose the foreground color with better APCA contrast against a background. With one argument, picks between white and black. With three arguments, picks the better of two foreground candidates.
  • LaTeX color support: \textcolor{color}{body}, \colorbox{color}{body}, and \boxed{body} now roundtrip through Annotated expressions. Parsing and serialization are handled in the core Annotated infrastructure.
  • LaTeX font annotations: \textbf, \textit, \texttt, \textsf, \textup now serialize correctly from Annotated expressions via fontWeight, fontStyle, and fontFamily dict keys.
  • JavaScript compilation: All color operators (Color, ColorToString, ColorMix, ColorContrast, ContrastingColor, ColorToColorspace, ColorFromColorspace, Colormap) now compile to JavaScript.
  • oklab() CSS parsing: parseColor() now accepts oklab(L a b) and oklab(L a b / alpha) syntax, matching the existing oklch() support.
  • GPU compilation: ColorMix, ColorContrast, ContrastingColor, ColorToColorspace, and ColorFromColorspace now compile to GLSL and WGSL. Preamble functions provide sRGB ↔ OKLab ↔ OKLCh conversion, color mixing with shorter-arc hue interpolation, and APCA contrast on the GPU.
  • Added rgbToHsl() conversion function. Exported hslToRgb() (previously private).

Resolved Issues​

  • (#290) Derivatives of user-defined functions: \frac{d}{dx} f and f'(x) now correctly evaluate when f is a user-defined function (e.g., f(x) := 2x). Previously \frac{d}{dx} f returned 0 and f'(x) returned a symbolic Apply(Derivative(...)).
  • Cleaner D canonical form: f'(x) now canonicalizes to ["D", ["f", "x"], "x"] instead of the verbose ["D", ["Function", ["Block", ["f", "x"]], "x"], "x"]. Function calls are no longer redundantly wrapped in Function(Block(...)). Similarly, \frac{d}{dx} f where f is a known function symbol canonicalizes to ["D", ["f", "x"], "x"] by applying the function to the differentiation variable.

Free Functions​

  • Free functions (simplify, evaluate, N, expand, expandAll, factor, solve, compile) now accept ExpressionInput in addition to LatexString and Expression. This means you can pass numbers, MathJSON objects, or tuple arrays directly — e.g., evaluate(["Add", 1, 2]) or simplify(["Power", "x", 2]).
  • Added declare() free function to declare symbols without instantiating a ComputeEngine explicitly — e.g., declare('x', 'integer') or declare({ x: 'integer', y: 'real' }).

Units and Quantities​

  • New units library: A comprehensive unit system for physical quantities, available as the "units" library category. Supports SI base units, 18 named derived units, SI prefixes (quetta through quecto), and common non-SI units (imperial, angles, logarithmic).
  • Quantity expression: Pairs a numeric value with a unit: ["Quantity", 9.8, ["Divide", "m", ["Power", "s", 2]]]. Accessors QuantityMagnitude and QuantityUnit extract the parts.
  • Quantity arithmetic: Add, Subtract, Multiply, Divide, and Power are unit-aware. Addition and subtraction automatically convert compatible units and express the result in the unit with the largest scale factor (e.g., 12 cm + 1 m evaluates to 1.12 m). Incompatible dimensions remain unevaluated.
  • Unit conversion: UnitConvert converts between compatible units, including compound units like m/s to km/h. Supports affine temperature conversions (degC, degF, K). Returns an error for incompatible units. UnitSimplify reduces compound units to named derived units when possible (e.g., kg*m/s^2 to N).
  • Dimensional analysis: IsCompatibleUnit tests dimensional compatibility. UnitDimension returns the 7-element SI dimension vector. Both support compound unit expressions.
  • LaTeX parsing: \mathrm{...} and \text{...} containing recognized units produce Quantity expressions when juxtaposed with numbers. Compound units with /, ^, and \cdot are supported (e.g., 5\,\mathrm{m/s^{2}}).
  • siunitx commands: \qty{value}{unit}, \SI{value}{unit}, \unit{unit}, and \si{unit} are parsed.
  • LaTeX serialization: Quantity expressions serialize to value\,\mathrm{unit} notation.
  • DSL string sugar: Compound units can be specified as strings in MathJSON: ["Quantity", 9.8, "m/s^2"] is canonicalized to the structured form. Parentheses are supported for grouping: "kg/(m*s^2)".
  • Temperature units: degC and degF with affine offset conversions.
  • Angular unit unification: Trigonometric functions (Sin, Cos, Tan, etc.) accept Quantity arguments with angular units (deg, rad, grad, arcmin, arcsec) and convert to radians automatically.
  • Physics constants: 11 CODATA 2018 constants defined as Quantity expressions: SpeedOfLight, PlanckConstant, Mu0, StandardGravity, ElementaryCharge, BoltzmannConstant, AvogadroConstant, VacuumPermittivity, GravitationalConstant, StefanBoltzmannConstant, and GasConstant.

Compilation​

  • Tuple and Matrix compilation: Tuple and Matrix expressions can now be compiled across all targets. compile('(\\sin(t), \\cos(t))') produces [Math.sin(t), Math.cos(t)] in JavaScript, vec2(sin(t), cos(t)) in GLSL, vec2f(sin(t), cos(t)) in WGSL, and (np.sin(t), np.cos(t)) in Python.
  • GPU-native matrix types: Square matrices (2x2, 3x3, 4x4) compile to native GPU matrix constructors (mat2/mat3/mat4 in GLSL, mat2x2f/mat3x3f/mat4x4f in WGSL) with proper column-major transposition. Column vectors are flattened to vecN/vecNf instead of nested single-element arrays.
  • Complex number compilation: The JavaScript compilation target now supports complex-valued expressions. The compiler performs static type analysis at compile time to determine whether each subexpression is real or complex, and emits the appropriate code path. Simple arithmetic (Add, Subtract, Multiply, Divide, Negate) uses inline {re, im} field math to avoid allocation. Transcendental functions (Sin, Cos, Exp, Ln, Sqrt, Power, and others) delegate to runtime helpers backed by the complex-esm library. Mixed real/complex operands are promoted inline. ImaginaryUnit compiles to {re: 0, im: 1}. Symbols with unknown type are assumed real. Complex-aware Sum and Product loops emit {re, im} accumulators when the loop body is complex-valued. Reciprocal trig/hyperbolic functions (Cot, Sec, Csc, Coth, Sech, Csch) and their inverses dispatch to complex helpers when operands are complex.
  • Python complex compilation: The Python target now supports complex-valued expressions using Python's native complex() constructor and the cmath module for transcendental functions. Real-valued expressions continue to use NumPy.
  • Gamma function compilation: Gamma and GammaLn can now be compiled to interval-js, glsl, wgsl, and interval-glsl targets. The interval targets include pole detection at non-positive integers and correct monotonicity handling around the minimum at x ≈ 1.46.
  • Special function compilation: 27 additional functions can now be compiled to JavaScript: Erf, Erfc, ErfInv, Beta, Digamma, Trigamma, PolyGamma, Zeta, LambertW, BesselJ, BesselY, BesselI, BesselK, AiryAi, AiryBi, Factorial, Factorial2, Exp2, Log2, Log10, Lg, Arctan2, Hypot, Degrees, Haversine, InverseHaversine, Binomial, and Fibonacci.
  • GPU special functions: Erf, Erfc, ErfInv, Beta, Factorial, Arctan2, Hypot, Haversine, InverseHaversine, Log10, and Lg can now be compiled to GLSL and WGSL targets. Erf/ErfInv use Abramowitz & Stegun polynomial approximations; Beta and Factorial leverage the existing GPU Gamma preamble.

Simplification​

  • Factorial quotient simplification: n!/k! is now simplified to a partial product for both concrete integers (e.g., 10!/7! → 720) and symbolic expressions with small constant difference (e.g., n!/(n-2)! → n(n-1)).
  • Binomial detection: Expressions of the form n!/(k!(n-k)!) are automatically recognized and simplified to Binomial(n, k).
  • Binomial identity simplification: C(n,0) → 1, C(n,1) → n, C(n,n) → 1, C(n,n-1) → n.
  • Factorial sum factoring: Sums and differences of factorials with related arguments are factored out, e.g., n! - (n-1)! → (n-1)! * (n-1), (n+1)! + n! → n! * (n+2).

0.50.2 2026-02-12​

Numerics​

  • Centralized overflow protection: Improved robustness of Rational and ExactNumericValue arithmetic by centralizing overflow checks and automatic promotion to BigInt.
  • [#287](https://github.com/cortex-js/compute-engine/issues/287) Improved precision for large integer products: Multiplications and additions of large integers that would previously lose precision (exceeding Number.MAX_SAFE_INTEGER) are now automatically promoted to BigInt to maintain exact results.

Symbols​

  • #288 Allow reassigning a symbol from operator to value: ce.assign() no longer throws when assigning a plain value to a symbol that was previously declared as a function. Existing expressions using the symbol as a function head will produce a type error at evaluation time if the new value is not callable.

Evaluation​

  • Fixed scope leaks: Ensured that evaluation contexts are correctly popped even when an error or timeout occurs in BoxedFunction.evaluate(), findUnivariateRoots(), and rule-boxing operations.
  • Improved numerical evaluation performance: Sum, Product, Divide, and statistical operators (Mean, Variance, etc.) now correctly propagate the numericApproximation option, significantly speeding up large numerical calculations by avoiding expensive exact arithmetic.

0.50.1 2026-02-11​

Compilation​

  • CompilationResult.preamble for shader targets: compile() with interval-wgsl and interval-glsl targets now returns a preamble field containing the interval arithmetic library (struct definitions, helper functions). Previously, the compiled code referenced functions like ia_div and ia_sin that were not included in the output. Use preamble + code for a self-contained shader, or call compileShaderFunction() on the target directly.

0.50.0 2026-02-11​

Breaking API Changes​

This release includes several breaking changes to the public API.

The most significant is the restructuring of the Expression type hierarchy and the introduction of type-guarded role interfaces, which improves type safety and API ergonomics but requires updates to code that accessed role-specific properties directly on expression instances.

See MIGRATION_GUIDE_0.50.0.md for details.

Naming Alignment: Expression, MathJsonExpression, and ExpressionInput​

  • The compute-engine runtime type is now Expression (preferred name). BoxedExpression is retained as a deprecated alias for migration.
  • The MathJSON type is now MathJsonExpression (the old MathJSON Expression name has been removed from the math-json entrypoint).
  • SemiBoxedExpression is now ExpressionInput (with a deprecated alias for migration).

Role-Specific Properties Moved to Type-Guarded Interfaces​

Properties that were previously on all Expression instances (returning undefined when not applicable) have been moved to role interfaces. They are now only accessible after narrowing with a type guard.

Removed from ExpressionAccess via
.symbolisSymbol(expr) or isSymbol(expr, 'Pi') then expr.symbol
.stringisString(expr) then expr.string
.ops, .nops, .op1/.op2/.op3isFunction(expr) or isFunction(expr, 'Add') then expr.ops etc.
.numericValue, .isNumberLiteralisNumber(expr) then expr.numericValue
.tensorisTensor(expr) then expr.tensor
// Before
if (expr.symbol !== null) console.log(expr.symbol);

// After
import { isSymbol, sym } from '@cortex-js/compute-engine';

if (isSymbol(expr)) console.log(expr.symbol);
// isSymbol() accepts an optional symbol name:
if (isSymbol(expr, 'Pi')) { /* expr is the Pi symbol */ }
// or use the convenience helper:
if (sym(expr) === 'Pi') { /* ... */ }

// isFunction() accepts an optional operator name:
if (isFunction(expr, 'Add')) {
// expr is narrowed to a function AND has operator 'Add'
console.log(expr.ops);
}

Properties that remain on Expression: .operator, .re/.im, .shape, all arithmetic methods (.add(), .mul(), etc.), and all numeric predicates (.isPositive, .isInteger, etc.).

Expression Creation: form Replaces canonical/structural​

The canonical (boolean or array) and structural (boolean) options on ce.box(), ce.function(), and ce.parse() have been unified into a single form option.

ce.box(['Add', 1, 'x'], { form: 'canonical' }); // default
ce.box(['Add', 1, 'x'], { form: 'raw' }); // no canonicalization, no binding
ce.function('Add', [1, 'x'], { form: 'structural' }); // bound, not fully canonical
ce.box(['Add', 1, 'x'], { form: ['Number', 'Order'] }); // selective passes

New Free Functions​

Top-level free functions are now available for common operations and use a shared ComputeEngine instance created on first call.

FunctionPurpose
getDefaultEngine()Return the shared default ComputeEngine instance.
parse(latex)Parse a LaTeX string into an Expression.
simplify(exprOrLatex)Simplify an expression or LaTeX input.
evaluate(exprOrLatex)Evaluate an expression or LaTeX input symbolically.
N(exprOrLatex)Numerically evaluate an expression or LaTeX input.
assign(id, value) / assign(record)Assign one symbol value or many at once.
expand(exprOrLatex)Expand distributively at the top level (Expression | null).
expandAll(exprOrLatex)Expand distributively recursively (Expression | null).
solve(exprOrLatex, vars?)Solve equations/systems (returns solve result variants).
factor(exprOrLatex)Factor an expression.
compile(exprOrLatex, options?)Compile to a target language with CompilationResult.
import {
getDefaultEngine,
parse,
simplify,
evaluate,
N,
assign,
expand,
expandAll,
solve,
factor,
compile,
} from '@cortex-js/compute-engine';

assign('x', 3);

const expr = parse('x^2 - 5x + 6');
solve(expr, 'x'); // [2, 3]
factor('(2x)(4y)'); // 8xy
compile('x^2 + 1').run({ x: 3 }); // 10

Except for parse(), assign(), and getDefaultEngine(), these free functions accept either a LaTeX string or an existing Expression.

Free Function Notes​

  • compile() is now a top-level entry point returning CompilationResult. Custom compilation targets are managed with ce.registerCompilationTarget() and ce.unregisterCompilationTarget().
  • expand() and expandAll() return null when an expression is not expandable.
  • solve() is available as a top-level wrapper over equation/system solving.
  • factor() is the top-level factoring entry point. Specialized helpers such as factorPolynomial() and factorQuadratic() remain expression-only APIs.

trigSimplify() Method Removed​

Use simplify({ strategy: 'fu' }) instead, which is equivalent.

// Before
const result = expr.trigSimplify();

// After
const result = expr.simplify({ strategy: 'fu' });

Library System​

The constructor now accepts a libraries option for controlling which libraries are loaded. Libraries declare their dependencies and are loaded in topological order.

// Load specific standard libraries
const ce = new ComputeEngine({
libraries: ['core', 'arithmetic', 'trigonometry'],
});

// Add a custom library
const ce = new ComputeEngine({
libraries: [
...ComputeEngine.getStandardLibrary(),
{ name: 'physics', requires: ['arithmetic'], definitions: { /* ... */ } },
],
});

User-Extensible Simplification Rules​

ce.simplificationRules is now a public getter/setter. Users can push additional rules or replace the entire rule set.

ce.simplificationRules.push({
match: ['Power', ['Sin', '_x'], 2],
replace: ['Subtract', 1, ['Power', ['Cos', '_x'], 2]],
});

Canonicalization​

  • Exact numeric folding during canonicalization: canonicalAdd and canonicalMultiply now fold exact numeric operands at canonicalization time, making behavior consistent with canonicalDivide which already folded coefficients. This means expressions are reduced earlier in the pipeline without waiting for a .simplify() call.

    What gets folded (exact values):

    • Integers: Add(2, x, 5) → Add(x, 7)
    • Rationals: Add(1/3, x, 2/3) → Add(x, 1)
    • Radicals: Add(√2, x, √2) → Add(x, 2√2)
    • Mixed exact: Multiply(2, x, 5) → Multiply(10, x)
    • Full reduction: Add(2, 3) → 5, Multiply(2, 3) → 6
    • Identity elimination: Multiply(1/2, x, 2) → x
    • Complex promotion: Add(1, Complex(0, -1)) → Complex(1, -1)

    What is NOT folded (non-exact values):

    • Machine floats: Add(1.5, x, 0.5) remains Add(x, 0.5, 1.5)
    • Infinity/NaN: Multiply(0, ∞) correctly returns NaN
    • Single numeric: Multiply(5, Pi) is unchanged (nothing to fold)

    The folding uses the existing ExactNumericValue arithmetic, which automatically handles radical grouping (√2 + √2 = 2√2) and rational simplification (1/3 + 2/3 = 1).

  • Exact numeric folding in canonicalPower: Integer powers of numeric literals are now folded during canonicalization when the exponent is an integer with |e| ≤ 64. For machine-number bases, the result must be a safe integer; for exact numeric values (rationals, radicals), NumericValue.pow() is used.

    • Power(2, 3) → 8
    • Power(3, 2) → 9
    • Power(1/2, 2) → 1/4
    • Power(-2, 3) → -8
    • Power(2, 100) remains unevaluated (exponent exceeds limit)
  • Complex promotion handles non-adjacent operands: canonicalAdd now combines a real float with imaginary terms even when they are not adjacent in the operand list. Previously, only a real immediately followed by an imaginary was promoted to a complex number.

Type Inference​

  • Type handlers for 25 operators: Added explicit type handlers to operators that were missing them, enabling the type system to return precise types instead of the broad signature return type.
    • Arithmetic: Factorial, Factorial2, Sign return finite_integer; Ceil and Floor return finite_integer for finite inputs, integer otherwise.
    • Trigonometry: Arctan uses numericTypeHandler (returns finite_real for real inputs, finite_number for complex).
    • Complex: Real, Imaginary, Argument return finite_real.
    • Number theory: Totient, Sigma0, Sigma1, Eulerian, Stirling, NPartition return finite_integer; SigmaMinus1 returns finite_rational.
    • Combinatorics: Choose, Fibonacci, Binomial, Multinomial, Subfactorial, BellNumber return finite_integer.
    • Truncate, GCD, LCM type handlers: Truncate returns finite_integer for finite inputs (matching Ceil/Floor); GCD and LCM always return finite_integer.

Solving​

  • And operator support for systems of equations: solve() now accepts And(Equal(...), Equal(...)) in addition to List(Equal(...), Equal(...)) for representing systems of equations. Both forms route through the same linear, polynomial, and inequality solvers.

  • Parametric solution type filtering: filterSolutionByTypes now uses === false instead of !== true for type predicate checks. This allows underdetermined (parametric) solutions to pass through when type predicates return undefined (unknown) rather than being incorrectly rejected.

  • Or operator support in solve(): Solving Or(Equal(x,1), Equal(x,2)) returns the union of solutions from each branch, with deduplication. Works for both univariate (returns array of values) and multivariate (returns array of records) cases.

  • Mixed equality + inequality systems: solve() now handles systems combining Equal and inequality operators (Less, LessEqual, Greater, GreaterEqual). Equalities are solved first, then solutions are filtered against the inequalities.

  • Parametric solutions omit free variables: Underdetermined linear systems no longer include free variables (self-referential entries) in the result record. Only dependent variables with non-trivial expressions are returned.

Special Functions​

  • Numeric evaluation for Digamma, Trigamma, PolyGamma, Beta, Zeta, LambertW: These six functions now evaluate numerically when .N() is called, at both machine precision and arbitrary precision (bignum). Returns unevaluated without numeric approximation.

    • Digamma/Trigamma: recurrence + asymptotic with Bernoulli numbers
    • PolyGamma: generalized recurrence for arbitrary order n
    • Beta: via gamma, with log-gamma fallback for large arguments
    • Zeta: Cohen-Villegas-Zagier acceleration, functional equation for \operatorname{Re}(s)<0
    • LambertW: Halley's method with branch-point handling
  • Arbitrary-precision (bignum) variants for special functions: When ce.precision > 15, Digamma, Trigamma, PolyGamma, Beta, Zeta, and LambertW now compute results to the requested precision using bignum arithmetic. The asymptotic shift threshold scales with precision to maintain accuracy (e.g., ce.precision = 50 produces 50-digit results for Digamma and Zeta).

  • Numeric evaluation for Bessel functions (BesselJ, BesselY, BesselI, BesselK): Integer-order Bessel functions now evaluate numerically.

    • BesselJ: power series for small |x|, Miller's backward recurrence for intermediate values, Hankel asymptotic expansion for large |x|
    • BesselY: DLMF 10.8.3 series for Y_0/Y_1, forward recurrence for higher orders, shared Hankel asymptotic with BesselJ
    • BesselI: power series + asymptotic expansion
    • BesselK: series for K_0, Wronskian-derived K_1, forward recurrence for higher orders, asymptotic for large x
  • Numeric evaluation for Airy functions (AiryAi, AiryBi): Power series using Maclaurin coefficients for |x| \leq 5, asymptotic expansions (exponential decay for Ai, exponential growth for Bi at positive x, oscillatory for negative x) for large arguments.

Linear Algebra​

(Fix #285)

  • \begin{vmatrix} now parses to Determinant: The vmatrix LaTeX environment now produces ["Determinant", ["Matrix", ...]] instead of ["Matrix", ..., "'||'"]. Serialization round-trips correctly back to \begin{vmatrix}...\end{vmatrix} when the argument is a Matrix expression, and uses \det\left(...\right) for symbol arguments.

  • \begin{Vmatrix} now parses to Norm: The Vmatrix LaTeX environment now produces ["Norm", ["Matrix", ...]] instead of ["Matrix", ..., "'‖‖'"]. Serialization round-trips to \begin{Vmatrix}...\end{Vmatrix} when the argument is a Matrix, and uses \left\Vert...\right\Vert for symbol arguments.

  • A^{-1} produces Inverse for matrix-typed symbols and matrix expressions: When a symbol is declared with type matrix, parsing A^{-1} now returns ["Inverse", "A"] instead of ["Power", "A", -1]. This also works for inline matrix expressions, e.g. \begin{pmatrix}...\end{pmatrix}^{-1}. Undeclared symbols still fall through to the default Power/Divide handling, and function symbols still produce InverseFunction (e.g., \sin^{-1} → Arcsin).

  • Inverse serializes as ^{-1}: ["Inverse", "A"] now serializes to A^{-1} instead of \mathrm{Inverse}(A).

  • Power(A, -1) canonicalizes to Inverse(A) for matrices: When A has a matrix type, ce.box(["Power", "A", -1]) now canonicalizes to ["Inverse", "A"] instead of ["Divide", 1, "A"].

  • \det(A) and \tr(A) now parse correctly: Fixed Determinant and Trace LaTeX dictionary entries to use latexTrigger (\det, \tr) instead of symbolTrigger, which only matches plain identifiers. Both functions also accept plain text forms (det(A), tr(A)).

  • \det A and \tr A work without parentheses: Determinant and Trace now accept implicit arguments, so \det A parses as ["Determinant", "A"] (like \cos x parses as ["Cos", "x"]). Implicit arguments bind at multiplication precedence, so \det 2A + 1 parses as det(2A) + 1.

  • Determinant serialization uses \det A for simple arguments: Symbol arguments serialize as \det A instead of \det\left(A\right). Matrix arguments still serialize as \begin{vmatrix}...\end{vmatrix}.

  • Added standard LaTeX operators \ker, \dim, \deg, \hom: These commands are now in the MathJSON LaTeX dictionary as function entries with implicit arguments, so forms like \ker V, \dim V, \deg p, and \hom(V, W) parse correctly and serialize back to the corresponding standard operator notation. The corresponding function symbols (Kernel, Dimension, Degree, Hom) are also registered in the linear algebra library.

  • Implemented runtime evaluation for Kernel, Dimension, Degree, and Hom:

    • Kernel now computes a numeric null-space basis (for scalar/vector/matrix real inputs) and returns it as a list of basis vectors.
    • Dimension now evaluates finite dimensions for concrete tensors and collections, and computes dim(Hom(V, W)) = dim(V) * dim(W) when both dimensions are inferable.
    • Degree now evaluates polynomial degree for polynomial-form expressions while keeping ambiguous bare symbols (for example Degree(p)) unevaluated.
    • Hom now evaluates/simplifies its arguments while preserving the symbolic Hom(...) form.

LaTeX Parsing​

  • arguments: 'implicit' option for function dictionary entries: Function entries in the LaTeX dictionary can now set arguments: 'implicit' to accept bare arguments without parentheses (e.g., \det A), matching the behavior of trig functions. The default remains 'enclosure' (parentheses required). Applied to \det, \tr, \Re, \Im, \arg, \max, \min, \sup, \inf.

Simplification​

  • Infinity handling for 24+ functions: arctan(∞), arccot(±∞), tanh/coth/sech/csch(±∞), arsinh(-∞), arcosh(-∞), arccoth(±∞), arcsch(±∞), π^∞, ∞^n, (-∞)^{-n}, log_∞(x), log_{0.5}(∞), √∞, ∛∞ now all return correct limits.

  • Root edge cases: Root(x, 0) → NaN, Root(0, n), Root(1, n), Root(+∞, n), and Sqrt(+∞) now handled correctly.

  • Division edge cases: a/a → 1 now works for compound expressions (e.g., (π+1)/(π+1)); 2/0 → ComplexInfinity and 1/(1/0) → 0 propagate correctly.

  • Logarithm edge cases: Fixed infinity detection in simplify-log.ts (was using sym() which fails on BoxedNumber infinity values); added log_∞(∞) → NaN, base-aware log_c(0), guards for log_1(x) and log_c(c^x) evaluation.

  • Absolute value of odd functions: |arcsin(x)|, |sinh(x)|, |arsinh(x)|, |artanh(x)| now simplify to f(|x|).

  • Even function with abs argument: cosh(|x+2|) → cosh(x+2).

  • Trig period shifts: cot(π+x) → cot(x), csc(π+x) → -csc(x).

  • Ln simplification in Add/Multiply operands: ln(x^3) − 3·ln(x) → 0 and ln(x^√2) → √2·ln(x) now work; cost function bypassed for log rules that are mathematically valid but structurally more expensive.

  • Preserved function identity: Removed unconditional expansions of sinh/cosh → exp, arsinh/arcosh/artanh → ln, and arcsin → arctan2 that prevented abs/odd-function rules from firing.

Compilation​

  • WGSL (WebGPU Shading Language) Compilation Target: New built-in WGSL target for compiling mathematical expressions to WebGPU shaders.

    // Via the registry
    const result = compile(expr, { to: 'wgsl' });

    WGSL-specific differences from GLSL:

    • inverseSqrt (camelCase) instead of inversesqrt
    • % operator for mod instead of mod() function
    • vec2f/vec3f/vec4f constructors instead of vec2/vec3/vec4
    • array<f32, n>() instead of float[n]()
    • fn name(x: f32) -> f32 instead of float name(float x)
    • @vertex/@fragment/@compute entry points with struct-based I/O
    • @group/@binding uniform declarations and @workgroup_size for compute
  • Interval WGSL Compilation Target: New interval-wgsl target for interval arithmetic in WebGPU shaders, mirroring the existing interval-glsl target. Since WGSL does not support function overloading, the library uses _v suffixes for internal vec2f-parameter implementations (e.g., ia_add_v), while the public API (ia_add, ia_sin, etc.) takes IntervalResult values.

Resolved Issues​

  • Sequence type inference now returns a proper tuple type: Multi-argument Sequence expressions previously returned 'any' as their inferred type, losing all type information. They now return a tuple<...> type with each element's individual type preserved (e.g., Sequence(1, "a") types as tuple<integer, string>), consistent with the Tuple operator.

  • Subscript parsing now checks for collection type: The LaTeX subscript (_) parser now checks whether the LHS is a collection (symbol declared as indexed_collection, or a list literal) and produces At() directly at parse time, consistent with bracket indexing (x[i]). Multi-index subscripts on collections (A_{k,j}) are now correctly unpacked into separate At arguments instead of being wrapped in a Tuple.

  • NumericValue(0).mul(Infinity) now returns NaN: All three NumericValue subclasses (MachineNumericValue, BigNumericValue, ExactNumericValue) had an early-return if (this.isZero) return this in mul(), which returned 0 without checking if the other operand was infinity. 0 × ±∞ is now correctly indeterminate (NaN), and ±∞ × 0 is handled symmetrically.

  • Power simplification (a^n)^m -> a^{nm} now correctly guarded: The rule was applied unconditionally, which is mathematically incorrect when the base can be negative and exponents are non-integer. The classic counterexample: ((-1)^2)^{1/2} = 1, but (-1)^{2·1/2} = -1. The rule is now only applied when: (1) the base is non-negative, (2) the outer exponent is an integer, or (3) the inner exponent is an odd integer. This fix applies to canonicalization (canonicalPower), the pow() helper, and simplification (simplifyPower). As a result, (x^2)^{1/2} now correctly simplifies to |x| instead of x.

  • Power distribution rules now guarded for non-integer exponents: Three additional power distribution rules in pow() were applied unconditionally, producing wrong results when the exponent is non-integer and operands are negative. (1) (a/b)^c -> a^c / b^c — e.g. ((-2)(-3))^{1/2} = sqrt(6) but distributing gives (-2)^{1/2} * (-3)^{1/2} = -sqrt(6). (2) (a*b)^c -> a^c * b^c — same class of bug. (3) (-x)^n used n % 2 === 0 to test parity, but for non-integer n (e.g. 0.5), 0.5 % 2 = 0.5 falls to the odd branch, giving (-x)^{0.5} -> -(x^{0.5}) which is wrong. All three rules, plus the corresponding canonicalPower() Divide rule, now require integer exponents (or non-negative operands) before distributing.

  • Sqrt/Root exponent rearrangement now guarded: Two more rules in pow() unconditionally rearranged exponents. (1) (√a)^b -> √(a^b) rearranges (a^{1/2})^b to (a^b)^{1/2}, which is wrong for negative a (e.g. (√(-4))^3 = -8i but √((-4)^3) = 8i). Now only applied when a >= 0. The even-integer branches ((√a)^2 -> a, (√a)^{2k} -> a^k) remain unconditional since integer outer exponents are always safe. (2) Root(a,b)^c -> a^{c/b} combined exponents unconditionally. Now guarded with a >= 0 or c is integer. Audit of simplify-power.ts confirmed all rules there are already properly guarded.

  • Relational operators now evaluate: Seven relational operators (TildeFullEqual, TildeEqual, Approx, ApproxEqual, ApproxNotEqual, Precedes, Succeeds) previously had canonical handlers but no evaluate handlers, so expressions like Approx(3.14, 3.14) returned unevaluated. The approximate-equality family (TildeFullEqual, TildeEqual, Approx, ApproxEqual) now checks whether |a - b| <= tolerance via ce.chop(), with support for multi-argument chains. Precedes and Succeeds evaluate as numeric < and > respectively. Negated variants (NotApprox, NotTildeFullEqual, etc.) work automatically through the Not operator.

  • BoxedNumber.operator now returns specific numeric types: The operator property on BoxedNumber instances previously returned the generic 'Number' for all numeric values. It now returns specific types that match the internal type system: 'Integer' for integers, 'Rational' for non-integer rationals, 'Real' for floating-point numbers, 'Complex' for complex numbers with non-zero imaginary part, and 'NaN', 'PositiveInfinity', 'NegativeInfinity' for special values. This improves API consistency with the type property and enables more precise pattern matching and type discrimination in user code. Breaking change: Code that explicitly checks for .operator === 'Number' will need to be updated to check for specific numeric types or use the isNumber() type guard instead.

  • Non-XIDC Unicode characters in symbol names now encoded correctly: When parsing LaTeX symbols containing non-identifier Unicode characters via \unicode{...}, \char, or ^^XX escapes (e.g., figure dash U+2012 in \operatorname{speed\unicode{"2012}of\unicode{"2012}sound}), the characters are now encoded as ____XXXXXX (4 underscores + 6 hex digits) in the symbol name. This encoding is valid per isValidSymbol() and round-trips correctly: the serializer decodes ____XXXXXX back to \unicode{"XXXX"} in LaTeX output. Previously, these characters passed through raw and caused symbol validation to fail.

  • Assign to compound symbol names no longer misinterpreted as sequence definitions (fixes #286): ce.box(["Assign", "t_half", 10]) previously failed because the Assign evaluate handler split any symbol containing _ and treated it as a subscripted sequence definition. User-provided compound symbols like t_half or half_life are now assigned correctly. Sequence definitions via parsed LaTeX (e.g., L_0 := 1) continue to work as before.

0.35.6 2026-02-07​

Resolved Issues​

  • Monte Carlo improper integrals: Fixed two bugs in monteCarloEstimate() that produced incorrect results (typically NaN or Infinity) for improper integrals. The change-of-variables estimator was inverted (f(x) / \mathrm{jacobian} instead of f(x) * \mathrm{jacobian}), and the finite-interval scale factor b - a was applied to transformed domains where it is infinite. Affects NIntegrate and compiled integrate for any integral with infinite bounds.

Compilation​

  • Truncate, Remainder, and Mod for JS/GLSL targets: Added Truncate (Math.trunc / trunc), Remainder, and Mod to the JavaScript and GLSL compilation targets, matching the Python target which already had them.

  • Interval trunc and remainder: Added trunc() and remainder() to the interval arithmetic library. trunc has proper discontinuity detection (behaves like floor for positive, ceil for negative, continuous at zero). remainder(a, b) = a - b * round(a/b) composes existing interval operations with discontinuity detection inherited from round. Added corresponding mappings to both interval JavaScript and interval GLSL targets.

  • Interval Lb, Log, and Root for GLSL: Added ia_log2, ia_log10, and Root to the interval GLSL target for consistency with the interval JavaScript target.

  • Reverse cross-reference test: Added a test that verifies all core CE math functions have compilation support in every target. Currently all 5 targets have full coverage of the 47 compilable math functions.

0.35.5 2026-02-06​

Resolved Issues​

  • Compilation Target Function Name Mismatches: Fixed several function keys in compilation targets that did not match their canonical library operator names, causing silent compilation failures and runtime errors ("Unexpected value"). Affected mappings: Ceiling → Ceil, Sgn → Sign, LogGamma → GammaLn, Arcsinh → Arsinh, Arccosh → Arcosh, Arctanh → Artanh, Re → Real, Im → Imaginary, Arg → Argument across all five compilation targets.

  • Missing Library Operator Definitions: Added library definitions for Exp2, Fract, Log10, Log2, Remainder, and Truncate which were referenced by compilation targets but had no corresponding library entries. Exp2 canonicalizes to Power(2, x), Log10/Log2 canonicalize to Log with the appropriate base, and Fract, Remainder, Truncate have direct numeric evaluation.

  • Derivative Rule for GammaLn: Fixed the derivative table entry that used the non-canonical name LogGamma instead of GammaLn, preventing the derivative d/dx GammaLn(x) = Digamma(x) from being computed.

0.35.4 2026-02-06​

Interval Arithmetic​

  • Discontinuity Continuity Direction: Singular interval results now include an optional continuity field ('left' or 'right') indicating from which side the function is continuous at a jump discontinuity. Floor, Round, Fract, and Mod report 'right' (right-continuous), Ceil reports 'left' (left-continuous). Pole-type singularities (e.g., tan, 1/x) leave the field undefined. This is reflected in both the JavaScript and GLSL interval arithmetic targets (new IA_SINGULAR_RIGHT and IA_SINGULAR_LEFT status constants in GLSL).

0.35.3 2026-02-06​

Compilation​

  • Expanded Function Support Across All Targets: Added comprehensive function mappings to all five compilation targets (JavaScript, GLSL, Interval GLSL, Interval JavaScript, Python): reciprocal trig (Cot, Csc, Sec), inverse reciprocal trig (Arccot, Arccsc, Arcsec), hyperbolic (Sinh, Cosh, Tanh), reciprocal hyperbolic (Coth, Csch, Sech), inverse hyperbolic (Arcosh, Arsinh, Artanh, Arcoth, Arcsch, Arsech), and elementary functions (Sgn, Lb, Log with base, Square, Root, Fract).

  • Interval Discontinuity Detection: Floor, Ceil, Round, Sign, Fract, and Mod now correctly report singularities when an interval spans a discontinuity point, in both the JavaScript and GLSL interval arithmetic targets. Previously these functions returned normal interval bounds even across jump discontinuities, which could cause incorrect connecting lines in plotted curves.

  • New Interval Functions: Added Round, Fract, and Mod to the interval arithmetic targets (both JS and GLSL) with proper discontinuity detection.

0.35.2 2026-02-05​

Resolved Issues​

  • Decimal Number Representation: Numbers written with a decimal point (e.g., 6.02e23) are now correctly treated as approximate decimal values (BigNumericValue) rather than exact integers. Previously, 6.02e23 was incorrectly converted to the exact bigint 602000000000000000000000, which implied false precision and caused memory inefficiency for very large exponents. Numbers without a decimal point (e.g., 602e21) continue to be treated as exact integers when possible. This change aligns with the documented behavior of the parseNumbers: 'auto' option.

  • Scientific Notation Serialization (#284): Fixed toLatex() with scientific and adaptiveScientific notation options to produce properly normalized output. Previously, numbers like 6.02e23 would serialize as 602\cdot10^{21} instead of the expected 6.02\cdot10^{23}. The output now depends only on the numeric value and formatting options, not on the internal representation.

  • Numeric Sum Precision: Fixed precision loss when summing large integers with rational values (e.g., 12345678^3 + 1/3). The ExactNumericValue.sum() method now uses bignumRe instead of re to preserve full precision when handling large integer values from BigNumericValue.

  • Broadcastable Functions with Union/Any Types (#235): Broadcastable (threadable) functions like Multiply and Add no longer reject arguments whose type is a union of numeric and collection types (e.g., number | list) or any. Previously, declaring a symbol as ce.declare('a', 'number | list') and using it in ce.box(['Multiply', 'a', 'b']) would produce an incompatible-type error.

  • Division Canonicalization Over-Simplification (#227): Fixed A/A being incorrectly simplified to 1 during canonicalization for constant expressions that evaluate to infinity or zero, such as tan(π/2)/tan(π/2). This now correctly evaluates to NaN (since ∞/∞ is indeterminate) instead of 1. Expressions with free variables (e.g., x/x, sin(x)/sin(x)) continue to simplify to 1 per standard algebraic convention. Also fixed deferred constant divisions like 0/(1-1) and (1-1)/(1-1) to properly evaluate to NaN instead of remaining as unevaluated expressions.

0.35.1 2026-02-03​

Resolved Issues​

  • Interval Arithmetic (JS/GLSL): Fixed interval evaluation of compound arguments (e.g. sin(2x), sin(x+x), sin(x^2), cos(2x)) by propagating interval results through trig, elementary, and comparison functions in interval-js, and by adding IntervalResult overloads to the GLSL interval library for interval-glsl.

0.35.0 2026-02-02​

Parsing​

  • Large Integer Precision: Fixed precision loss when parsing integers exceeding Number.MAX_SAFE_INTEGER with parseNumbers: 'rational'. Large integers and rational numerators now use BigInt arithmetic to preserve exact values. Fixes #283.

Compilation​

  • Interval Arithmetic Targets: Added two new compilation targets for reliable singularity detection:
    • interval-js - Compiles to JavaScript using interval arithmetic
    • interval-glsl - Compiles to GLSL for GPU-based interval evaluation

0.34.0 2026-02-01​

Parsing​

  • \mathopen and \mathclose: The LaTeX parser supports \mathopen and \mathclose delimiter prefixes for matchfix operators (explicit delimiter spacing control), e.g. \mathopen(a, b\mathclose) and \mathopen{(}a, b\mathclose{)}.

  • Interval Notation Parsing: Added support for parsing mathematical interval notation from LaTeX, including half-open intervals. Addresses #254.

    // Half-open intervals (American notation)
    ce.parse('[3, 4)').json; // → ["Interval", 3, ["Open", 4]]
    ce.parse('(3, 4]').json; // → ["Interval", ["Open", 3], 4]

    // Open intervals (ISO/European notation)
    ce.parse(']3, 4[').json; // → ["Interval", ["Open", 3], ["Open", 4]]

    // LaTeX bracket commands and sizing prefixes
    ce.parse('\\lbrack 3, 4\\rparen').json; // → ["Interval", 3, ["Open", 4]]
    ce.parse('\\left[ 3, 4 \\right)').json; // → ["Interval", 3, ["Open", 4]]
    ce.parse('\\bigl( 3, 4 \\bigr]').json; // → ["Interval", ["Open", 3], 4]

    Contextual Parsing: Lists and tuples are automatically converted to intervals when used in set contexts (Element, Union, Intersection, etc.):

    ce.parse('x \\in [0, 1]').json;
    // → ["Element", "x", ["Interval", 0, 1]]

    ce.parse('[0, 1] \\cup [2, 3]').json;
    // → ["Union", ["Interval", 0, 1], ["Interval", 2, 3]]

    // Standalone notation remains backward compatible
    ce.parse('[0, 1]').json; // → ["List", 0, 1]
    ce.parse('(0, 1)').json; // → ["Tuple", 0, 1]

Compilation​

  • Custom Operator Compilation: The compile() method now supports overriding operators to use function calls instead of native operators. This enables compilation of vector/matrix operations and custom domain-specific languages. Addresses #240.

    // Override operators for vector operations
    const expr = ce.parse('v + w');
    const compiled = expr.compile({
    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 result = compiled({ v: [1, 2, 3], w: [4, 5, 6] });
    // → [5, 7, 9]

    Highlights:

    • Map operators via an object or a function
    • Function-name operators compile to calls; symbol operators compile to infix
    • Supports scalar/collection arguments and partial overrides
  • Exported Compilation Interfaces: Advanced users can now create custom compilation targets by using the exported CompileTarget interface, BaseCompiler class, and JavaScriptTarget class.

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

    // Create a custom compilation target
    const customTarget = {
    language: 'my-dsl',
    operators: (op) => ({ Add: ['ADD', 11], Multiply: ['MUL', 12] }[op]),
    functions: (id) => id.toUpperCase(),
    var: (id) => `VAR("${id}")`,
    string: (s) => `"${s}"`,
    number: (n) => n.toString(),
    ws: () => ' ',
    preamble: '',
    indent: 0,
    };

    const expr = ce.parse('x + y * 2');
    const code = BaseCompiler.compile(expr, customTarget);
    // → "ADD(VAR("x"), MUL(VAR("y"), 2))"

    Exported building blocks include CompileTarget, LanguageTarget, CompilationOptions, CompiledExecutable, BaseCompiler, JavaScriptTarget, and GLSLTarget (plus helper types like CompiledOperators and CompiledFunctions).

  • Compilation Plugin Architecture: The Compute Engine now supports registering custom compilation targets, allowing you to compile mathematical expressions to any target language beyond the built-in JavaScript and GLSL targets.

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

    const ce = new ComputeEngine();

    // Define a custom Python target
    class PythonTarget {
    // ... implementation (see documentation)
    }

    // Register the custom target
    ce.registerCompilationTarget('python', new PythonTarget());

    // Compile to Python
    const expr = ce.parse('\\sin(x) + \\cos(y)');
    const pythonCode = expr.compile({ to: 'python' });
    console.log(pythonCode.toString());
    // → math.sin(x) + math.cos(y)

    // Switch between targets
    const jsFunc = expr.compile({ to: 'javascript' });
    const glslCode = expr.compile({ to: 'glsl' });

    Notes:

    • Built-in targets: javascript (executable) and glsl (shader code)
    • Add targets via ce.registerCompilationTarget(name, target)
    • Switch targets with compile({ to: ... }) (or override once with target)
  • Python/NumPy Compilation Target: Added a complete Python/NumPy compilation target for scientific computing workflows. The PythonTarget class compiles mathematical expressions to NumPy-compatible Python code.

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

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

    // Register the target
    ce.registerCompilationTarget('python', python);

    // Compile expressions to Python
    const expr = ce.parse('\\sin(x) + \\cos(y)');
    const code = expr.compile({ to: 'python' });
    console.log(code.toString());
    // → import numpy as np
    //
    // np.sin(x) + np.cos(y)

    // Generate complete Python functions
    const func = python.compileFunction(
    ce.parse('\\sqrt{x^2 + y^2}'),
    'magnitude',
    ['x', 'y'],
    'Calculate vector magnitude'
    );
    // Generates:
    // import numpy as np
    //
    // def magnitude(x, y):
    // """Calculate vector magnitude"""
    // return np.sqrt(x ** 2 + y ** 2)

    Highlights:

    • NumPy-compatible output (including arrays)
    • Function mapping for common math + linear algebra
    • Helpers for full functions, lambdas, and vectorized code

    See the Python/NumPy Target Guide for complete documentation and examples.

  • GLSL Compilation Target: New built-in GLSL (OpenGL Shading Language) target for compiling mathematical expressions to WebGL shaders.

    const expr = ce.parse('x^2 + y^2');
    const glslCode = expr.compile({ to: 'glsl' });
    console.log(glslCode.toString());
    // → pow(x, 2.0) + pow(y, 2.0)

    // Generate complete GLSL functions
    import { GLSLTarget } from '@cortex-js/compute-engine';
    const glsl = new GLSLTarget();

    const distExpr = ce.parse('\\sqrt{x^2 + y^2 + z^2}');
    const func = glsl.compileFunction(distExpr, 'distance3D', 'float', [
    ['x', 'float'],
    ['y', 'float'],
    ['z', 'float'],
    ]);
    console.log(func);
    // → float distance3D(float x, float y, float z) {
    // return sqrt(pow(x, 2.0) + pow(y, 2.0) + pow(z, 2.0));
    // }

    // Generate complete shaders
    const shader = glsl.compileShader({
    type: 'fragment',
    version: '300 es',
    outputs: [{ name: 'fragColor', type: 'vec4' }],
    body: [
    {
    variable: 'fragColor',
    expression: ce.box(['List', 1, 0, 0, 1]),
    },
    ],
    });

    Highlights:

    • Native vector/matrix operators and constructors
    • Float literal formatting (2.0)
    • Helpers for functions and complete shaders

Algebra​

  • Polynomial Factoring: The Factor function now supports comprehensive polynomial factoring including perfect square trinomials, difference of squares, and quadratic factoring with rational roots. Addresses #180 and #33.

    // Perfect square trinomials
    ce.parse('x^2 + 2x + 1').factor().latex;
    // → "(x+1)^2"

    ce.parse('4x^2 + 12x + 9').factor().latex;
    // → "(2x+3)^2"

    // Difference of squares
    ce.parse('x^2 - 4').factor().latex;
    // → "(x-2)(x+2)"

    // Quadratic with rational roots
    ce.box(['Factor', ['Add', ['Power', 'x', 2], ['Multiply', 5, 'x'], 6], 'x'])
    .evaluate().latex;
    // → "(x+2)(x+3)"

    Automatic Factoring in sqrt Simplification: Square roots now automatically factor their arguments before applying simplification rules, enabling expressions like √(x²+2x+1) to simplify to |x+1|.

    // Issue #180 - Now works!
    ce.parse('\\sqrt{x^2 + 2x + 1}').simplify().latex;
    // → "\\vert x+1\\vert"

    ce.parse('\\sqrt{4x^2 + 12x + 9}').simplify().latex;
    // → "\\vert 2x+3\\vert"

    ce.parse('\\sqrt{a^2 + 2ab + b^2}').simplify().latex;
    // → "\\vert a+b\\vert"

    Includes perfect square trinomials, difference of squares, and quadratics with rational roots. Helper functions are exported for advanced usage (factorPerfectSquare, factorDifferenceOfSquares, factorQuadratic, factorPolynomial).

    MathJSON API:

    ["Factor", expr] // Auto-detect variable
    ["Factor", expr, variable] // Explicit variable specification

    The enhanced factoring system works seamlessly with existing polynomial functions like Expand, Together, Cancel, PolynomialGCD, and others.

Simplification​

  • Absolute Value Power Simplification: Fixed simplification of |x^n| expressions with even and rational exponents. Previously, expressions like |x²| and |x^{2/3}| were not simplified. Now they correctly simplify based on the parity of the exponent's numerator. Addresses #181.

    ce.parse('|x^2|').simplify().latex; // → "x^2" (even exponent)
    ce.parse('|x^3|').simplify().latex; // → "|x|^3" (odd exponent)
    ce.parse('|x^{2/3}|').simplify().latex; // → "x^{2/3}" (even numerator)
    ce.parse('|x^{3/2}|').simplify().latex; // → "|x|^{3/2}" (odd numerator)
  • Assumption-Based Simplification: Simplification rules use assumptions about symbol signs:

    ce.assume(ce.parse('x > 0'));
    ce.parse('\\sqrt{x^2}').simplify().latex; // → "x" (was "|x|")
    ce.parse('|x|').simplify().latex; // → "x" (was "|x|")

    ce.assume(ce.parse('y < 0'));
    ce.parse('\\sqrt{y^2}').simplify().latex; // → "-y"
    ce.parse('|y|').simplify().latex; // → "-y"
  • Nested Root Simplification: Nested roots simplify to a single root:

    ce.box(['Sqrt', ['Sqrt', 'x']]).simplify() // → root(4)(x)
    ce.box(['Root', ['Root', 'x', 3], 2]).simplify() // → root(6)(x)
    ce.box(['Sqrt', ['Root', 'x', 3]]).simplify() // → root(6)(x)

    Applies to all combinations: sqrt(sqrt(x)), root(sqrt(x), n), sqrt(root(x, n)), and root(root(x, m), n).

  • Extended Coefficient Factoring in Power Combination: The power combination rule now handles additional coefficient forms when combining same-base powers in products:

    • Multi-prime coefficients: 12·2ˣ·3ˣ → 2^(x+2)·3^(x+1) (since 12 = 2²·3). All primes in the factorization must have a matching base. Non-matching multi-prime coefficients like 6·2ˣ are left unchanged.
    • Negative coefficients: -4·2ˣ → -2^(x+2), -8·2ˣ → -2^(x+3). The absolute value is factored and the sign is preserved.
    • Rational-radical coefficients: √2·2ˣ → 2^(x+½), 2√2·2ˣ → 2^(x+3/2), (√2/2)·2ˣ → 2^(x-½). Decomposes (num/den)·√radical into prime contributions from all three components (radical primes get half-integer exponents, numerator primes get positive exponents, denominator primes get negative exponents).
    • Rational coefficients: 2ˣ/4 → 2^(x-2), 3ˣ/9 → 3^(x-2). Factors both numerator (positive exponents) and denominator (negative exponents).
  • Improved Cost Function for Negated Powers: Negate(Power(...)) now costs 3 + cost(exponent), consistent with the cost of Multiply(-1, Power(...)). This makes the cost model more accurate when comparing negated power forms.

Assumptions & Types​

  • Improved ask() Queries: ce.ask() now matches patterns with wildcards correctly, can answer common "bound" queries such as ask(["Greater", "x", "_k"]) and ask(["Greater", "_x", "_k"]), normalizes inequality patterns for matching (e.g. ask(["Greater", "_x", 0])), and falls back to verify() for closed predicates when the fact is known but not stored as an explicit assumption.

  • Tri-state verify(): Implemented ce.verify() as a truth query that returns true, false or undefined when a predicate cannot be determined from the current assumptions and declarations. And/Or/Not use 3-valued logic.

  • Element/NotElement Type Membership: Element(x, T) and NotElement(x, T) now support type-style RHS (e.g. real, finite_real, number, any) in addition to set collections (e.g. RealNumbers, Integers).

  • Value Resolution from Equality Assumptions: After ce.assume(['Equal', symbol, value]), the symbol now evaluates to the assumed value:

    ce.assume(ce.box(['Equal', 'one', 1]));
    ce.box('one').evaluate(); // → 1 (was: 'one')
    ce.box(['Equal', 'one', 1]).evaluate(); // → True (was: ['Equal', 'one', 1])
    ce.box(['Equal', 'one', 0]).evaluate(); // → False
    ce.box('one').type.matches('integer'); // → true

    This also fixes comparison evaluation: Equal(symbol, assumed_value) now correctly evaluates to True instead of staying symbolic.

  • Inequality Evaluation Using Assumptions: Inequality comparisons can use transitive bounds extracted from assumptions.

    ce.assume(ce.box(['Greater', 'x', 4]));
    ce.box(['Greater', 'x', 0]).evaluate(); // → True (x > 4 > 0)
    ce.box(['Less', 'x', 0]).evaluate(); // → False
    ce.box('x').isGreater(0); // → true
    ce.box('x').isPositive; // → true
  • Type Inference from Assumptions: Inequalities infer real; equalities infer from the value.

    ce.assume(ce.box(['Greater', 'x', 4]));
    ce.box('x').type.toString(); // → 'real' (was: 'unknown')

    ce.assume(ce.box(['Equal', 'one', 1]));
    ce.box('one').type.toString(); // → 'integer' (was: 'unknown')
  • Tautology and Contradiction Detection: ce.assume() returns 'tautology' for redundant assumptions and 'contradiction' for conflicts.

    ce.assume(ce.box(['Greater', 'x', 4]));

    // Redundant assumption (x > 4 implies x > 0)
    ce.assume(ce.box(['Greater', 'x', 0])); // → 'tautology' (was: 'ok')

    // Conflicting assumption (x > 4 contradicts x < 0)
    ce.assume(ce.box(['Less', 'x', 0])); // → 'contradiction'

    // Same assumption repeated
    ce.assume(ce.box(['Equal', 'one', 1]));
    ce.assume(ce.box(['Equal', 'one', 1])); // → 'tautology'

    // Conflicting equality
    ce.assume(ce.box(['Less', 'one', 0])); // → 'contradiction'

Solving​

  • Systems of Linear Equations: The solve() method now handles systems of linear equations parsed from LaTeX \begin{cases}...\end{cases} environments. Returns an object mapping variable names to their solutions.

    const e = ce.parse('\\begin{cases}x+y=70\\\\2x-4y=80\\end{cases}');
    const result = e.solve(['x', 'y']);
    console.log(result.x.json); // 60
    console.log(result.y.json); // 10

    // 3x3 systems work too
    const e2 = ce.parse('\\begin{cases}x+y+z=6\\\\2x+y-z=1\\\\x-y+2z=5\\end{cases}');
    const result2 = e2.solve(['x', 'y', 'z']);
    // → { x: 1, y: 2, z: 3 }

    Non-linear systems that don't match known patterns and inconsistent systems return null.

  • Non-linear Polynomial Systems: The solve() method now handles certain non-linear polynomial systems with 2 equations and 2 variables:

    • Product + sum pattern: Systems like xy = p, x + y = s are solved by recognizing that x and y are roots of the quadratic t² - st + p = 0.

    • Substitution method: When one equation is linear in one variable, it substitutes into the other equation and solves the resulting univariate equation.

    Returns an array of solution objects (multiple solutions possible):

    // Product + sum pattern
    const e = ce.parse('\\begin{cases}xy=6\\\\x+y=5\\end{cases}');
    const result = e.solve(['x', 'y']);
    // → [{ x: 2, y: 3 }, { x: 3, y: 2 }]

    // Substitution method
    const e2 = ce.parse('\\begin{cases}x+y=5\\\\x^2+y=7\\end{cases}');
    const result2 = e2.solve(['x', 'y']);
    // → [{ x: 2, y: 3 }, { x: -1, y: 6 }]

    Only real solutions are returned; complex solutions are filtered out.

  • Exact Rational Arithmetic in Linear Systems: The linear system solver now uses exact rational arithmetic throughout the Gaussian elimination process. Systems with fractional coefficients produce exact fractional results rather than floating-point approximations.

    const e = ce.parse('\\begin{cases}x+y=1\\\\x-y=1/2\\end{cases}');
    const result = e.solve(['x', 'y']);
    console.log(result.x.json); // ["Rational", 3, 4] (exact 3/4)
    console.log(result.y.json); // ["Rational", 1, 4] (exact 1/4)

    // Fractional coefficients
    const e2 = ce.parse('\\begin{cases}x/3+y/2=1\\\\x/4+y/5=1\\end{cases}');
    const result2 = e2.solve(['x', 'y']);
    // → { x: 36/7, y: -10/7 }
  • Linear Inequality Systems: The solve() method now handles systems of linear inequalities in 2 variables, returning the vertices of the feasible region (convex polygon). Supports all inequality operators: <, <=, >, >=.

    // Triangle: x >= 0, y >= 0, x + y <= 10
    const e = ce.parse('\\begin{cases}x\\geq 0\\\\y\\geq 0\\\\x+y\\leq 10\\end{cases}');
    const result = e.solve(['x', 'y']);
    // → [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 0, y: 10 }]

    // Square: 0 <= x <= 5, 0 <= y <= 5
    const square = ce.parse('\\begin{cases}x\\geq 0\\\\x\\leq 5\\\\y\\geq 0\\\\y\\leq 5\\end{cases}');
    square.solve(['x', 'y']);
    // → [{ x: 0, y: 0 }, { x: 5, y: 0 }, { x: 5, y: 5 }, { x: 0, y: 5 }]

    Vertices are returned in counterclockwise convex hull order. Returns null for infeasible systems or non-linear constraints.

  • Under-determined Systems (Parametric Solutions): The solve() method now returns parametric solutions for under-determined linear systems (fewer equations than variables) instead of returning null. Free variables appear as themselves in the solution, with other variables expressed in terms of them.

    // Single equation with two variables
    const e = ce.parse('\\begin{cases}x+y=5\\end{cases}');
    const result = e.solve(['x', 'y']);
    // → { x: -y + 5, y: y } (y is a free variable)

    // Two equations with three variables
    const e2 = ce.parse('\\begin{cases}x+y+z=6\\\\x-y=2\\end{cases}');
    const result2 = e2.solve(['x', 'y', 'z']);
    // → { x: -z/2 + 4, y: -z/2 + 2, z: z } (z is a free variable)

    Inconsistent systems still return null.

  • Extended Sqrt Equation Solving: The equation solver now handles sqrt equations of the form √(f(x)) = g(x) by squaring both sides and solving the resulting polynomial. Extraneous roots are automatically filtered.

    ce.parse('\\sqrt{x+1} = x').solve('x'); // → [1.618...] (golden ratio)
    ce.parse('\\sqrt{2x+3} = x - 1').solve('x'); // → [4.449...]
    ce.parse('\\sqrt{3x-2} = x').solve('x'); // → [1, 2]
    ce.parse('\\sqrt{x} = x').solve('x'); // → [0, 1]
  • Two Sqrt Equation Solving: The equation solver now handles equations with two sqrt terms of the form √(f(x)) + √(g(x)) = e using double squaring. Both addition and subtraction forms are supported, and extraneous roots are automatically filtered.

    ce.parse('\\sqrt{x+1} + \\sqrt{x+4} = 3').solve('x'); // → [0]
    ce.parse('\\sqrt{x} + \\sqrt{x+7} = 7').solve('x'); // → [9]
    ce.parse('\\sqrt{x+5} - \\sqrt{x-3} = 2').solve('x'); // → [4]
    ce.parse('\\sqrt{2x+1} + \\sqrt{x-1} = 4').solve('x'); // → [46 - 8√29] ≈ 2.919
  • Nested Sqrt Equation Solving: The equation solver now handles nested sqrt equations of the form √(x + √x) = a using substitution. These patterns have √x inside the argument of an outer sqrt. The solver uses u = √x substitution, solves the resulting quadratic, and filters negative u values.

    ce.parse('\\sqrt{x + 2\\sqrt{x}} = 3').solve('x'); // → [11 - 2√10] ≈ 4.675
    ce.parse('\\sqrt{x + \\sqrt{x}} = 2').solve('x'); // → [9/2 - √17/2] ≈ 2.438
    ce.parse('\\sqrt{x - \\sqrt{x}} = 1').solve('x'); // → [φ²] ≈ 2.618
  • Quadratic Equations Without Constant Term: Added support for solving quadratic equations of the form ax² + bx = 0 (missing constant term). These are solved by factoring: x(ax + b) = 0 → x = 0 or x = -b/a.

    ce.parse('x^2 + 3x = 0').solve('x'); // → [0, -3]
    ce.parse('2x^2 - 4x = 0').solve('x'); // → [0, 2]

Subscripts & Indexing​

  • Subscript Evaluation Handler: Define custom evaluation functions for subscripted symbols like mathematical sequences using subscriptEvaluate:

    // Define a Fibonacci sequence
    ce.declare('F', {
    subscriptEvaluate: (subscript, { engine }) => {
    const n = subscript.re;
    if (!Number.isInteger(n) || n < 0) return undefined;
    // Calculate Fibonacci number...
    return engine.number(fibValue);
    },
    });

    ce.parse('F_{10}').evaluate(); // → 55
    ce.parse('F_5').evaluate(); // → 5
    ce.parse('F_n').evaluate(); // → stays symbolic (handler returns undefined)

    Both simple subscripts (F_5) and complex subscripts (F_{5}) are supported. When the handler returns undefined, the expression stays symbolic. Subscripted expressions with subscriptEvaluate have type number and can be used in arithmetic operations: ce.parse('F_{5} + F_{3}').evaluate() works correctly.

  • Type-Aware Subscript Handling: Subscripts on symbols declared as collection types (list, tuple, matrix, etc.) now automatically convert to At() indexing operations:

    ce.declare('v', 'list<number>');
    ce.parse('v_n'); // → At(v, n)
    ce.parse('v_{n+1}'); // → At(v, n+1)
    ce.parse('v_{i,j}'); // → At(v, Tuple(i, j))

    This works for both simple subscripts (v_n) and complex subscripts (v_{n+1}). The type of the At() expression is correctly inferred from the collection's element type, allowing subscripted collection elements to be used in arithmetic.

  • Complex Subscripts in Arithmetic (Issue #273): Subscript expressions like a_{n+1} can now be used in arithmetic operations without type errors:

    ce.parse('a_{n+1} + 1'); // → Add(Subscript(a, n+1), 1)
    ce.parse('2 * a_{n+1}'); // → Multiply(2, Subscript(a, n+1))
    ce.parse('a_{n+1}^2'); // → Power(Subscript(a, n+1), 2)

    Previously, complex subscripts would fail with "incompatible-type" errors when used in arithmetic contexts.

  • Multi-Index At() Support: The At function now supports multiple indices for accessing nested collections (e.g., matrices):

    const matrix = ce.box(['List', ['List', 2, 3, 4], ['List', 6, 7, 9]]);
    ce.box(['At', matrix, 1, 2]).evaluate(); // → 3 (row 1, column 2)

    The signature was updated from single index to variadic: (value: indexed_collection, index: (number|string)+) -> unknown

  • Text Subscripts: Added support for \text{} in subscripts, allowing descriptive subscript names:

    ce.parse('x_{\\text{max}}'); // → symbol "x_max"
    ce.parse('v_{\\text{initial}}'); // → symbol "v_initial"

Sequences​

  • Declarative Sequence Definitions: Define mathematical sequences using recurrence relations with the new declareSequence() method:

    // Fibonacci sequence
    ce.declareSequence('F', {
    base: { 0: 0, 1: 1 },
    recurrence: 'F_{n-1} + F_{n-2}',
    });
    ce.parse('F_{10}').evaluate(); // → 55
    ce.parse('F_{20}').evaluate(); // → 6765

    // Arithmetic sequence: a_n = a_{n-1} + 2, a_0 = 1
    ce.declareSequence('A', {
    base: { 0: 1 },
    recurrence: 'A_{n-1} + 2',
    });
    ce.parse('A_{5}').evaluate(); // → 11

    // Factorial via recurrence
    ce.declareSequence('H', {
    base: { 0: 1 },
    recurrence: 'n \\cdot H_{n-1}',
    });
    ce.parse('H_{5}').evaluate(); // → 120

    Features:

    • Base cases as index → value mapping
    • Recurrence relation as LaTeX string or BoxedExpression
    • Automatic memoization for efficient evaluation (configurable)
    • Custom index variable name (default: n)
    • Domain constraints (min/max valid indices)
    • Symbolic subscripts stay symbolic (e.g., F_k remains unevaluated)

    Alternatively, sequences can be defined using natural LaTeX assignment notation:

    // Arithmetic sequence via LaTeX
    ce.parse('L_0 := 1').evaluate();
    ce.parse('L_n := L_{n-1} + 2').evaluate();
    ce.parse('L_{5}').evaluate(); // → 11

    // Fibonacci via LaTeX
    ce.parse('F_0 := 0').evaluate();
    ce.parse('F_1 := 1').evaluate();
    ce.parse('F_n := F_{n-1} + F_{n-2}').evaluate();
    ce.parse('F_{10}').evaluate(); // → 55

    Base cases and recurrence can be defined in any order. The sequence is finalized when both are present.

  • Sequence Status API: Query the status of sequence definitions with getSequenceStatus():

    ce.parse('F_0 := 0').evaluate();
    ce.getSequenceStatus('F');
    // → { status: 'pending', hasBase: true, hasRecurrence: false, baseIndices: [0] }

    ce.parse('F_n := F_{n-1} + F_{n-2}').evaluate();
    ce.getSequenceStatus('F');
    // → { status: 'complete', hasBase: true, hasRecurrence: true, baseIndices: [0] }

    ce.getSequenceStatus('x');
    // → { status: 'not-a-sequence', hasBase: false, hasRecurrence: false }
  • Sequence Introspection API: Inspect and manage defined sequences:

    // Get sequence information
    ce.getSequence('F');
    // → { name: 'F', variable: 'n', baseIndices: [0, 1], memoize: true, cacheSize: 5 }

    // List all defined sequences
    ce.listSequences(); // → ['F', 'A', 'H']

    // Check if a symbol is a sequence
    ce.isSequence('F'); // → true
    ce.isSequence('x'); // → false

    // Manage memoization cache
    ce.getSequenceCache('F'); // → Map { 2 => 1, 3 => 2, ... }
    ce.clearSequenceCache('F'); // Clear cache for specific sequence
    ce.clearSequenceCache(); // Clear all sequence caches
  • Generate Sequence Terms: Generate a list of sequence terms with getSequenceTerms():

    ce.declareSequence('F', {
    base: { 0: 0, 1: 1 },
    recurrence: 'F_{n-1} + F_{n-2}',
    });

    ce.getSequenceTerms('F', 0, 10);
    // → [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

    // With step parameter (every other term)
    ce.getSequenceTerms('F', 0, 10, 2);
    // → [0, 1, 3, 8, 21, 55]
  • Sum and Product over Sequences: Sum and Product now work seamlessly with user-defined sequences:

    ce.declareSequence('F', {
    base: { 0: 0, 1: 1 },
    recurrence: 'F_{n-1} + F_{n-2}',
    });

    ce.parse('\\sum_{k=0}^{10} F_k').evaluate(); // → 143
    ce.parse('\\prod_{k=1}^{5} A_k').evaluate(); // Works with any defined sequence
  • OEIS Integration: Look up sequences in the Online Encyclopedia of Integer Sequences (OEIS) and verify your sequences against known mathematical sequences:

    // Look up a sequence by its terms
    const results = await ce.lookupOEIS([0, 1, 1, 2, 3, 5, 8, 13]);
    // → [{ id: 'A000045', name: 'Fibonacci numbers', terms: [...], url: '...' }]

    // Check if your sequence matches a known OEIS sequence
    ce.declareSequence('F', {
    base: { 0: 0, 1: 1 },
    recurrence: 'F_{n-1} + F_{n-2}',
    });

    const result = await ce.checkSequenceOEIS('F', 10);
    // → { matches: [{ id: 'A000045', name: 'Fibonacci numbers', ... }], terms: [...] }

    Note: OEIS lookups require network access to oeis.org.

  • Multi-Index Sequences: Define sequences with multiple indices like Pascal's triangle P_{n,k} or grid-based recurrences:

    // Pascal's Triangle: P_{n,k} = P_{n-1,k-1} + P_{n-1,k}
    ce.declareSequence('P', {
    variables: ['n', 'k'],
    base: { 'n,0': 1, 'n,n': 1 }, // Pattern-based base cases
    recurrence: 'P_{n-1,k-1} + P_{n-1,k}',
    domain: { n: { min: 0 }, k: { min: 0 } },
    constraints: 'k <= n', // k must not exceed n
    });

    ce.parse('P_{5,2}').evaluate(); // → 10
    ce.parse('P_{10,5}').evaluate(); // → 252

    Features:

    • Multiple index variables with variables: ['n', 'k']
    • Pattern-based base cases: 'n,0' matches any (n, 0), 'n,n' matches diagonal
    • Per-variable domain constraints
    • Constraint expressions (e.g., 'k <= n')
    • Composite key memoization (e.g., '5,2')
    • Full introspection support with isMultiIndex flag

    Pattern matching for base cases:

    • Exact values: '0,0' matches only (0, 0)
    • Wildcards: 'n,0' matches any value for n with k=0
    • Equality: 'n,n' matches when both indices are equal
    • Priority: exact matches are checked before patterns

Special Functions​

  • Special Function Definitions: Added type signatures for special mathematical functions, enabling them to be used in expressions without type errors:

    • Zeta - Riemann zeta function \zeta(s)
    • Beta - Euler beta function B(a,b) = \Gamma(a)\Gamma(b)/\Gamma(a+b)
    • LambertW - Lambert W function (product logarithm)
    • BesselJ, BesselY, BesselI, BesselK - Bessel functions of first/second kind
    • AiryAi, AiryBi - Airy functions

    These functions now have proper signatures and can be composed with other expressions: ce.box(['Add', 1, ['LambertW', 'x']]) works correctly.

  • Special Function LaTeX Parsing: Added LaTeX parsing support for special functions: \zeta(s), \Beta(a,b), \operatorname{W}(x), Bessel functions via \operatorname{J}, \operatorname{Y}, etc., and Airy functions via \operatorname{Ai}, \operatorname{Bi}.

Calculus​

  • LambertW Derivative: Added derivative rule for the Lambert W function: d/dx W(x) = W(x)/(x·(1+W(x)))

  • Bessel Function Derivatives: Added derivative support for all four Bessel function types using order-dependent recurrence relations:

    ce.box(['D', ['BesselJ', 'n', 'x'], 'x']).evaluate();
    // → 1/2 * BesselJ(n-1, x) - 1/2 * BesselJ(n+1, x)

    ce.box(['D', ['BesselI', 'n', 'x'], 'x']).evaluate();
    // → 1/2 * BesselI(n-1, x) + 1/2 * BesselI(n+1, x)

    ce.box(['D', ['BesselK', 'n', 'x'], 'x']).evaluate();
    // → -1/2 * BesselK(n-1, x) - 1/2 * BesselK(n+1, x)

    Chain rule is automatically applied for composite arguments.

  • Multi-Argument Function Derivatives: Added derivative support for:

    • Log(x, base) - Logarithm with custom base:

      ce.box(['D', ['Log', 'x', 2], 'x']).evaluate(); // → 1/(x·ln(2))
      ce.box(['D', ['Log', 'x', 'a'], 'x']).evaluate(); // → 1/(x·ln(a))

      Also handles cases where both x and base depend on the variable by applying the quotient rule to ln(x)/ln(base).

    • Discrete functions (Mod, GCD, LCM) - Return 0 as these are step functions with derivative 0 almost everywhere:

      ce.box(['D', ['Mod', 'x', 5], 'x']).evaluate(); // → 0
      ce.box(['D', ['GCD', 'x', 6], 'x']).evaluate(); // → 0
  • Integration of 1/(x·ln(x)) Pattern: Added support for integrating expressions where the denominator is a product and one factor is the derivative of another:

    ce.parse('\\int \\frac{1}{x\\ln x} dx').evaluate(); // → ln(|ln(x)|)
    ce.parse('\\int \\frac{3}{x\\ln x} dx').evaluate(); // → 3·ln(|ln(x)|)

    This uses u-substitution: since 1/x = d/dx(ln(x)), the integral becomes ∫ h'(x)/h(x) dx = ln|h(x)|.

  • Cyclic Integration for e^x with Trigonometric Functions: Added support for integrating products of exponentials and trigonometric functions that require the "solve for the integral" technique:

    ce.parse('\\int e^x \\sin x dx').evaluate();
    // → -1/2·cos(x)·e^x + 1/2·sin(x)·e^x

    ce.parse('\\int e^x \\cos x dx').evaluate();
    // → 1/2·sin(x)·e^x + 1/2·cos(x)·e^x

    // Also works with linear arguments:
    ce.parse('\\int e^x \\sin(2x) dx').evaluate();
    // → -2/5·cos(2x)·e^x + 1/5·sin(2x)·e^x

    ce.parse('\\int e^x \\cos(2x) dx').evaluate();
    // → 1/5·cos(2x)·e^x + 2/5·sin(2x)·e^x

    These patterns cannot be solved by standard integration by parts (which would lead to infinite recursion) and instead use direct formulas:

    • ∫ e^x·sin(ax+b) dx = (e^x/(a²+1))·(sin(ax+b) - a·cos(ax+b))
    • ∫ e^x·cos(ax+b) dx = (e^x/(a²+1))·(a·sin(ax+b) + cos(ax+b))
  • Derivative Recursion Safety: Added recursion protection to differentiate() with a depth limit (MAX_DIFFERENTIATION_DEPTH), returning undefined when the limit is exceeded.

  • Equation Equivalence in isEqual() (Issue #275): Two equations are now recognized as equivalent if they have the same solution set:

    ce.parse('2x+1=0').isEqual(ce.parse('x=-1/2')); // → true
    ce.parse('3x+1=0').isEqual(ce.parse('6x+2=0')); // → true

    Uses sampling to check whether (LHS₁-RHS₁)/(LHS₂-RHS₂) is a non-zero constant.

Logic​

  • Boolean Simplification Rules: Added absorption laws and improved boolean expression simplification:

    • Absorption: A ∧ (A ∨ B) → A and A ∨ (A ∧ B) → A
    • Idempotence: A ∧ A → A and A ∨ A → A
    • Complementation: A ∧ ¬A → False and A ∨ ¬A → True
    • Identity: A ∧ True → A and A ∨ False → A
    • Domination: A ∧ False → False and A ∨ True → True
    • Double negation: ¬¬A → A

    These rules are applied automatically during simplification:

    ce.box(['And', 'A', ['Or', 'A', 'B']]).simplify(); // → A
    ce.box(['Or', 'A', ['And', 'A', 'B']]).simplify(); // → A
  • Prime Implicants and Minimal Normal Forms: Added Quine-McCluskey algorithm for finding prime implicants/implicates and computing minimal CNF/DNF:

    • PrimeImplicants(expr) - Find all prime implicants (minimal product terms)
    • PrimeImplicates(expr) - Find all prime implicates (minimal sum clauses)
    • MinimalDNF(expr) - Convert to minimal DNF using prime implicant cover
    • MinimalCNF(expr) - Convert to minimal CNF using prime implicate cover
    // Find prime implicants (terms that can't be further simplified)
    ce.box(['PrimeImplicants', ['Or', ['And', 'A', 'B'], ['And', 'A', ['Not', 'B']]]]).evaluate();
    // → [A] (AB and A¬B combine to just A)

    // Compute minimal DNF
    ce.box(['MinimalDNF', ['Or',
    ['And', 'A', 'B'],
    ['And', 'A', ['Not', 'B']],
    ['And', ['Not', 'A'], 'B']
    ]]).evaluate();
    // → A ∨ B (simplified from 3 terms to 2)

    Limited to 12 variables to prevent exponential blowup; larger expressions return unevaluated.

Linear Algebra​

  • Matrix Decompositions: Added four matrix decomposition functions for numerical linear algebra:

    • LUDecomposition(A) → [P, L, U] - LU factorization with partial pivoting
    • QRDecomposition(A) → [Q, R] - QR factorization using Householder reflections
    • CholeskyDecomposition(A) → L - Cholesky factorization for positive definite matrices
    • SVD(A) → [U, Σ, V] - Singular Value Decomposition
    ce.box(['LUDecomposition', [[4, 3], [6, 3]]]).evaluate();
    // → [P, L, U] where PA = LU

    ce.box(['QRDecomposition', [[1, 2], [3, 4]]]).evaluate();
    // → [Q, R] where A = QR, Q orthogonal, R upper triangular

    ce.box(['CholeskyDecomposition', [[4, 2], [2, 2]]]).evaluate();
    // → L where A = LL^T

    ce.box(['SVD', [[1, 2], [3, 4]]]).evaluate();
    // → [U, Σ, V] where A = UΣV^T

Fixed​

  • replace() Literal Matching in Object Rules: .replace({ match: 'a', replace: 2 }) no longer treats 'a' as a wildcard (string rules like "a*x -> 2*x" still auto-wildcard).

    const expr = ce.box(['Add', ['Multiply', 'a', 'x'], 'b']);
    expr.replace({match: 'a', replace: 2}, {recursive: true});
    // → 2x + b (was: 2 - incorrectly matched entire expression)
  • forget() Clears Assumed Values: ce.forget() now clears values set by equality assumptions across all evaluation context frames.

    ce.assume(ce.box(['Equal', 'x', 5]));
    ce.box('x').evaluate(); // → 5
    ce.forget('x');
    ce.box('x').evaluate(); // → 'x' (was: 5)
  • Scoped Assumptions Clean Up on popScope(): Assumptions made inside a scope no longer leak after popScope().

    ce.pushScope();
    ce.assume(ce.box(['Equal', 'y', 10]));
    ce.box('y').evaluate(); // → 10
    ce.popScope();
    ce.box('y').evaluate(); // → 'y' (was: 10)
  • Extraneous Root Filtering for Sqrt Equations: Candidate solutions are now validated against the original expression (before clearing denominators / harmonization) to filter extraneous roots.

    Examples of equations that now correctly filter extraneous roots:

    • √x = x - 2 → returns [4] (filters out x=1)
    • √x + x - 2 = 0 → returns [1] (filters out x=4)
    • √x - x + 2 = 0 → returns [4] (filters out x=1)
    • x - 2√x - 3 = 0 → returns [9] (filters out x=1)
    • 2x + 3√x - 2 = 0 → returns [1/4] (filters out x=4)
  • Simplification (#178):

    • Safer division canonicalization for denominators that may simplify to 0
    • Implicit multiplication powers: xx → x^2
    • Targeted exp/log rewriting for \exp(\log(x)±y)

0.33.0 2026-01-30​

Resolved Issues​

Arithmetic and Infinity​

  • Division by Zero: Improved handling of division by zero:

    • 0/0 returns NaN (indeterminate form)
    • a/0 where a ≠ 0 returns ComplexInfinity (~∞) as a "better NaN" that indicates an infinite result with unknown sign
    • This applies to all forms including 1/0, x/0, and rational literals
  • Infinity Sign Propagation: Fixed infinity multiplication not propagating signs correctly. Now ∞ * (-2) = -∞ and -∞ * 2 = -∞ as expected.

  • Infinity Division: Fixed ∞/∞ incorrectly returning 1. Now correctly returns NaN (indeterminate form). The a/a → 1 simplification rule now excludes infinity values.

Trigonometry​

  • Trigonometric Period Identities: Fixed incorrect sign handling for csc(π+x) and cot(π+x):

    • csc(π+x) now correctly simplifies to -csc(x) (was incorrectly csc(x))
    • cot(π+x) now correctly simplifies to cot(x) (was incorrectly -cot(x), cotangent has period π)
  • Trigonometric Co-function Identities: Fixed co-function identities not applying to canonical form expressions. Now correctly simplifies:

    • sin(π/2 - x) → cos(x)
    • cos(π/2 - x) → sin(x)
    • tan(π/2 - x) → cot(x)
    • cot(π/2 - x) → tan(x)
    • sec(π/2 - x) → csc(x)
    • csc(π/2 - x) → sec(x)
  • Double Angle with Coefficient: Fixed 2sin(x)cos(x) not simplifying to sin(2x). The product-to-sum identity now handles coefficients:

    • 2sin(x)cos(x) → sin(2x)
    • c·sin(x)cos(x) → c·sin(2x)/2 for any coefficient c
  • Trigonometric Product Identities: Improved handling of trig products in simplification. The Multiply rule now correctly defers to trig-specific rules for patterns like sin(x)*cos(x) and tan(x)*cot(x), ensuring these are simplified to sin(2x)/2 and 1 respectively.

Logarithms and Exponentials​

  • Logarithm-Exponential Composition: Fixed log(exp(x)) incorrectly simplifying to x. Now correctly returns x/ln(10) ≈ 0.434x since log₁₀(eˣ) = x·log₁₀(e) = x/ln(10). The identity log(exp(x)) = x only holds for natural logarithm.

  • Logarithm of e: Added simplification for log(e) → 1/ln(10) ≈ 0.434 and log_c(e) → 1/ln(c) for any base c.

  • Logarithm Combination Base Preservation: Fixed log(x) + log(y) (base 10) incorrectly becoming ln(xy). Now correctly produces log(xy) preserving the original base.

  • Logarithm Quotient Rule: Added expansion rule for logarithm of quotients. ln(x/y) now simplifies to ln(x) - ln(y) when x and y are known positive. Similarly for any base: log_c(x/y) → log_c(x) - log_c(y).

  • Exponential-Logarithm Composition: Added simplification for exp(log(x)) where log has a different base than e. Now e^log(x) → x^{1/ln(10)} and more generally e^log_c(x) → x^{1/ln(c)} for any base c.

Powers and Exponents​

  • Zero Power with Symbolic Exponent: Fixed 0^π and similar expressions with positive symbolic exponents not simplifying. Now 0^x → 0 when x is known to be positive (including π, e, etc.).

  • Exponent Evaluation in Products: Fixed (x³)² · (y²)² not simplifying to x⁶y⁴. Numeric subexpressions in exponents (like 2×3 in x^{2×3}) are now evaluated when the expression is part of a product.

  • Negative Exponents on Fractions: Fixed (a/b)^{-n} not simplifying properly. Now (x³/y²)^{-2} correctly simplifies to y⁴/x⁶ during canonicalization by distributing the negative exponent.

  • Negative Base with Fractional Exponent: Fixed (-ax)^{p/q} returning complex results when p and q are both odd. Now correctly factors out the negative sign: (-2x)^{3/5} → -(2x)^{3/5} = -2^{3/5}·x^{3/5}, giving real results. This affects products like (-2x)^{3/5}·x which now correctly simplify to -2^{3/5}·x^{8/5} instead of returning an imaginary value.

Radicals​

  • Radical Perfect Square Factoring: Fixed √(x²y) not simplifying to |x|√y. Adjusted cost function to penalize radicals containing perfect squares, enabling the simplification rule to apply.

  • Generalized Root Extraction: Added comprehensive root simplification rules:

    • √[n]{x^m} → x^{m/n} for odd roots (always valid)
    • √[n]{x^m} → |x|^{m/n} for even roots with integer result
    • √{x^{odd}} → |x|^n · √x factoring (e.g., √{x⁵} → |x|²√x)
    • Handles all combinations: √[4]{x⁶} → |x|^{3/2}, √[3]{x⁶} → x²
  • Symbolic Radicals Preservation: Fixed numeric radicals (√2, ∛5, 2^{3/5}) being evaluated to floating-point approximations during multiplication. Now x * √2 stays as √2 · x instead of 1.414... · x, and x * 2^{1/3} stays as x · ∛2 instead of 1.259... · x. This preserves exact irrational values and allows proper algebraic manipulation. Use .N() to get numeric approximations when needed.

LaTeX Parsing​

  • LaTeX \exp() Juxtaposition: Fixed adjacent \exp() calls not parsing as multiplication. Now \exp(x)\exp(2) correctly parses as e^x · e^2 instead of producing a parse error. The expression then simplifies to e^{x+2} as expected.

Features​

Trigonometry​

  • Fu Algorithm for Trigonometric Simplification: Implemented the Fu algorithm based on Fu, Zhong, and Zeng's paper "Automated and readable simplification of trigonometric expressions" (2006). This provides systematic, high-quality trigonometric simplification through:

    • Transformation Rules (TR1-TR22): Comprehensive set of rewrite rules including reciprocal conversions (sec→1/cos), ratio forms (tan→sin/cos), Pythagorean substitutions (sin²+cos²=1), power reductions, product-to-sum, sum-to-product, angle expansion/contraction, and Morrie's law for cosine product chains.

    • Rule Lists (RL1, RL2): Organized application sequences for tan/cot expressions and sin/cos expressions respectively, with greedy selection of optimal results.

    • Cost Function: Minimizes trigonometric function count as primary metric, with leaf count as secondary, to find the most readable form.

    Usage:

    // Option 1: Use strategy option with simplify()
    const result = expr.simplify({ strategy: 'fu' });

    // Option 2: Dedicated trigSimplify() method
    const result = expr.trigSimplify();

    Examples:

    • sin(x)⁴ - cos(x)⁴ → -cos(2x)
    • tan(x)·cot(x) → 1
    • sin²(x) + cos²(x) → 1
    • 2sin(x)cos(x) → sin(2x)
    • cos(x)·cos(2x)·cos(4x) → sin(8x)/(8sin(x)) (Morrie's law)

    Enhanced Transformations:

    • TRmorrie with Rational Coefficients: Morrie's law now handles angles that are rational multiples of π, such as cos(π/9)·cos(2π/9)·cos(4π/9) → 1/8. The algorithm detects maximal geometric sequences and handles cases where the sine terms cancel to produce pure fractions.

    • TR12i Tangent Sum Identity: Recognizes the pattern tan(A) + tan(B) - k·tan(A)·tan(B) and simplifies to -tan(C) when A + B + C = π and k = tan(C). Works with standard angles (π/6, π/4, π/3, etc.) and handles sign variations.

    • TRpythagorean for Compound Expressions: Detects sin²(x) + cos²(x) pairs within larger Add expressions and simplifies them to 1, e.g., sin²(x) + cos²(x) + 2 → 3.

    • Early TR9 Sum-to-Product: Applies sum-to-product transformation before angle expansion to catch patterns like sin(x+h) + sin(x-h) → 2sin(x)cos(h) that would otherwise be expanded and lose their simplified form.

    • Dual Strategy Approach: The Fu strategy now tries both "Fu first" and "simplify first" approaches and picks the best result. This handles both Morrie-like patterns (which need Fu before evaluation) and period reduction patterns (which need simplification first for angle contraction).

  • Trigonometric Periodicity Reduction: Trigonometric functions now simplify arguments containing integer multiples of π:

    • sin(5π + k) → -sin(k) (period 2π, with sign change for odd multiples)
    • cos(4π + k) → cos(k) (period 2π)
    • tan(3π + k) → tan(k) (period π)
    • Works for all six trig functions: sin, cos, tan, cot, sec, csc
    • Handles both positive and negative multiples of π
  • Pythagorean Trigonometric Identities: Added simplification rules for all Pythagorean identities:

    • sin²(x) + cos²(x) → 1
    • 1 - sin²(x) → cos²(x) and 1 - cos²(x) → sin²(x)
    • sin²(x) - 1 → -cos²(x) and cos²(x) - 1 → -sin²(x)
    • tan²(x) + 1 → sec²(x) and sec²(x) - 1 → tan²(x)
    • 1 + cot²(x) → csc²(x) and csc²(x) - 1 → cot²(x)
    • a·sin²(x) + a·cos²(x) → a (with coefficient)
  • Trigonometric Equation Solving: The solve() method now handles basic trigonometric equations:

    • sin(x) = a → x = arcsin(a) and x = π - arcsin(a) (two solutions)
    • cos(x) = a → x = arccos(a) and x = -arccos(a) (two solutions)
    • tan(x) = a → x = arctan(a) (one solution per period)
    • cot(x) = a → x = arccot(a)
    • Supports coefficient form: a·sin(x) + b = 0
    • Domain validation: returns no solutions when |a| > 1 for sin/cos
    • Automatic deduplication of equivalent solutions (e.g., cos(x) = 1 → single solution 0)

Calculus​

  • (#163) Additional Derivative Notations: Added support for parsing multiple derivative notations beyond Leibniz notation:

    • Newton's dot notation for time derivatives: \dot{x} → ["D", "x", "t"], \ddot{x} for second derivative, \dddot{x} and \ddddot{x} for higher orders. The time variable is configurable via the new timeDerivativeVariable parser option (default: "t").

    • Lagrange prime notation with arguments: f'(x) now parses to ["D", ["f", "x"], "x"], inferring the differentiation variable from the function argument. Works for f''(x), f'''(x), etc. for higher derivatives.

    • Euler's subscript notation: D_x f → ["D", "f", "x"] and D^2_x f or D_x^2 f for second derivatives.

    • Derivative serialization: D expressions now serialize to Leibniz notation (\frac{\mathrm{d}}{\mathrm{d}x}f) for consistent round-trip parsing.

  • Derivative Rules for Special Functions: Added derivative formulas for:

    • d/dx Digamma(x) = Trigamma(x)
    • d/dx Erf(x), d/dx Erfc(x), d/dx Erfi(x)
    • d/dx FresnelS(x), d/dx FresnelC(x)
    • d/dx LogGamma(x) = Digamma(x)

Special Functions​

  • Special Function Definitions: Added type signatures for Digamma, Trigamma, and PolyGamma functions to the library:
    • Digamma(x) - The digamma function ψ(x), logarithmic derivative of Gamma
    • Trigamma(x) - The trigamma function ψ₁(x), derivative of digamma
    • PolyGamma(n, x) - The polygamma function ψₙ(x), nth derivative of digamma

Logarithms and Exponentials​

  • Logarithm Combination Rules: Added simplification rules that combine logarithms with the same base:

    • ln(x) + ln(y) → ln(xy) (addition combines via multiplication)
    • ln(x) - ln(y) → ln(x/y) (subtraction combines via division)
    • log_c(x) + log_c(y) → log_c(xy) (works with any base)
    • log_c(x) - log_c(y) → log_c(x/y)
    • Handles multiple terms: ln(a) + ln(b) - ln(c) → ln(ab/c)
  • Exponential e Simplification: Added rules for combining powers of e:

    • eˣ · eʸ → e^(x+y) (same-base multiplication)
    • eˣ / eʸ → e^(x-y) (same-base division)
    • eˣ · e → e^(x+1) and eˣ / e → e^(x-1)
    • Preserves symbolic form instead of evaluating e^n numerically

Powers and Exponents​

  • Negative Base Power Simplification: Added rules to simplify powers with negated bases:

    • (-x)^n → x^n when n is even (e.g., (-x)^4 → x^4)
    • (-x)^n → -x^n when n is odd (e.g., (-x)^3 → -x^3)
    • (-x)^{n/m} → x^{n/m} when n is even and m is odd
    • (-x)^{n/m} → -x^{n/m} when both n and m are odd
    • (-1)^{p/q} → -1 when both p and q are odd (real odd root)
  • Power Distribution: Added rule to distribute integer exponents over products:

    • (ab)^n → a^n · b^n when n is an integer
    • Example: (x³y²)² → x⁶y⁴
    • Example: (-2x)² → 4x²
  • Same-Base Power Combination: Improved power combination for products with 3+ terms:

    • a³ · a · a² → a⁶ (combines all same-base terms)
    • Works with unknown symbols when sum of exponents is positive
    • Handles mixed products: b³c²dx⁷ya⁵gb²x⁵(3b) → 3dgyx¹²b⁶a⁵c²

Sum and Product​

  • (#133) Element-based Indexing Sets for Sum/Product: Added support for \in notation in summation and product subscripts:
    • Parsing: \sum_{n \in \{1,2,3\}} n now correctly parses to ["Sum", "n", ["Element", "n", ["Set", 1, 2, 3]]] instead of silently dropping the constraint.

    • Evaluation: Sums and products over finite sets, lists, and ranges are now evaluated correctly:

      • \sum_{n \in \{1,2,3\}} n → 6
      • \sum_{n \in \{1,2,3\}} n^2 → 14
      • \prod_{k \in \{1,2,3,4\}} k → 24
    • Serialization: Element-based indexing sets serialize back to LaTeX with proper \in notation: \sum_{n\in \{1, 2, 3\}}n

    • Range support: Works with Range expressions via ce.box(): ["Sum", "n", ["Element", "n", ["Range", 1, 5]]] → 15

    • Bracket notation as Range: Two-element integer lists in bracket notation [a,b] are now treated as Range(a,b) when used in Element context:

      • \sum_{n \in [1,5]} n → 15 (iterates 1, 2, 3, 4, 5)
      • Previously returned 6 (treated as List with just elements 1 and 5)
    • Interval support: Interval expressions work with Element-based indexing, including support for Open and Closed boundary markers:

      • ["Interval", 1, 5] → iterates integers 1, 2, 3, 4, 5 (closed bounds)
      • ["Interval", ["Open", 0], 5] → iterates 1, 2, 3, 4, 5 (excludes 0)
      • ["Interval", 1, ["Open", 6]] → iterates 1, 2, 3, 4, 5 (excludes 6)
    • Infinite series with Element notation: Known infinite integer sets are converted to their equivalent Limits form and iterated (capped at 1,000,000):

      • NonNegativeIntegers (ℕ₀) → iterates from 0, like \sum_{n=0}^{\infty}
      • PositiveIntegers (ℤ⁺) → iterates from 1, like \sum_{n=1}^{\infty}
      • Convergent series produce numeric approximations: \sum_{n \in \Z^+} \frac{1}{n^2} → ≈1.6449 (close to π²/6)
    • Non-enumerable domains stay symbolic: When the domain cannot be enumerated (unknown symbol, non-iterable infinite set, or symbolic bounds), the expression stays symbolic instead of returning NaN:

      • \sum_{n \in S} n with unknown S → stays as ["Sum", "n", ["Element", "n", "S"]]
      • \sum_{n \in \Z} n → stays symbolic (bidirectional, can't forward iterate)
      • \sum_{x \in \R} f(x) → stays symbolic (non-countable)
      • \sum_{n \in [1,a]} n with symbolic bound → stays symbolic
      • Previously these would all return NaN with no explanation
    • Multiple Element indexing sets: Comma-separated Element expressions now parse and evaluate correctly:

      • \sum_{n \in A, m \in B} (n+m) → ["Sum", ..., ["Element", "n", "A"], ["Element", "m", "B"]]
      • Nested sums like \sum_{i \in A}\sum_{j \in B} i \cdot j evaluate correctly
      • Mixed indexing sets (Element + Limits) work together
    • Condition/filter support in Element expressions: Conditions can be attached to Element expressions to filter values from the set:

      • \sum_{n \in S, n > 0} n → sums only positive values from S
      • \sum_{n \in S, n \ge 2} n → sums values ≥ 2 from S
      • \prod_{k \in S, k < 0} k → multiplies only negative values from S
      • Supported operators: >, >=, <, <=, !=
      • Conditions are attached as the 4th operand of Element: ["Element", "n", "S", ["Greater", "n", 0]]

Linear Algebra​

  • Matrix Multiplication: Added MatrixMultiply function supporting:

    • Matrix × Matrix: A (m×n) × B (n×p) → result (m×p)
    • Matrix × Vector: A (m×n) × v (n) → result (m)
    • Vector × Matrix: v (m) × B (m×n) → result (n)
    • Vector × Vector (dot product): v1 (n) · v2 (n) → scalar
    • Proper dimension validation with incompatible-dimensions errors
    • LaTeX serialization using \cdot notation
  • Matrix Addition and Scalar Broadcasting: Add now supports element-wise operations on tensors (matrices and vectors):

    • Matrix + Matrix: Element-wise addition (shapes must match)
    • Scalar + Matrix: Broadcasts scalar to all elements
    • Vector + Vector: Element-wise addition
    • Scalar + Vector: Broadcasts scalar to all elements
    • Symbolic support: [[a,b],[c,d]] + [[1,2],[3,4]] evaluates correctly
    • Proper dimension validation with incompatible-dimensions errors
  • Matrix Construction Functions: Added convenience functions for creating common matrices:

    • IdentityMatrix(n): Creates an n×n identity matrix
    • ZeroMatrix(m, n?): Creates an m×n matrix of zeros (square if n omitted)
    • OnesMatrix(m, n?): Creates an m×n matrix of ones (square if n omitted)
  • Matrix and Vector Norms: Added Norm function for computing various norms:

    • Vector norms: L1 (sum of absolute values), L2 (Euclidean, default), L-infinity (max absolute value), and general Lp norms
    • Matrix norms: Frobenius (default, sqrt of sum of squared elements), L1 (max column sum), L-infinity (max row sum)
    • Scalar norms return the absolute value
  • Eigenvalues and Eigenvectors: Added functions for eigenvalue decomposition:

    • Eigenvalues(matrix): Returns list of eigenvalues (2×2: symbolic via characteristic polynomial; 3×3: Cardano's formula; larger: numeric QR)
    • Eigenvectors(matrix): Returns list of corresponding eigenvectors using null space computation via Gaussian elimination
    • Eigen(matrix): Returns tuple of (eigenvalues, eigenvectors)
  • Diagonal Function: Now fully implemented with bidirectional behavior:

    • Vector → Matrix: Creates a diagonal matrix from a vector (Diagonal([1,2,3]) → 3×3 diagonal matrix)
    • Matrix → Vector: Extracts the diagonal as a vector (Diagonal([[1,2],[3,4]]) → [1,4])
  • Higher-Rank Tensor Operations: Extended Transpose, ConjugateTranspose, and Trace to work with rank > 2 tensors:

    • Transpose: Swaps last two axes by default (batch transpose), or specify explicit axes with ['Transpose', T, axis1, axis2]
    • ConjugateTranspose: Same axis behavior as Transpose, plus element-wise complex conjugation
    • Trace (batch trace): Returns a tensor of traces over the last two axes. For a [2,2,2] tensor, returns [trace of T[0], trace of T[1]]. Optional axis parameters: ['Trace', T, axis1, axis2]
  • Reshape Cycling: Implements APL-style ravel cycling. When reshaping to a larger shape, elements cycle from the beginning: Reshape([1,2,3], (2,2)) → [[1,2],[3,1]]

  • Scalar Handling: Most linear algebra functions now handle scalar inputs:

    • Flatten(42) → [42] (single-element list)
    • Transpose(42) → 42 (identity)
    • Determinant(42) → 42 (1×1 matrix determinant)
    • Trace(42) → 42 (1×1 matrix trace)
    • Inverse(42) → 1/42 (scalar reciprocal)
    • ConjugateTranspose(42) → 42 (conjugate of real is itself)
    • Reshape(42, (2,2)) → [[42,42],[42,42]] (scalar replication)
  • Improved Error Messages: Operations requiring square matrices (Determinant, Trace, Inverse) now return expected-square-matrix error for vectors and tensors (rank > 2).

Performance​

  • Pattern Matching Optimization: Significantly improved performance of commutative pattern matching by adding early rejection guards:
    • Arity Guard: Patterns without sequence wildcards (__/___) now immediately reject expressions with mismatched operand counts instead of attempting factorial permutations
    • Anchor Fingerprint: Patterns with literal or symbolic anchors verify anchor presence before attempting permutation matching, eliminating impossible matches in O(n) time
    • Universal Anchoring: Extended the efficient anchor-based backtracking algorithm to all patterns with anchors, not just those with sequence wildcards
    • Hash Bucketing: For patterns with many anchors (4+) against large expressions (6+ operands), uses hash-based indexing to reduce anchor lookup from O(n×m) to O(n+m) average case
    • Example: Matching a + b + c + 1 against x + y + z now rejects immediately (arity mismatch: 4 vs 3) instead of trying 24 permutations

Resolved Issues​

Arithmetic​

  • Indeterminate Form Handling: Fixed incorrect results for mathematical indeterminate forms:

    • 0 * ∞ now correctly returns NaN (previously returned ∞)
    • ∞ / ∞ now correctly returns NaN (previously returned 1)
    • ∞^0 now correctly returns NaN (was already correct)
    • All combinations (0 * (-∞), (-∞) / ∞, etc.) are handled correctly
  • (#176) Power Combination Simplification: Fixed simplification failing to combine powers with the same base when one factor has an implicit exponent or when there are 3+ operands. Previously, expressions like 2 * 2^x, e * e^x * e^{-x}, and x^2 * x would not simplify. Now correctly simplifies to 2^(x+1), e, and x^3 respectively. The fix includes:

    • Extended power combination rules to support numeric literal bases
    • Added functional rule to handle n-ary Multiply expressions (3+ operands)
    • Adjusted simplification cost threshold from 1.2 to 1.3 to accept mathematically valid simplifications where exponents become slightly more complex (e.g., 2 * 2^x → 2^(x+1))
  • Symbolic Factorial: Fixed (n-1)! incorrectly evaluating to NaN instead of staying symbolic. The factorial evaluate function was attempting numeric computation on symbolic arguments. Now correctly returns undefined (keeping the expression symbolic) when the argument is not a number literal.

Linear Algebra​

  • Matrix Operations Type Validation: Fixed matrix operations (Shape, Rank, Flatten, Transpose, Determinant, Inverse, Trace, etc.) returning incorrect results or failing with type errors. The root cause was a type mismatch: function signatures expected matrix type (a 2D list with dimensions), but BoxedTensor.type returned list<number> without dimensions. Now BoxedTensor, BoxedFunction, and BoxedSymbol correctly derive shape and rank from their type's dimensions. Additionally, linear algebra functions now properly evaluate their operands before checking if they are tensors.

Calculus​

  • Numerical Integration: Fixed \int_0^1 \sin(x) dx returning NaN when evaluated numerically with .N(). The integrand was already wrapped in a Function expression by the canonical form, but the numerical evaluation code was wrapping it again, creating a nested function that returned a function instead of a number. Now correctly checks if the integrand is already a Function before wrapping.

LaTeX Parsing and Serialization​

  • Subscript Function Calls: Fixed parsing of function calls with subscripted names like f_\text{a}(5). Previously, this was incorrectly parsed as a Tuple instead of a function call because Subscript expressions weren't being canonicalized before the function call check. Now correctly recognizes that f_a(5) is a function call when the subscript canonicalizes to a symbol.

  • (#130) Prefix/Postfix Operator LaTeX Serialization: Fixed incorrect LaTeX output for prefix operators (like Negate) and postfix operators (like Factorial) when applied to expressions with lower precedence. Previously, Negate(Add(a, b)) incorrectly serialized as -a+b instead of -(a+b), causing round-trip failures where parsing the output produced a mathematically different expression. Similarly, Factorial(Add(a, b)) now correctly serializes as (a+b)! instead of a+b!. The fix ensures operands are wrapped in parentheses when their precedence is lower than the operator's precedence.

  • (#156) Logical Operator Precedence: Fixed parsing of logical operators \vee (Or) and \wedge (And) with relational operators. Previously, expressions like 3=4\vee 7=8 were incorrectly parsed with the wrong precedence. Now correctly parses as ["Or", ["Equal", 3, 4], ["Equal", 7, 8]]. Logical operators have lower precedence (230-235) than comparison operators (245) and set relations (240), so compound propositions parse correctly without requiring parentheses.

  • (#156) Logical Connective Arrows: Added support for additional arrow notation in logical expressions:

    • \rightarrow now parses as Implies (previously parsed as To for set/function mapping)
    • \leftrightarrow now parses as Equivalent (previously produced an "unexpected-command" error)
    • Long arrow variants now supported: \Longrightarrow, \longrightarrow → Implies; \Longleftrightarrow, \longleftrightarrow → Equivalent
    • The existing variants \Rightarrow, \Leftrightarrow, \implies, \iff continue to work
    • \to remains available for function/set mapping notation (e.g., f: A \to B)

Simplification​

  • Rules Cache Isolation: Fixed rules cache building failing with "Invalid rule" errors when user expressions had previously polluted the global scope. For example, parsing x(y+z) would add x as a symbol with function type to the global scope. Later, when the simplification rules cache was built, rule parsing would fail because wildcards like _x in rules would be type-checked against the polluted scope where x had incompatible type. The fix ensures rule parsing uses a clean scope that inherits only from the system scope (containing built-in definitions), not from user-polluted scopes.

  • Simplification Rules: Added and fixed several simplification rules:

    • x + x now correctly simplifies to 2x (term combination)
    • e^x * e^{-x} now correctly simplifies to 1 (exponential inverse)
    • sin(∞) and cos(∞) now correctly evaluate to NaN
    • tanh(∞) now correctly evaluates to 1, tanh(-∞) to -1
    • log_b(x^n) now correctly simplifies to n * log_b(x) (log power rule)
    • Improved cost function to prefer n * ln(x) form over ln(x^n)
    • Trigonometric functions now reduce arguments by their period (e.g., cos(5π + k) simplifies using cos(π + k) = -cos(k))
  • (#178) Non-Canonical Expression Simplification: Fixed .simplify() not working on expressions parsed with { canonical: false }. Previously, ce.parse('x+x', { canonical: false }).simplify() would return x+x instead of 2x. The bug was in the simplification loop detection: when canonicalizing before simplification, the non-canonical form was recorded in the "seen" set, and since isSame() considers non-canonical and canonical forms equivalent, the canonical form was incorrectly detected as already processed. Now the simplification correctly starts fresh when canonicalizing, allowing full simplification to proceed.

0.32.0 2026-01-28​

Resolved Issues​

Calculus​

  • (#230) Root Derivatives: Fixed the D operator not differentiating expressions containing the Root operator (n-th roots). Previously, D(Root(x, 3), x) (derivative of ∛x) would return an unevaluated derivative expression instead of computing the result. Now correctly returns 1/(3x^(2/3)), equivalent to the expected (1/3)·x^(-2/3). The fix adds a special case in the differentiate function to handle Root(base, n) by applying the power rule with exponent 1/n.

  • Abs Derivative: Fixed d/dx |x| returning an error when evaluated with a variable that has an assigned value. The derivative formula now uses Sign(x) instead of a complex Which expression that couldn't be evaluated symbolically.

  • Step Function Derivatives: Fixed D(floor(x), x), D(ceil(x), x), and D(round(x), x) causing infinite recursion. These step functions now correctly return 0 (the derivative is 0 almost everywhere). Also fixed a bug where derivative formulas that evaluate to 0 weren't recognized due to a falsy check.

  • Inverse Trig Integrals: Fixed incorrect integration formulas for arcsin, arccos, and arctan. The previous formulas were completely wrong. Correct:

    • ∫ arcsin(x) dx = x·arcsin(x) + √(1-x²)
    • ∫ arccos(x) dx = x·arccos(x) - √(1-x²)
    • ∫ arctan(x) dx = x·arctan(x) - (1/2)·ln(1+x²)
  • Erfc Derivative: Fixed incorrect derivative formula for erfc(x). Now correctly returns -2/√π · e^(-x²) (the negative of the erf derivative).

  • LogGamma Derivative: Added derivative rule for LogGamma(x) which returns Digamma(x) (the digamma/psi function).

  • Special Function Derivatives: Fixed derivative formulas for several special functions and removed incorrect ones:

    • Fixed d/dx erfi(x) = (2/√π)·e^(x²) (imaginary error function)
    • Fixed d/dx S(x) = sin(πx²/2) (Fresnel sine integral)
    • Fixed d/dx C(x) = cos(πx²/2) (Fresnel cosine integral)
    • Removed incorrect derivative formulas for Zeta, Digamma, PolyGamma, Beta, LambertW, Bessel functions, and Airy functions (these now return symbolic derivatives like Digamma'(x) instead of wrong numeric results)
  • Symbolic Derivative Evaluation: Fixed derivatives of unknown functions returning 0 instead of symbolic derivatives. For example, D(Digamma(x), x) now correctly returns Digamma'(x) (as Apply(Derivative(Digamma, 1), x)) instead of incorrectly returning 0.

LaTeX Parsing and Serialization​

  • (#256) Subscript Symbol Parsing: Fixed parsing of single-letter symbols with subscripts. Previously, i_A was incorrectly parsed as ["Subscript", ["Complex", 0, 1], "A"] because i was recognized as the imaginary unit before the subscript was processed. Now i_A correctly parses as the symbol i_A. This applies to all single-letter symbols including constants like e and i. Complex subscripts containing operators (n+1), commas (n,m), or parentheses ((n+1)) still produce Subscript expressions.

  • LaTeX Serialization: Fixed TypeScript error in power serialization where denom (a number | null) was incorrectly passed where an Expression was expected. Now correctly uses operand(exp, 2) to get the expression form.

  • (#168) Absolute Value: Fixed parsing of nested absolute value expressions that start with a double bar (e.g. ||3-5|-4|), which previously produced an invalid structure instead of evaluating correctly.

  • (#244) Serialization: Fixed LaTeX and ASCIIMath serialization ambiguity for negative bases and negated powers. Powers now render (-2)^2 (instead of -2^2) when the base is negative, and negated powers now render as -(2^2) rather than -2^2.

  • (#243) LaTeX Parsing: Fixed logic operator precedence causing expressions like x = 1 \vee x = 2 to be parsed incorrectly as x = (1 ∨ x) = 2 instead of (x = 1) ∨ (x = 2). Comparison operators (=, <, >, etc.) now correctly bind tighter than logic operators (\land, \lor, \veebar, etc.).

  • (#264) Serialization: Fixed LaTeX serialization of quantified expressions (ForAll, Exists, ExistsUnique, NotForAll, NotExists). Previously, only the quantifier symbol was output (e.g., \forall x instead of \forall x, x>y). The body of the quantified expression is now correctly serialized.

  • (#257) LaTeX Parsing: Fixed \gcd command not parsing function arguments correctly. Previously \gcd\left(24,37\right) would parse as ["Tuple", "GCD", ["Tuple", 24, 37]] instead of the expected ["GCD", 24, 37]. The \operatorname{gcd} form was unaffected. Also added support for \lcm as a LaTeX command (in addition to the existing \operatorname{lcm}).

  • (#223) Serialization: Fixed scientific/engineering LaTeX serialization dropping the leading coefficient for exact powers of ten. For example, 1000 now serializes to 1\cdot10^{3} (or 1\times10^{3} depending on exponentProduct) instead of 10^{3}.

  • LaTeX Parsing: Fixed \cosh incorrectly mapping to Csch instead of Cosh.

  • (#255) LaTeX Parsing: Fixed multi-letter subscripts like A_{CD} causing "incompatible-type" errors in arithmetic operations. Multi-letter subscripts without parentheses are now interpreted as compound symbol names (e.g., A_{CD} → A_CD, x_{ij} → x_ij, T_{max} → T_max). Use parentheses for expression subscripts: A_{(CD)} creates a Subscript expression where CD represents implicit multiplication. The Delimiter wrapper is now stripped from subscript expressions for cleaner output.

First-Order Logic​

  • (#263) Quantifier Scope: Fixed quantifier scope in First-Order Logic expressions. Previously, \forall x.P(x)\rightarrow Q(x) was parsed with the implication inside the quantifier scope: ["ForAll", "x", ["To", P(x), Q(x)]]. Now it correctly follows standard FOL conventions where the quantifier binds only the immediately following formula: ["To", ["ForAll", "x", P(x)], Q(x)]. This applies to all quantifiers (ForAll, Exists, ExistsUnique, NotForAll, NotExists) and all logical connectives (\rightarrow, \to, \implies, \land, \lor, \iff). Use explicit parentheses for wider scope: \forall x.(P(x)\rightarrow Q(x)). Also fixed quantifier type signatures to properly return boolean, enabling correct type checking when quantified expressions are used as arguments to logical operators.

Simplification​

  • Sign Simplification: Fixed Sign(x).simplify() returning 1 instead of -1 when x is negative. The simplification rule incorrectly returned ce.One for both positive and negative cases.

Type System​

  • Ceil Type Signature: Fixed Ceil function signature from (real) -> integer to (number) -> integer to match Floor. This resolves "incompatible-type" errors when computing derivatives of ceiling expressions or using Ceil in contexts expecting a general number type.

Polynomials​

  • Polynomial Degree Detection: Fixed polynomialDegree() returning 0 for expressions like e^x or e^(-x^2) when it should return -1 (not a polynomial). When the base of a power is constant but the exponent depends on the variable, this is not a polynomial. This bug caused infinite recursion in simplification when simplifying expressions containing exponentials, such as the derivative of erf(x) which is (2/√π)·e^(-x²).

Pattern Matching​

  • (#258) Pattern Matching: Fixed BoxedExpression.match() returning null when matching patterns against canonicalized expressions. Several cases are now handled:
    • Rational patterns now match expressions like ['Rational', 'x', 2] which are canonicalized to ['Multiply', ['Rational', 1, 2], 'x']
    • Power patterns now match ['Power', 'x', -1] which is canonicalized to ['Divide', 1, 'x'], returning {_base: x, _exp: -1}
    • Power patterns now match ['Root', 'x', 3] (cube root), returning {_base: x, _exp: ['Divide', 1, 3]}

Sum and Product​

  • (#252) Sum/Product: Fixed Sum and Product returning NaN when the body contains free variables (variables not bound by the index). For example, \sum_{n=1}^{10}(x) now correctly evaluates to 10x instead of NaN, and \prod_{n=1}^{5}(x) evaluates to x^5. Mixed expressions like \sum_{n=1}^{10}(n \cdot x) now return 55x. Also fixed toString() for Sum and Product expressions with non-trivial bodies (e.g., Multiply) which were incorrectly displayed as int().

Equation Solving​

  • (#242) Solve: Fixed solve() returning an empty array for equations with variables in fractions. For example, F = 3g/h solved for g now correctly returns Fh/3 instead of an empty array. The solver now clears denominators before applying solve rules, enabling it to handle expressions like a + bx/c = 0. Also added support for solving equations where the variable is in the denominator (e.g., a/x = b now returns x = a/b).

  • (#220) Solve: Fixed solve() returning an empty array for equations involving square roots of the unknown, e.g. 2x = \sqrt{5x}. The solver now handles equations of the form ax + b√x + c = 0 using quadratic substitution. Also added support for solving logarithmic equations like a·ln(x) + b = 0 which returns x = e^(-b/a).

Improvements​

First-Order Logic​

  • (#263) First-Order Logic: Added several improvements for working with First-Order Logic expressions:
    • Configurable quantifier scope: New quantifierScope parsing option controls how quantifier scope is determined. Use "tight" (default) for standard FOL conventions where quantifiers bind only the immediately following formula, or "loose" for scope extending to the end of the expression.
      ce.parse('\\forall x. P(x)', { quantifierScope: 'tight' }) // default
      ce.parse('\\forall x. P(x)', { quantifierScope: 'loose' })
    • Automatic predicate inference: Single uppercase letters followed by parentheses (e.g., P(x), Q(a,b)) are now automatically recognized as predicate/function applications without requiring explicit declaration. This enables natural FOL syntax like \forall x. P(x) \rightarrow Q(x) to work out of the box.
    • Quantifier evaluation over finite domains: Quantifiers (ForAll, Exists, ExistsUnique, NotForAll, NotExists) now evaluate to boolean values when the bound variable is constrained to a finite set. For example:
      ce.box(['ForAll', ['Element', 'x', ['Set', 1, 2, 3]], ['Greater', 'x', 0]]).evaluate()
      // Returns True (all values in {1,2,3} are > 0)
      ce.box(['Exists', ['Element', 'x', ['Set', 1, 2, 3]], ['Greater', 'x', 2]]).evaluate()
      // Returns True (3 > 2)
      ce.box(['ExistsUnique', ['Element', 'x', ['Set', 1, 2, 3]], ['Equal', 'x', 2]]).evaluate()
      // Returns True (only one element equals 2)
      Supports Set, List, Range, and integer Interval domains up to 1000 elements. Nested quantifiers are evaluated over the Cartesian product of their domains.
    • Symbolic simplification for quantifiers: Quantifiers now simplify automatically in special cases:
      • ∀x. True → True, ∀x. False → False
      • ∃x. True → True, ∃x. False → False
      • ∀x. P → P (when P doesn't contain x)
      • ∃x. P → P (when P doesn't contain x)
    • CNF/DNF conversion: New ToCNF and ToDNF functions convert boolean expressions to Conjunctive Normal Form and Disjunctive Normal Form respectively:
      ce.box(['ToCNF', ['Or', ['And', 'A', 'B'], 'C']]).evaluate()
      // Returns (A ∨ C) ∧ (B ∨ C)
      ce.box(['ToDNF', ['And', ['Or', 'A', 'B'], 'C']]).evaluate()
      // Returns (A ∧ C) ∨ (B ∧ C)
      Handles And, Or, Not, Implies, Equivalent, Xor, Nand, and Nor operators using De Morgan's laws and distribution.
    • Boolean operator evaluation: Added evaluation support for Xor, Nand, and Nor operators with True/False arguments:
      ce.box(['Xor', 'True', 'False']).evaluate() // Returns True
      ce.box(['Nand', 'True', 'True']).evaluate() // Returns False
      ce.box(['Nor', 'False', 'False']).evaluate() // Returns True
    • N-ary boolean operators: Xor, Nand, and Nor now support any number of arguments:
      • Xor(a, b, c, ...) returns true when an odd number of arguments are true
      • Nand(a, b, c, ...) returns the negation of And(a, b, c, ...)
      • Nor(a, b, c, ...) returns the negation of Or(a, b, c, ...)
    • Satisfiability checking: New IsSatisfiable function checks if a boolean expression can be made true with some assignment of variables:
      ce.box(['IsSatisfiable', ['And', 'A', ['Not', 'A']]]).evaluate() // False
      ce.box(['IsSatisfiable', ['Or', 'A', 'B']]).evaluate() // True
    • Tautology checking: New IsTautology function checks if a boolean expression is true for all possible variable assignments:
      ce.box(['IsTautology', ['Or', 'A', ['Not', 'A']]]).evaluate() // True
      ce.box(['IsTautology', ['And', 'A', 'B']]).evaluate() // False
    • Truth table generation: New TruthTable function generates a complete truth table for a boolean expression:
      ce.box(['TruthTable', ['And', 'A', 'B']]).evaluate()
      // Returns [["A","B","Result"],["False","False","False"],...]
    • Explicit Predicate function: Added a new Predicate function to explicitly represent predicate applications in First-Order Logic. Inside quantifier scopes (\forall, \exists, etc.), single uppercase letters followed by parentheses are now parsed as ["Predicate", "P", "x"] instead of ["P", "x"]. This distinguishes predicates from regular function applications and avoids naming conflicts with library functions.
      ce.parse('\\forall x. P(x)').json
      // Returns ["ForAll", "x", ["Predicate", "P", "x"]]
      Outside quantifier scopes, P(x) is still parsed as ["P", "x"] to maintain backward compatibility with function definitions like Q(x) := ....
    • D(f, x) no longer maps to derivative: The LaTeX notation D(f, x) is not standard mathematical notation for derivatives and previously caused confusion with the D derivative function in MathJSON. Now D(f, x) in LaTeX parses as ["Predicate", "D", "f", "x"] instead of the derivative. Use Leibniz notation (\frac{d}{dx}f) for derivatives in LaTeX, or construct the derivative directly in MathJSON: ["D", expr, "x"].
    • N(x) no longer maps to numeric evaluation: Similarly, N(x) in LaTeX is CAS-specific notation, not standard math notation. Now N(x) parses as ["Predicate", "N", "x"] instead of the numeric evaluation function. This allows N to be used as a variable (e.g., "for all N in Naturals"). Use the .N() method for numeric evaluation, or construct it directly in MathJSON: ["N", expr].

Polynomials​

  • Polynomial Simplification: The simplify() function now automatically cancels common polynomial factors in univariate rational expressions. For example, (x² - 1)/(x - 1) simplifies to x + 1, (x³ - x)/(x² - 1) simplifies to x, and (x + 1)/(x² + 3x + 2) simplifies to 1/(x + 2). Previously, this required explicitly calling the Cancel function with a variable argument.

Sum and Product​

  • Sum/Product Simplification: Added simplification rules for Sum and Product expressions with symbolic bounds:
    • Constant body: \sum_{n=1}^{b}(x) simplifies to b * x
    • Triangular numbers (general bounds): \sum_{n=a}^{b}(n) simplifies to (b(b+1) - a(a-1))/2
    • Sum of squares: \sum_{n=1}^{b}(n^2) simplifies to b(b+1)(2b+1)/6
    • Sum of cubes: \sum_{n=1}^{b}(n^3) simplifies to [b(b+1)/2]^2
    • Geometric series: \sum_{n=0}^{b}(r^n) simplifies to (1-r^(b+1))/(1-r)
    • Alternating unit series: \sum_{n=0}^{b}((-1)^n) simplifies to (1+(-1)^b)/2
    • Alternating linear series: \sum_{n=0}^{b}((-1)^n * n) simplifies to (-1)^b * floor((b+1)/2)
    • Arithmetic progression: \sum_{n=0}^{b}(a + d*n) simplifies to (b+1)(a + db/2)
    • Sum of binomial coefficients: \sum_{k=0}^{n}C(n,k) simplifies to 2^n
    • Alternating binomial sum: \sum_{k=0}^{n}((-1)^k * C(n,k)) simplifies to 0
    • Weighted binomial sum: \sum_{k=0}^{n}(k * C(n,k)) simplifies to n * 2^(n-1)
    • Partial fractions (telescoping): \sum_{k=1}^{n}(1/(k(k+1))) simplifies to n/(n+1)
    • Partial fractions (telescoping): \sum_{k=2}^{n}(1/(k(k-1))) simplifies to (n-1)/n
    • Weighted squared binomial sum: \sum_{k=0}^{n}(k^2 * C(n,k)) simplifies to n(n+1) * 2^(n-2)
    • Weighted cubed binomial sum: \sum_{k=0}^{n}(k^3 * C(n,k)) simplifies to n²(n+3) * 2^(n-3)
    • Alternating weighted binomial sum: \sum_{k=0}^{n}((-1)^k * k * C(n,k)) simplifies to 0 (n ≥ 2)
    • Sum of binomial squares: \sum_{k=0}^{n}(C(n,k)^2) simplifies to C(2n, n)
    • Sum of consecutive products: \sum_{k=1}^{n}(k(k+1)) simplifies to n(n+1)(n+2)/3
    • Arithmetic progression (general bounds): \sum_{n=m}^{b}(a + d*n) simplifies to (b-m+1)(a + d(m+b)/2)
    • Product of constant: \prod_{n=1}^{b}(x) simplifies to x^b
    • Factorial: \prod_{n=1}^{b}(n) simplifies to b!
    • Shifted factorial: \prod_{n=1}^{b}(n+c) simplifies to (b+c)!/c!
    • Odd double factorial: \prod_{n=1}^{b}(2n-1) simplifies to (2b-1)!!
    • Even double factorial: \prod_{n=1}^{b}(2n) simplifies to 2^b * b!
    • Rising factorial (Pochhammer): \prod_{k=0}^{n-1}(x+k) simplifies to (x)_n
    • Falling factorial: \prod_{k=0}^{n-1}(x-k) simplifies to x!/(x-n)!
    • Telescoping product: \prod_{k=1}^{n}((k+1)/k) simplifies to n+1
    • Wallis-like product: \prod_{k=2}^{n}(1 - 1/k^2) simplifies to (n+1)/(2n)
    • Factor out constants: \sum_{n=1}^{b}(c \cdot f(n)) simplifies to c \cdot \sum_{n=1}^{b}(f(n)), and similarly for products where the constant is raised to the power of the iteration count
    • Nested sums/products: inner sums/products are simplified first, enabling cascading simplification
    • Edge cases: empty ranges (upper < lower) return identity elements (0 for Sum, 1 for Product), and single-iteration ranges substitute the bound value

0.31.0 2026-01-27​

Breaking Changes​

  • The [Length] function has been renamed to [Count].
  • The xsize property of collections has been renamed to count.
  • The xcontains() method of collections has been renamed to contains().
  • Handling of dictionaries (["Dictionary"] expressions and \{dict:...\} shorthand) has been improved.
  • Inverse hyperbolic functions have been renamed to follow the ISO 80000-2 standard: Arcsinh → Arsinh, Arccosh → Arcosh, Arctanh → Artanh, Arccoth → Arcoth, Arcsech → Arsech, Arccsch → Arcsch. The "ar" prefix (for "area") is mathematically correct since these functions relate to areas on a hyperbola, not arc lengths. Both LaTeX spellings (\arsinh and \arcsinh) are accepted as input (Postel's law).

Resolved Issues​

LaTeX Parsing​

  • Metadata Preservation: Fixed verbatimLatex not being preserved when parsing with preserveLatex: true. The original LaTeX source is now correctly stored on parsed expressions (when using non-canonical mode). Also fixed metadata (latex, wikidata) being lost when boxing MathJSON objects that contain these attributes.

  • String Parsing: Fixed parsing of \text{...} with preserveLatex: true which was incorrectly returning an "invalid-symbol" error instead of a string expression.

Calculus​

  • Derivatives: d/dx e^x now correctly simplifies to e^x instead of ln(e) * e^x. The hasSymbolicTranscendental() function now recognizes that transcendentals which simplify to exact rational values (like ln(e) = 1) should not be preserved symbolically.

  • Derivatives: d/dx log(x) now returns 1 / (x * ln(10)) symbolically instead of evaluating to 0.434... / x. Fixed by using substitution instead of function application when applying derivative formulas, which preserves symbolic transcendental constants.

Arithmetic​

  • Rationals: Fixed reducedRational() to properly normalize negative denominators before the early return check. Previously 1/-2 would not canonicalize to -1/2.

  • Arithmetic: Fixed .mul() to preserve logarithms symbolically. Previously multiplying expressions containing Ln or Log would evaluate the logarithm to its numeric value.

Serialization​

  • Serialization: Fixed case inconsistency in toString() output for trigonometric functions. Some functions like Cot were being serialized with capital letters while others like csc were lowercase. All trig functions now consistently serialize in lowercase (e.g., cot(x) instead of Cot(x)).

  • Serialization: Improved display of inverse trig derivatives and similar expressions:

    • Negative exponents like x^(-1/2) now display as 1/sqrt(x) in both LaTeX and ASCII-math output
    • When a sum starts with a negative term and contains a positive constant, the constant is moved to the front (e.g., -x^2 + 1 displays as 1 - x^2) while preserving polynomial ordering (e.g., x^2 - x + 3 stays unchanged)
    • d/dx arcsin(x) now displays as 1/sqrt(1-x^2) instead of (-x^2+1)^(-1/2)
  • Scientific Notation: Fixed normalization of scientific notation for fractional values (e.g., numbers less than 1).

Sum and Product​

  • Compilation: Fixed compilation of Sum and Product expressions.

  • Sum/Product: Fixed sum and prod library functions to correctly handle substitution of index variables.

New Features and Improvements​

Serialization​

  • Number Serialization: Added adaptiveScientific notation mode. When serializing numbers to LaTeX, this mode uses scientific notation but avoids exponents within a configurable range (controlled by avoidExponentsInRange). This provides a balance between readability and precision for numbers across different orders of magnitude.

Type System​

  • Refactored the type parser to use a modular architecture. This allows for better extensibility and maintainability of the type system.

Pattern Matching​

  • Pattern Matching: The validatePattern() function is now exported from the public API. Use it to check patterns for invalid combinations like consecutive sequence wildcards before using them.

Polynomials​

  • Polynomial Arithmetic: Added new library functions for polynomial operations:
    • PolynomialDegree(expr, var) - Get the degree of a polynomial
    • CoefficientList(expr, var) - Get the list of coefficients
    • PolynomialQuotient(dividend, divisor, var) - Polynomial division quotient
    • PolynomialRemainder(dividend, divisor, var) - Polynomial division remainder
    • PolynomialGCD(a, b, var) - Greatest common divisor of polynomials
    • Cancel(expr, var) - Cancel common factors in rational expressions

Calculus​

  • Integration: Significantly expanded symbolic integration capabilities:
    • Polynomial division: Integrals like ∫ x²/(x²+1) dx now correctly divide first, yielding x - arctan(x)
    • Repeated linear roots: ∫ 1/(x-1)² dx = -1/(x-1) and higher powers
    • Derivative pattern recognition: ∫ f'(x)/f(x) dx = ln|f(x)| is now recognized automatically
    • Completing the square: Irreducible quadratics like ∫ 1/(x²+2x+2) dx now yield arctan(x+1)
    • Reduction formulas: ∫ 1/(x²+1)² dx now works using reduction formulas
    • Mixed partial fractions: ∫ 1/((x-1)(x²+1)) dx now decomposes correctly
    • Factor cancellation: ∫ (x+1)/(x²+3x+2) dx simplifies before integrating
    • Inverse hyperbolic: Added ∫ 1/√(x²+1) dx = arcsinh(x) and ∫ 1/√(x²-1) dx = arccosh(x)
    • Arcsec pattern: Added ∫ 1/(x·√(x²-1)) dx = arcsec(x)
    • Trigonometric substitution: Added support for ∫√(a²-x²) dx, ∫√(x²+a²) dx, and ∫√(x²-a²) dx using trig/hyperbolic substitution