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 actionPermutationAction(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 namedoptionsdictionary. A query that reaches a limit returns an error such asError("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)gavemissing | numberandwiden(missing, nan, real)gavemissing | nan | real. Both now givemissing | nan | real:nanbeside another number type is kept as its own arm (widen(real, nan)isnan | real, notnumber, which also admits the complex numbers). A finite type joined with an infinity is stillnumber. The join of+ooand-ooissigned_infinity, notinfinity(which also admits~oo), and a union of+ooand-oono longer drops-oo.[NaN, 1]has the element typeinteger | nanwhether it is built withce.box()orce.list(). - A shader compile refuses a
varsentry that names a variable of the compiled code. Onglslandwgsl,vars: { a: 'k' }beside aBlockthat declaresk(or aSumindex, a loop variable, a function parameter namedk) compiled to code that read the localkinstead 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]andg(p) := h(p) + 1,g([x, y])is anincompatible-typeerror in the interpreter, and compiled to code that gave a value. It now declines on every target. - A recursive function declared
functionis typed as the same function assigned without the declaration. WithG,SandRdeclaredfunctionand then assignedG(a,b,c) = |\sin a| - 0.1,S(a,b,c) = \max(G(a,b,c), c - 3)andR(i) = \{i = 0: 0, R(i-1) + S(1, 2, R(i-1))\},R(3)andR(x)were typedany(with a simplerS, a union of numbers, tuples and lists). They are now typednumber. A recursive list builder declaredfunctionis typedlist<number>, notcollection<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-diagramdocument,B,H,u,vandZeach calledF(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), …)) computedT_3(t)twice on every target; it is now computed once. On the shader targets, a call that reads a uniform passed with a renamedvarsentry (vars: { s: 'u_s' }) can now be shared too. - More shader operands use the built-in
sin,cos,sign,max,minandclampinstead of the NaN-safe helpers: a product whose factors have declared ranges (sin(k t)withk: real<0..10>,t: real<-7..7>), a product with asin,cosortanhfactor, a symbol with an assigned value that is compiled as that value, and avec2tovec4operand whose components are all finite for certain (sin([t, k, 1])witht,kdeclaredreal). A shader that calls onlygcdno longer carries thelcmhelper. - A function that reads a symbol holding a long symbolic expression is
faster to call (ROADMAP CE-566). With
X(l) := C_0[l]andC_0assigned aSumover a chain of lazyMapandUniquecollections, each call ofXderived 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 aWhichwhose later conditions call it), theglslandwgsltargets 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
Rdeclaredfunctionand assigned(V, θ) ↦ [Σ V·[cos θ, -sin θ], …],R([x, y] + [-0.57, 2.8], φ)gaveNaNonjavascriptand an empty interval oninterval-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.javascriptnow gives the interpreter's value,glslandwgslcompile a copy ofRwith avec2parameter, andinterval-jsdeclines instead of giving a wrong interval. - A recursive function inside
Moddeclined oninterval-js.f(x, n) := 1/\operatorname{mod}(f(x, n-1), 1)withf(x, 0) = xdeclined 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)))withf(q) := \operatorname{mod}(q, 1) - 0.5, the readsp[1]typedpas a point, and the compiler passed it tofas a point, whichModminus 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 documentra1p9naofscompiles onjavascriptand 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)withn(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 forD; read outside the scope, the nameNfound the library operatorN. It now has["N", "x", "y"], as the expression parsed without a scope, and asevaluate()andsubs()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 invalidD(…, Error('expected-a-symbol')). It is nowEvaluateAt(Function(D(x^2, x), x), 3), written\left.\left(\frac{\mathrm{d}x^2}{\mathrm{d}x}\right)\right|_{x=3}, which evaluates to6(a float under.N()).- This also applies inside a scoped expression:
Sum(D(x^k, x), k=1..3)withxreplaced by2evaluates to17. Integrate(a·x, x).subs({x: 3, a: 2})evaluates to9, andD(x^2, x).subs({x: x + 1})to2(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})isD(x·x, x), which evaluates to2x. - A wildcard (
_f) is still replaced inside the node. A value with no closed form stays an unevaluatedEvaluateAt. The value of aSeriesat a point keeps itsBigOremainder. A value that keeps aBigOterm isNaNunder.N();Normaldrops the remainder, before or after the substitution, andNormalnow strips it before any numeric evaluation (Normal(Series(sin x, x, 0, 3)).N()wasNaN).
- This also applies inside a scoped expression:
-
EvaluateAtat a point has the value ofApply. The statements of the body run in order, and anIfevaluates 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)is5, 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 lazypthat returns its operand givesRandom()without a draw. - The form stays unevaluated only when a
D, aSeriesor anIntegratewith no closed form comes from the BODY: the identity atSeries(e^{1/x}, x, 0, 3)is that series, andx ↦ xbetween0and 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}is6. EvaluateAtcompiles:EvaluateAt(f, a)as the applicationf(a)andEvaluateAt(f, a, b)asf(b) − f(a), so the result ofsubs()on a derivative compiles to JavaScript.Dof anEvaluateAtevaluates it first: the derivative inyof(x^2)|_{x=y}is2y.
- A lazy callee receives the point as written, and the expression it returns
is not evaluated again:
-
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)isx(the derivativeyaty = xof the caller); it wasx + 3, a product rule mixed with the caller'sx.((x, a) ↦ ∫a·x dx)(x + 1, x)isx(x + 1)²/2; the point was lost. A function-valued parameter named like a user function no longer leaks its name: withw(x) := x + 100,(w ↦ D(w(x), x))(x ↦ 5·w(x))is5, and the same forIntegrateandSeries. -
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 unevaluatedIntegrate(the numeric request sent the integral to the quadrature route, which has no bounds to use). -
An
evaluatehandler 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)is0: 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)isx³/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,Seriesor an indefiniteIntegrateof 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)is2(x + 1), not2x. Under.N(),(x ↦ D(x^2, x))(1 + 2.5)is7, not2x. -
The LaTeX of
EvaluateAtparses back to the sameEvaluateAt.\left.x^2\right|_{x=2}wasSubscript(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 forx:\left.kx\right|_{2}^{5}is3k, while\left.\left(xy\right)\right|_{(1,2)}is2.\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) dxof a Tycho document, the result was± 5.7e-10with a true error of1.6e-5; it is now± 9.3e-10with a true error of2.3e-11. - Far from 0, the rounding of the sample points to the doubles is counted:
∫ sin(10³·(x − a)) dxover[10⁶, 10⁶ + 0.01]wasconverged,± 2.2e-13, for a true error of2.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) dxover[10⁶, 10⁶ + 0.01]). - With
singularEndpoints, a singular point that is not exactly a double is found:∫₋₁¹ |x − 0.3|^(−1/2) dxwas± 3.9e-10for a true error of1.3e-8after 28,480 evaluations; it is now± 4e-13for3e-14after 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)withc = 0.3 + 10⁻⁶wasconverged,± 3.1e-12, for a true error of1e-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-5for a true error of2.3e-5and 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 theirconvergedstatus 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 reportsconvergedwith 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]withc1000 doubles below was± 1.3e-7and is± 1.3e-10for a true error of1e-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 + ε]gaveNaN. An interval one double wide still reports0 ± 0(ROADMAP CE-561).
- 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
-
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 theEnclosurevalue kind (see Features) found them: some answers excluded a true value, others were wider than necessary.modwith a divisor interval that is not a point:mod([5, 5], [2, 4])was[1, 1], which excludesMod(5, 3) = 2andMod(5, 2.5) = 0. It is now the hull[0, 4](asingularresult 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.atanhat the boundary of its domain:atanh([0, 1])waspartial [0, 18.3684…], a clip of the argument to1 − ε. It is nowpartial [0, +∞], andatanh([1, 1])isempty.- 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)andpowRealBranchwith a negative exponent weresingularwith no value (the whole line). They are nowpartialwith the half-line beside the pole:gamma([0, 1])is[1, +∞]andpowRational([-1, 0], -2, 3)is[1, +∞]. A pole inside the interval still givessingular. A point at a pole has no value:gamma([0, 0])andpowRational([0, 0], -1, 3)areempty;tan,cot,secandcscat a point that is their pole staysingular, because the interpreter answers~oothere. powwith a negative integer exponent over a base with a zero endpoint:pow([0, 1], -1)waspartial [−∞, +∞]and is nowpartial [1, +∞];pow([-1, 0], -1)is[−∞, −1], andpow([0, 0], -1)isempty.powIntervalwith a negative base over an exponent interval:powInterval([-2, -2], [1, 2])wasempty, although(−2)^1and(−2)^2exist. It is now[−4, 4], the symmetric hull[−M, M]withM = 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]waspartial [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])isempty). Visible to Tycho's adaptive sampler (its rows 260 and 417): a box withx < 0and 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.powwith a negative integer exponent rounds outward at each step. For the doublex = 1.9678349534779586e-103,pow([x, x], -3)was[1.3123025984121744e308, 1.3123025984121748e308], which excludes the true value (its nearest double is1.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-jstarget multiplied the angle by the double nearest to the factor (Math.PI / 180,2 * Math.PI), which can be below the true factor. Inturnmode, the compiledcos(x)at the point0.25had two positive bounds and excluded the true value0. 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 compiledcosandarcsinnow contain the true value at a quarter turn indeg,gradandturnmode. The other targets keep the float factors. fract([-1e-20, -1e-20])was[1, 1], while the true fractional part is below 1. The subtractionx − 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), +∞](wassingularwith no value), and the same forcsch,acoth,acschandasechat 0 and forgammalnat a non-positive integer (gammaln([0, 1])is[0, +∞]). A pole inside the interval, or a point at the pole, is stillsingular.asech([-1, 0])isempty. modwith 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 degreen, compiled tointerval-js, reads the degree at run time: an integer degree keeps its exact value. Withn = 6,x = [−64, −1]givesempty(was[−2, 2]); withn = 3,[−4, −1](was[−4, 4]); a negative integer degree is the reciprocal root (n = −3,x = −8gives−1/2). Another degree takes the powerx^(1/n)as before.
-
Degreesrefuses a complex number. An angle is real, and the signature ofDegreesis(real) -> real.Degrees(2 + 3i)was(1/90 + i/60)·π; it is now anincompatible-typeerror, asDegrees((2 + 3i) ± 0.1)already was. A symbol declaredcomplexthat holds a real number still converts.NaNand the infinities are not changed. -
The
interval-jsrunner 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 isenclosureor a union with anenclosurearm,run()checks the value BEFORE it converts the inputs: a record{ lo, hi }must havelo <= hi, noNaNand not the same infinity twice, and a number is a point. A union input is checked by arm: withy: number | enclosure,y: Infinityis accepted by the numeric arm. A refused value throws (it is not turned into anentireresult).broadcast()andbroadcastAsync()check every cell before the loop, so one refused cell rejects the whole call. The inputs of every other type are not changed, including theNaNthat marks an absent value of areal | missinginput. -
On
interval-js, a decline of an expression with an enclosure is not replaced by the interpreter, also withfallback: 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 typedenclosure. The other value kinds (Measurement,Quantity) keep the fallback. -
The target of an
Assignoutside aBlockis a free variable. The target is a write to a symbol of the enclosing scope, sounknownsandfreeVariableslist it. Before, anyAssign,DeclareorDefineFunctionoperand 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"], asAssign(k, 2)alone always had. ABlockstill keeps the names it assigns or declares local:(k\coloneq2; kx)has the free variablexonly.If(z > 0, Assign(z, 5), 0)listsz. Thesubs()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-insmoothstep, but the library had no definition: it stayed unevaluated, and thejavascripttarget did not compile it. It now evaluates tot² (3 − 2t)witht = Clamp((x − e0) / (e1 − e0), 0, 1), exactly for exact operands (Smoothstep(0, 1, 1/3)is7/27), and broadcasts over lists. Thejavascriptandpythontargets compile it. Whene0 = e1the result is the step function (0 whenx < e0, else 1), and whene0 > e1the formula applies as written. The shader targets now write the formula out instead of calling the built-insmoothstep, which has no defined result whene0 ≥ 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 setInterval(lo, hi):Abs(Enclosure(-1, 2))isEnclosure(0, 2), whileAbsof the set is its cardinality. It is a library value kind, besideResidueClass,MeasurementandQuantity, with the kind typeenclosure, 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)isEnclosure(1, 2)), reads one operand as a point (Enclosure(3)isEnclosure(3, 3)), and converts a setInterval(Enclosure(Interval(0, 1))isEnclosure(0, 1)). An endpoint that is not an extended real, or is a value of a kind, is anincompatible-typeerror. ANaNendpoint, 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)isEnclosure(1/3, 3),Enclosure(-1, 2) · Enclosure(3, 4)isEnclosure(-4, 8), andEnclosure(1, 2) − Enclosure(1, 2)isEnclosure(-1, 1)(the two operands are independent). A division by an enclosure with a zero endpoint gives a half-line (1 / Enclosure(0, 1)isEnclosure(1, +oo)), and with0inside the whole line. - A numeric function gives its image (the
applyhook), with the critical points of the function:Sin(Enclosure(0, Pi))isEnclosure(0, 1),Sin(Enclosure(0, 1))isEnclosure(0, Sin(1)),Sqrt(Enclosure(-1, 4))isEnclosure(0, 2)(the domain is clipped),Tan(Enclosure(0, Pi/2))isEnclosure(0, +oo), andLn(Enclosure(-2, -1))is indeterminate. Underevaluate()with exact endpoints the endpoints are exact. Under.N(), the endpoints are converted to doubles OUTWARD (the largest double belowlo, the smallest abovehi) and the interval library computes the image:Enclosure(0, Pi).N()isEnclosure(0, 3.1415926535897936), andSin(Enclosure(0, 1)).N()isEnclosure(0, 0.8414709848078966). The endpoints under.N()are machine doubles at everyce.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)isTrue, andEnclosure(0, 2) < Enclosure(1, 3)stays unevaluated. Two enclosures that touch decide only the non-strict comparison:Enclosure(0, 1) <= Enclosure(1, 2)isTrueand>isFalse, while<stays unevaluated.Enclosure(1, 1) = 1isTrue; two enclosures that overlap may hold the same real, soEqualstays unevaluated. Max,Min,Clamp,ElementMaxandElementMingive the hull of the extrema:Max(Enclosure(0, 2), Enclosure(1, 3))isEnclosure(1, 3), andMax(Enclosure(0, 1), +oo)is+oo.Sum,ProductandMeanof a list of enclosures and numbers fold through the hooks:Mean([Enclosure(0, 1), Enclosure(1, 2), 3])isEnclosure(4/3, 2). The other statistics stay unevaluated.- The code compiled for the
interval-jstarget computes with an enclosure: an input typedenclosureis 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. Withxdeclaredenclosure,compile(ce.parse('x + 1'), { to: 'interval-js' })gives{ kind: 'interval', value: { lo: 1, hi: 2 } }forrun({ x: { lo: 0, hi: 1 } }). The other targets decline an enclosure with the message of the kind. Measurement(Enclosure(0, 1), 0.1)andQuantity(Enclosure(0, 1), m)areincompatible-typeerrors.
The rules are in
doc/06-guide-augmenting.md, "Enclosures"; the design is section 4.9.2 ofdocs/plans/2026-10-26-value-kinds.md. - The canonical form orders the endpoints (
-
Two extensions of
ArithmeticDefinitionfor a value kind that you declare.- The optional
extremum(which, ops, context)hook gives the maximum (whichis'max') or the minimum of operands of which one at least is a value of the kind, forMax,Min,Clamp,ElementMaxandElementMin. The engine asks it before it orders the operands withcompare, so a kind whose values can overlap can answer a value that is not one of the operands.undefineddeclines, and the engine orders the operands withcompareas before. comparecan 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,LessEqualisTrueandGreaterisFalse,LessandGreaterEqualstay unevaluated, andEqualasks theeqhandlers. A reader that needs a total order (Sort, andMax/Minwithout anextremumhook) reads it as no answer.
- The optional
Performance
-
The shader targets call the built-in
sin,cos,sign,maxandmin, and writeclampasmin(max(x, lo), hi), for an operand that is finite for certain. The NaN-safe helpers that replaced them (_gpu_sinand 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 declaredrealholds a finite value), it reads no name that the caller maps withfunctionsoroperatorsand no name thatvarsmaps 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 thatvarsmaps 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)andcos(T + 1.9k)with declared real symbols are now the built-ins, also withvars: { T: 'T' };sin(u)withu: 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,Integrateand 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 withD(PointY(S(u)), u), whereSis a user function with a large body, typedS(u)again after eachD; the replay of the canonical rebinds of the document2ki2hjsoufwent 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 nodex + 1computed duringf(3)(positive) was still served after the call returned, wherexhas 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
Blockthat declares a name and calls a function that reads the same name no longer prints "error canonicalizingBlock: Cannot read properties of undefined (reading 'parent')", and the function's derived signature is correct (ROADMAP CE-542). Example:bis declaredreal,fis declared(real) -> unknownand assignedp ↦ sin(b·p), andBlock(Declare(b), Assign(b, 1), f(0.5))is boxed. The call reads the signature off, which is derived by boxingf's body again in the scope wherefis defined. That boxing read the names of the block being canonicalized, so the freeboffwas taken for the block's local: the signature offbecame(real) -> numberinstead of(real) -> real, and when the block was boxed in a scope fromce.createScope()the name resolution walked past the root scope and threw. The value was not affected:freads thebof 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 documentg(x) with b = 1, wheregcallsf(x) = sin(b·x). -
On Apple GPUs, the complex shader helpers gave a finite value for a NaN part. The built-ins
sinandcosof a NaN give 0 on WebGL2 over ANGLE Metal and on WebGPU over Metal, soexp(1 + NaN·i)was(0, 0)on both shader targets, and so weresin,cos,sinhandcoshof 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_csqrtand_gpu_cln) now test each part first and give NaN, as the interpreter and thejavascripttarget do. -
A function body over a value kind keeps
x/xin 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 thatx/xis not folded to1before a kind value arrives. This did not happen with an overloaded signature: withg: ((residue) -> residue) & ((integer) -> integer)and the bodyx ↦ x/x,g(ResidueClass(2, 5))was1and is nowResidueClass(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). WithE = [0.1, 0.5],∫_0^1 y·When(∫_{−1}^{1} 1/ElementMax(E, |x|) dx, 0 ≤ y ≤ 5) dygaveNaN, and∫_0^1 \{0 ≤ y ≤ 5: 1/ElementMax(E, y+1)\} dystayed unevaluated. Both now give a list, here[1 + ln 10, 1 + ln 2]. AWhen(L, c)or aWhichwith 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 dyof Tycho's documentneyret/2agcgxq87y, wherenhas a jump and kinks, took about 25 s underN()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
Nothingbranch is no longer wrong.∫_0^1 Which(y < 1/2, y, True, Nothing) dygave5/8, because the antiderivative ofNothingwas taken as that of a constant. It now stays unevaluated, andN()givesNaN, as for aWhichwith no default clause.N()of∫_0^1 Nothing dxisNaN, as forMissing. -
A list combined with a piecewise evaluates to a list (Tycho row 452).
[0, 1, 2, 3]·Which(x > 1, 0, True, 1)withxfree evaluated toWhich(1 < x, [0, 0, 0, 0], True, [0, 1, 2, 3]), which is not aList. It now evaluates to[Which(1 < x, 0, True, 0), Which(1 < x, 0, True, 1), …], the same values at eachx. The same applies underAdd,Absand a boundedSum, and to aWhichor anIfwhose values are lists of one length, when the condition is undecided. AWhichwith no default clause is not changed. When such aWhichis combined with an operand, the result now has a last branch for "no condition holds":3 + Which(x > 1, 0)isWhich(1 < x, 3, True, NaN), the value that substitutingxfirst 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 aSum, the parameter of aFunctionand aBlocklocal becameErrornodes. A binder now keeps its bound variables:j + Sum(j, Limits(j, 1, 3))with{j: 10}gives10 + Sum(j, Limits(j, 1, 3)). The occurrences replaced are exactly those thatfreeVariablesreports, so the bounds of aLimitsclause, 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}givesSum(2k, …); rename the index first to avoid this. -
On
wgslandglsl, 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'svoronoi-diagramdrew 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,sqrtis computed 16 times per pixel (was 560) andsin32 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
MaxorMinis no longer typedany(Tycho row 468, a regression in 0.170.0). WithS(a, b) = Max(a, b)andR(i) = Which(i = 0, 0, True, R(i − 1) + S(1, R(i − 1))),R(3)was typedany. The type of a call whose body isMaxorMinover numbers and tuples is nownumberon its tuple arm, sinceMaxandMinread the coordinates of a tuple (Max(1, (2, 3))is 3), soR(3)gets the same type asRwithout the call toS. -
Compiling a
Sumor aProductwhose bounds are above 2^53 no longer hangs.compile(\sum_{k=2^{53}}^{2^{53}+2} \sin(x+k))never returned onjavascript,glsl,wgslandinterval-js, because at 2^53 the indexk + 1iskagain. The compile now declines without-of-range, the answer the interpreter gives for the same bounds. -
Reading a chain of lazy
Map/Zipcollections through index lists is linear in the depth of the chain (Tycho row 453). A read such asC[1]ofMap(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 aReferenceErrorinstead of answeringentire. AReferenceErrormeans that the compiled code names something that is not defined, for example a helper of an operator'scompileHelpersthat reads a variable from outside its own body. ThecompileHelpersdocumentation says that such a read throws on every thread, and thejavascripttarget already threw. The function thatcreateIntervalRuntime().load()returns for a stored result does the same. Other run-time errors still answerentire("cannot bound"). A helper ofcompileHelpersis defined forrun()oninterval-js, which is now tested. -
On
wgsl, a piecewise branch that draws a random value compiles to anifstatement 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 onglsl.\operatorname{WithRandomSeed}(s, \{x<0: \operatorname{Random}(), 0\})compiles, and so does1 +it. Other shapes still decline, because a hoistedifruns ahead of the rest of its statement and could change the order of the draws. 0.170.0 already used theifform, 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 onwgsldescribes the release before.) -
On
glslandwgsl, 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 aSumorProducthoists its loop or its terms the same way. So(\operatorname{Random}(), \cot(\operatorname{Random}()))drew the operand of\cotfirst, and(\operatorname{Random}(), \sum_{k=1}^{n} \operatorname{Random}())drew the loop first, unlike the interpreter and thejavascripttarget. 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, aSumthat is written first) still compiles. -
An interval value for interval arithmetic (#416): the
Enclosurevalue kind (see Features). With this third phase, the value kinds of #477 are complete: the mechanism andResidueClass(phase 1),MeasurementandQuantity(phase 2), andEnclosure(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), therequireconditions of theexportsmap and theComputeEnginescript-tag global are gone. A CommonJS consumer keeps callingrequire('@cortex-js/compute-engine'): Node loads the ESM entry throughrequire()since Node 22.12 (and 20.19), and no entry has a top-levelawait.engines.nodeis>=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/corehas thejavascriptandinterval-jscompilation targets. The GPU targets (glsl,wgsl) and the source-onlypythontarget are no longer bundled in that entry (together 408 KB minified). To use one there, import the target class from@cortex-js/compute-engine/compileand register it:ce.registerCompilationTarget('glsl', new GLSLTarget()). Once the full entry,…/epsilor theepsilCLI is loaded anywhere in the program, every engine has all five targets, an engine made earlier from…/coreincluded. 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
runtimeandkernel-workeris now built in one code-splitting invocation, so the engine and the modules it shares with…/numerics,…/latex-syntaxand…/intervalare emitted once into shared chunks. A host that imported…/coreand…/compileloaded two copies of the engine (compile.jswas 3.5 MB on its own), and aNumericValuemade from…/numericsfailed the engine'sinstanceofchecks. One effect: aJavaScriptTargetimported from…/compilecompiledn(n-1)(n-2)/6, boxed by the main entry, to0.16666666666666666 * …(559.9999999999999atn = 16); it is now(…) / 6, which gives560(#480, reported by enumeratio).runtimeandkernel-workerstay single self-contained files. ce.registerCompilationTarget(name, target)andce.unregisterCompilationTarget(name)are public. The compile guide documented the first under that name, but only the internal_registerCompilationTargetexisted.
Behavior Changes
- The code that a caller's operator
compilehandler 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 exampleglobalThis.myFloorIndex(…). The result hadshippable: true, sobroadcastAsync()sent it to the workers, where every cell threw: each value wasNaN, and the batch still reported every cell as done. Now a result with code from a caller handler hasshippable: falseand 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, setcompileShippable: trueon the definition, and give the functions that the code calls incompileHelpers(see Features). - A point parameter accepts a list that can hold both points and lists. With
k: (tuple<number, number>) -> numberandydeclaredlist<P | list<P>>,type A = list<A | P>ortype B = list<B> | P(Pistuple<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 anincompatible-typeerror (the compiled call givesNaN), and the type of the call includeserrorfor it. - A point parameter accepts an empty list.
k([])is typedlist<number>(it wasnumber, for the value[]), and a symbol declaredlist<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 typedtuple<number, number>, whileL := [[1, 2]]gives[(6, 8), (12, 16)]; it is nowtuple<number, number> | list<tuple<number, number> | list<any>>. Withb: broadcastable<number>,(x, y)·band(x, y)/barebroadcastable<tuple<number, number>>, and(x, y) + bisbroadcastable<error>. A list of points beside a collection pairs each point with an element:p·twitht: tensor<integer>islist<broadcastable<tuple<…>>>;p·vwithv: vector<integer^2>islist<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, andInverse,MatrixMultiply,Dot,LinearSolveand the other functions whose parameter is a matrix read each range as a row; they refused the list withincompatible-type. The list keeps itslist<range>type, so[1..2, 3..4]^{-1}and\cdotstill 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>>andy: 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
unknownholds a ragged list or a point (CE-502). WithL: list<unknown>,2·L[1]was typedbroadcastable<number>, which does not hold[[1], [2, 3]]or(1, 2)inL[1]; it is nowlist<list<any> | number | tuple> | number | tuple. The same holds fork(L[1])withk: (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 inferrednumber. - 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 typedtuple<number, number>and iserror; withg := x ↦ x + 1,g("s")wasnumberand iserror. - 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) -> neverand 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-pointserror at evaluation, as it already was when boxed (CE-491). Withpd := 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 to2x + ResidueClass(1, 5)(it was returned as written; onlysimplify()combined it), and(√2 + π + ResidueClass(1, 5)).N()is4.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.Multiplyis(number*) -> numberagain (it was((number | residue)*) -> number), andDivide,Negate,Power,Root,Sqrt,SquareandSubtractlost theirresiduearm 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 anincompatible-typeerror of these operators no longer namesresidueas an expected type. An engine built without theResidueClassdefinition no longer accepts aresidueoperand at these operators. - Two values of a kind written next to each other are a product, as two
residue classes are: with
GF8a head of the kind typegf8,\operatorname{GF8}(3)\operatorname{GF8}(5)parses to aMultiply, not to aTuple. A list of such values is typedlist<gf8^2>(it waslist<gf8>). Sum,ProductandReduceover residue classes are typedresidue.Sum([c1, c2])was typednumberandMap(Negate, [c1, c2])vector<2>; they areresidueandlist<residue^2>.- A measurement is typed
measurement, and a quantity is typedquantity(CE-507).Measurement(2.5, 0.1)was typed with the type of its nominal (real;integer,rationalorcomplexfor other nominals), andQuantity(2, m)was typedvalue.measurementandquantityare new primitive types of the type language. They are kind types: each one is a subtype ofvalueand is disjoint from the number types, soce.type('measurement').matches('real')isfalse. A host that compares type strings sees the change, and a host that declared a type namedmeasurementorquantitymust rename it. The result type of an operator with a measurement operand changes in the same way:Floor(2.5 ± 0.1)was typedintegerand ismeasurement | number(a propagated uncertainty of 0 gives a plain number),2 + (2.5 ± 0.1)ismeasurement, andMax(2 ± 0.1, 2)isinteger<2..2> | measurement. - A symbol or a function with a body that is declared over a number type
refuses a measurement. With
s: real(ornumber,integer),ce.assign('s', Measurement(2, 0.1))throws; it assigned the measurement. Withf: (real) -> realandf := x ↦ x^2,f(2 ± 0.1)is anincompatible-typeerror; it was4 ± 0.4, from a body that the canonical form could have changed (x/xis1for arealparameter). Declare the parameterreal | measurementto accept one. An operator of your own declared overrealwith anevaluatehandler (h) now leavesh(5 ± 0.1)unevaluated: the measurement does not know the derivative ofh, so it does not call the handler (it gave2 · (5 ± 0.1)forevaluate: ([x]) => x.mul(2)). An operator that handles a measurement itself namesmeasurementin 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 avalueparameter (UnitConvert,List), the 24 trigonometric functions andAbsaccept a quantity. Every other operator gives anincompatible-typeerror at boxing:Round(2.7 m),Floor(2 m),Sign(2 m),Exp(2 m),Hypot(2 m, 3)andInverseHaversine(2 m)stayed unevaluated or gave a wrong value, andMean([2 m, 4 m])stayed unevaluated. - The trigonometric functions and
Absnamequantityin their signatures. The 24 trigonometric, hyperbolic and inverse functions (SintoArcsch) are(complex | quantity) -> number(a function that acceptedinfinityorsigned_infinitykeeps that arm), andAbsis(complex | infinity | quantity) -> number. SoArctan(2 deg)folds the angle toArctan(π/90)as the other functions do (it stayedArctan(2 deg)),Abs(-2 m)is2 m, typedquantity(it stayed unevaluated, typedvalue), andSin(2 m)is typednumber(it wasvalue) and stays unevaluated. Theincompatible-typeerror of these functions names thequantityarm as an expected type (Sin("a")expectscomplex | quantity), and so does the type ofDerivative(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)is1, 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)is2. When a jump of the function lies within one σ of the nominal, the call stays unevaluated:Floor(2.95 ± 0.1)was2. A complex nominal atSignstays unevaluated. ModandFractpropagate with the slope between their jumps:Mod(5 ± 0.1, 3)is2 ± 0.1(it was2), andMod(7, 3 ± 0.1)is1 ± 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)was0,Arcsin(1 ± 0.1)wasπ/2;Ln(0 ± 0.1),Gamma(0 ± 0.1)andTan(π/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)is1.5668i ± 0.0436(it was the bare1.5668i), andLn(-1 ± 0.1)isπi ± 0.1. Hypot,Degrees,Haversine,InverseHaversine,Factorial,Logwith an uncertain base,Arctan2,Rootwith an uncertain index,Normwith an uncertain order,Roundwith an uncertain step,PreIncrement,PreDecrement, and the multilinear operatorsDot,Cross,MatrixMultiply,HadamardProductandTracepropagate every uncertain operand.Dot([2.5 ± 0.1, 2, 3], [1, 2, 3])is15.5 ± 0.1.Binomial,Choose,Pochhammer,Determinant,Inverse,Eigenvalues,ContinuedFraction,ComplexRoots,Arg,ArgumentandBaseFormstay unevaluated with a measurement operand, and so does an operator whose derivative the engine does not know (Zeta,Beta, the order ofBesselJ).Binomial(5 ± 0.1, 2)was(5 ± 0.1)·((5 ± 0.1) − 1)/2andDeterminant([[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,StandardDeviationandPopulationStandardDeviationof 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,ConjugateandAbskeep the uncertainty of the operand:Abs(0 ± 0.1)is0 ± 0.1.
- A step function (
- A comparison of measurements uses the nominals. The uncertainties take no
part:
1 ± 0.1 = 1 ± 0.5isTrue, as before.MaxandMindecide by nominal and keep the first operand of a tie:Max(1 ± 0.1, 1 ± 0.5)is1 ± 0.1(it stayed unevaluated), andMax(2 ± 0.1, 2)is2 ± 0.1(it was2).Clamp(x, lo, hi)returnsxwhenxequals a bound:Clamp(3 ± 0.1, 0, 3)is3 ± 0.1(it was3).ElementMaxandElementMincompare asMaxandMindo.- A measurement with a complex nominal has no order:
(2 + 3i) ± 0.1 < 3isFalse, as2 + 3i < 3is, andMaxof it stays unevaluated. Its equality is the equality of the nominal:Equal((2 + 3i) ± 0.1, 2 + 3i)isTrue. - A measurement is not exact:
Max(3/2 ± 1/10, Indeterminate)isNaN, notIndeterminate.
- A comparison of quantities converts to the finer unit and uses the engine
tolerance there, also in
Max,Min,Clamp,ElementMax,ElementMinandSort.Max(1 m, 1.000000000001 m)is1 m: the extrema compared with a tolerance of zero. The tolerance applies in the finer of the two units, soEqual(1 m, 100.0000000001 cm)staysFalse. A quantity with a magnitude that is a float is not exact:Max(1.5 m, Indeterminate)isNaN. - The methods
.add(),.mul(),.div(),.pow(),.sub()and.sqrt()combine measurements and quantities as the evaluation does.M.add(M2)forM = 3 ± 0.1andM2 = 2 ± 0.1is5 ± 0.141(it was the sum(3 ± 0.1) + (2 ± 0.1)), and(2 m).add(30 cm)is2.3 m(it was30 cm + 2 m)..neg()does not call thenegatehook of a kind:c.neg()isNegate(c), andNegate(c).evaluate()negates the value. .reand.imof a measurement are the parts of its nominal, as before:Measurement(2.5, 0.1).reis2.5. The numeric facts of a measurement (isPositive,sgn,isInteger) areundefined; they were the facts of the nominal (Measurement(4, 0.1).isIntegerwastrue). 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
Mare the same measurement: withM = 1 ± 0.1,Sin(M)^2 + Cos(M)^2simplifies to1 ± 0.13(it simplified to1), andM − Mis0 ± 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
SpeedOfLightand 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
compilehandler can give the functions its code calls (ROADMAP CE-518). The newcompileHelpersfield of an operator definition maps a name to a function. The engine copies the source of each function into the compiled code, asconst <name> = (<source>);, so the code carries its helpers to a worker of the pool and to a stored result. WithcompileShippable: 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);hostNamesthere now names only the functions of the host that the code does not carry. -
broadcastAsync()reports the cells that threw. The newfailedfield ofBroadcastAsyncResultcounts the cells, among the firstdone, whose evaluation threw, on the calling thread and on the workers. The value of such a cell isNaN, as for a cell whose value is not a number, so this count is the only way to tell the two apart.donekeeps 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
MyModdeclared(integer, integer) -> residue,x + MyMod(2, 4) + MyMod(1, 4)andMyMod(2, 4) - MyMod(2, 4)stayed as they were: onlyResidueClassvalues were combined, and no definition could give the arithmetic of another head. The newarithmeticfield of an operator definition gives it:- The field (
ArithmeticDefinition) has the hooksaddandmultiply(they answer folds,KindFold: the operands they combined and the value they make),negate,divide,power(also called forSqrt,RootandSquare),compare(one order for the comparisons,Max,Min,Sort,Clampand the methodsisEqual(),isLess(), …),apply(called in place of the handler of another operator that accepted the value as a number), and the fieldsadmittedAs,resultTypeandcompile. The new public types areArithmeticDefinition,KindOperands,KindOperand,KindFold,ArithmeticContextandArithmeticCompile. WithMyModdeclared withadd,multiplyandnegate,x + MyMod(2, 4) + MyMod(1, 4)isx + MyMod(3, 4), andMyMod(2, 4) - MyMod(2, 4)isMyMod(0, 4), inevaluate()andsimplify(). - The result type of an operator with an
arithmeticfield must be a kind type:residue, or a nominal type defined as exactlyvalueand declared with{ mint: false }, such asce.declareType('gf8', 'value', { mint: false }). Such a nominal type is a subtype ofvalueandexpression, and is disjoint from every other type, asresidueis: 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
mintoption ofce.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 areal(or anumber) accept the values, andapplythen computes the result: a measurement kind can propagate its error throughSinandFloor.admittedAs: 'none'(the default, and the rule ofresidue) admits the values at the arithmetic operators only.compilegives 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 newCompilationResult.valueKinds, and the bindings of a kind type inStoredEntryPlan.kinds; it is returned without the number conversion ofrun(), andcreateJavaScriptRuntime().load()refuses it, because the run-time check of a kind is a function of the host. Withoutcompile, an expression with a value of a kind declines, as a residue class does.
ResidueClassnow declares its arithmetic with this field. The design, which also coversQuantity,Measurementand an interval value (#416) in later phases, isdocs/plans/2026-10-26-value-kinds.md. The guide isdoc/06-guide-augmenting.md, "Declaring a Value Kind"; the type rules are indoc/08-guide-types.md, "Kind Types". - The field (
-
MeasurementandQuantityare 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 theirarithmeticfield, and the operators no longer test the namesMeasurementandQuantity. The visible changes are listed under Behavior Changes and Resolved Issues.Measurementhas the kind typemeasurementand is accepted where a number is accepted (admittedAs: 'number'). An operator that accepted the measurement as a number calls theapplyhook of the kind, which computes the value at the nominal and propagates the uncertainty.Quantityhas the kind typequantityand is accepted only by the arithmetic operators, the comparisons, the operators that namequantityand the operators with avalueparameter (admittedAs: 'none'). Itsaddhook 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 degCis15 K, and20 degC + 5 degCis571.3 K, as before.ArithmeticDefinition.comparecan answer'unordered': the two values are known and have no order (a measurement with a complex nominal). The order operators then answerFalse,EqualandNotEqualask theeqhandlers, andMax,MinandSortstay unevaluated.ArithmeticDefinition.isExact(value)says whether a value of the kind is exact, for the rule that an inexact operand turnsIndeterminateintoNaN. Absent: the values are exact.ArithmeticDefinition.isNaN(value)says whether a value of the kind stands for no value, as the numberNaNdoes (a measurement with aNaNnominal, a quantity with aNaNmagnitude). Such a value follows the rule ofNaN:Max,Min,ElementMax,ElementMinandClampgiveNaN, the order operators giveFalse, andEqualgivesFalse, also for the value and itself. Absent: no value of the kind isNaN.KindOperands.writtenholds the operands as they were written on every route: undersimplify(), the operands before their simplification. So a kind whosenegategives a value and whoseaddreadsNegate(b)inwrittengives the same difference underevaluate(),.N()andsimplify().ArithmeticDefinition.numericValue(value)gives the number that a value of the kind stands for.re,im,bignumRe,bignumImandvalueOf()of the value read it. The fact queries (isPositive,sgn, …) do not.ArithmeticContext.tolerancegives thecomparehook the tolerance of.is(): thetoleranceargument of the call, orengine.tolerance. SoM.is(2.6, 0.2)forM = 2.5 ± 0.1uses the tolerance0.2. It isundefinedfor the other comparisons.
The rules are in
doc/06-guide-augmenting.md, "Measurements and Quantities"; the decisions are in section 13 ofdocs/plans/2026-10-26-value-kinds.md. -
factor()acceptsvariableandgeneratoroptions, and a newfactorDetailed()returns a structured result.factor(p, { variable, generator })factors over an explicit number field, asFactor(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; withstatistics: trueit also returns the work counts of each step. Both are exported from the main and thecoreentry 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^nmonomials 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
PointListcompiles to a list (CE-514, Tycho row 447). On thejavascripttarget,(v ↦ PointList(PointY(v), PointX(v)))([(1, 2, 3), (4, 5, 6)])compiled to[NaN, NaN]; it now gives[[2, 1], [5, 4]], asevaluate()does. The same holds for a named function called with a list of points. APointX/PointY/PointZcomponent 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 listLof two points with integer coordinates is alist<tuple<integer, integer>^2>, where it wastuple<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,tanhcalls a helper that answerssign(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.Cothis1.0 / tanh(x)on both targets: the quotientcosh(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,Modstays in the range of the divisor. The built-inmodgavemod(1e8, 3.0) = -2andmod(-1e6, 1000.0) = 1000on WebGL2 over ANGLE Metal; they are now 1 and 0. On both targetsMod(x, 1)isfract(fract(x)), so a tiny negativexgives 0 instead of 1.0. - On both targets,
Modcalls a_gpu_fmodhelper, which receives each operand once. Onwgslthe 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 anifstatement.selectcomputes both arms: withg(x) = \sum_{i=1}^{300}\cos(ix),\{x<0 : g(x), 0\}took 5.05 ms on WebGPU against 2.36 ms with anif.
- On
sin,cosandtanof a quotientπ·u / dcompile to the exact half-turn forms (sinpi,cospi,tanpi) on every target, asπ·ualready did.\sin(2\pi s / P)ats = 2,P = -2is now exactly 0 in the compiled code; it was2.4e-16in JavaScript, so a test\sin(…) > 0was true in the compiled code and false in the interpreter.e^{i\pi u/d}and the hyperbolic functions ofi\pi u/dtake the exact forms too.- On
wgsl, a piecewise branch that draws a random value no longer compiles (it is evaluated by the interpreter instead). WGSLselectcomputes 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.glslis unchanged: its?:evaluates only the branch that is taken. - On
glsl,Modof a scalar by a vector compiles (Mod(1, v)broadcasts the scalar); the built-inmodhas no such overload, and it was refused. - On
glslandwgsl,Sin,Cos,Sec,Csc,Cot,Sign,SmoothstepandHeavisidegive NaN when the operand is NaN (CE-520), as thejavascripttarget does. On Apple GPUs the built-inssin,cos,signandsmoothstepof a NaN gave 0 (so\sec xgave +∞), and the GLSLHeavisidegave 0.5. They now call helpers that test the bit pattern of the operand, asMaxandMindo (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
glslandwgsl,Max,Min,Clamp,ElementMaxandElementMingive NaN when an operand is NaN (CE-519, Tycho row 465), as the interpreter and thejavascripttarget do. The shader built-ins return the other operand on Apple GPUs, so\max(0, \sqrt{1 - x^2})atx = 2was 0. The targets now call helpers that test the bit pattern of each operand (a testa != awas removed by the compiler on WebGPU over Metal) and return the NaN operand itself.Clamp(x, lo, hi)withlo > hinow giveshionglslas on the other targets (the built-inclampleft it undefined). A vectorClampwith scalar bounds, and anElementMaxorElementMinof a vector and a scalar in any position, now compile on both targets: the scalar is broadcast, as in the interpreter. They were refused onwgsl, and onglslwhen 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 fromLoop(…, Element(i, Range(1, 3)))printed anincompatible-typeerror, withiread as the imaginary unit; it now printsReplaceAt([1,2,3], i, 9). - An undeclared recursive pair that builds a list is typed
-> list<number>in every install order (CE-38). AssigningG := (n, K) ↦ F(n, K)beforeFgave-> list<any>. - A symbol declared
list<residue>is accepted by the arithmetic operators (CE-507). WithL: list<residue>,L + L,-LandL · 2wereincompatible-typeerrors when boxed, while a list literal of classes was accepted. They are typedlist<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 to1before 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)], asce.box()andce.parse()of the same expression do. - Measurements and quantities: wrong values and wrong types (CE-508, fixed
by making
MeasurementandQuantityvalue kinds, CE-507).Quantity(2, m) − Quantity(2, m)simplifies to0 m. It simplified to the number0, without the unit.Quantity(2, m) + Quantity(30, cm)simplifies to2.3 m, andQuantity(2, m) · Quantity(3, s)to6 m·s.simplify()returned them unchanged.x + Quantity(2, m) + Quantity(3, m)evaluates tox + 5 m. It stayed unchanged, because one operand was not a quantity.Measurement(2, 0.1) − Measurement(2, 0.1)simplifies toMeasurement(0, 0.141…), as it evaluates. It simplified to0.ResidueClass(2, 5) + Quantity(3, m)is typedquantity | residue. It was typedlist<list<any> | residue> | residue.- A comparison that is not decided keeps the measurement:
Less(Measurement(2, 0.1), x)evaluates to itself. It evaluated toLess(2, x), without the uncertainty, andEqualdid the same. - A measurement with a complex nominal at an operator whose parameter is real
is an
incompatible-typeerror at evaluation:ContinuedFraction((2 + 3i) ± 0.1)was[2], andFloor,Mod,Hypot,Degreesand 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 to2·arcsin(√2·√m). A quantity at an operator that does not accept it is now anincompatible-typeerror at boxing. Measurement(2, 0.1).isLess(3)istrueand.isEqual(2)istrue. The methods answeredundefined, whileLessandEqualansweredTrue.- 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 was1, 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)isi ± 0.1414. The two contributions cancelled, and the result was the barei. Power(-2, Measurement(2, 0.1))is4 ± 1.3, propagated with the principal logarithm of the base. It was the exact4, without an uncertainty. An uncertain exponent of the base0leaves 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 ofdictionary<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 matchesdictionary<integer>. A read by a key known only at evaluation on an open record is typedany. - A declared type with
unknownat a nested position refines from each assignment (CE-3.5):t: tuple<unknown, integer> := ("x", 2)wastuple<unknown, integer>and istuple<string, integer>. - The static type of a block local no longer depends on statement order
(CE-43):
mutate(v); v = [1.5, 2]typedvaslist<real>; it isvector<real^2>, as in the other order. An assignment inside amatchcase or awhile letbody widens the local:let xs = [1,2,3]; while let [h, ...] = xs { xs = Rest(xs) }; xswas typedvector<integer^3>for the value[], and islist<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))withh := x ↦ 2xwasanyand istuple<number, number>. A tuple with a tuple or string component isany;h(((1, 2), 3))was typednumberfor the value((2, 4), 6). Mapover elements whose depth the type does not state admits nested lists:Map(k, L)withk: (real) -> realandL: list<unknown>(orlist<any>,list<list<unknown>>) was typedlist<real>, which did not hold a ragged or nested element, and islist<indexed_collection<any> | real>. An operand typedanyat 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)withk: (tuple<number, number>) -> numberandxof a nominallist<tuple<…>>type was typedlist<number>and iserror, 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)wasmissing | stringand ismissing;M[7][1]over a matrix wasinteger | nanand isnan. Arithmetic over a proved-absent row or point isMissing. - A local declared
anykeeps its type (CE-444), and an operand typedanyat a generic collection parameter no longer fixes the element type from the other operands:ReplaceAt(ys, 2, "z")withys: anywaslist<string>and islist<any>. - An absent value of a symbol declared
anygivesNaNin arithmetic on every route: withp: anyassignedMissing,p + 1wasMissingand isNaN, assubsand compiled code already gave.
Resolved Issues
-
subs()on a structural expression that is a lone symbol keeps a constant name in the replacement. Withxdeclaredreal, the structuralxwithxreplaced by the structuralt + igavet + (0 + 1i)in 0.168.0 to 0.169.1, so a sum with an indexiover an inlinedf(x) = xgave complex values: withX = [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 callskeep. -
A
Map,FilterorReducecallback follows a reassignment of its source. WithL := [1, 2]andm = Map(x ↦ x + 1, L), a laterL := [["a"]]madem.evaluate()a list ofincompatible-typeerrors; it now gives the value that a freshMapgives. -
Range(n)withn < 1counts down from 1 on every route. A rawRange(0)and the compiled JavaScriptRange(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 to3·2·4·…·2·nor48·…·n; it now stays3·(2·4·…·2n). The same holds in a sum:x + 3(2·4·…·2n)wasx + 48·(…·n), and like terms still collect ((2·4·…·2n) + (2·4·…·2n)is2(2·4·…·2n)). -
The type of
Negate,Sqrtand the other element-wise functions of a list of range rows holds its value.Negate([1..3, 4..6])was typedlist<range>for a matrix of integers; it is nowlist<list<integer>>.Sum([1..2, 3..4])is typedinteger | list<integer>(it wasnumber, 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 beforef := (n, K) ↦ n + Kwas-> unknownand is-> number. -
A polymorphic callback over rows (CE-30):
Map(k, [[x ↦ x + 1]])withk: ((integer) -> T) -> Twasmatrix<unknown^(1x1)>and ismatrix<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
referencesandreferencedFunctionslist 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. Afterce.assign('a', 5),g(x) := x + areferencesa(it gave[]). Oncefis defined byf(x) := …, byce.assign('f', …)or declared with{ signature: … },g(x) := f(x) + areferencesaandf(it gave["a"]), andreferencedFunctionsis["f"]. A user function passed as an operand is referenced too:Map(L, f)referencesLandf. The names of a library (Add,Sin,Pi, …: the standard library, a library given to the constructor, or one loaded withce.loadLibrary()) and constants are still left out.unknownsandfreeVariablesare unchanged: they list only the symbols with no value.
Resolved Issues
subs()on a structural expression keeps a constant name in the replacement. Withxdeclaredreal, the structuralx + 1withxreplaced by the structuralX + igaveX + (0 + 1i) + 1in 0.168.0 and 0.169.0: a declared symbol of a structural expression is bound, and its replacement was boxed canonically. It givesX + i + 1again, in the structural form. An undeclaredxwas not affected.- A function that reads a function declared with an
unknownresult compiles. Withfdeclared(real | signed_infinity | nan) -> unknownand assignedx ↦ x + A cos(x) + d, the compile of a function whose body readsfas 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 typece.assign()accepted. A later change of the type of a symbol thatfreads (Aassigned2i) 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)], andIndexOf,Unique,Tally,UnionandContainstreat the two as one.Range(1, 3),Range(1, 3, 1)and[1, 2, 3]are the same forisSameand 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.Equalis 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]), andTuple(Range(1, 3), 4)to(Range(1, 3), 4), so the value agrees with the typetuple<range, integer>. A symbol declaredlist<range>holds[Range(1, 3)](it held[[1, 2, 3]], which is not a list of ranges). The same applies to aSetelement, 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, andLength,At,Flatten,SumandMapgive the same values. Other lazy elements, such as aMapor a comprehension, are still listed. LUDecomposition,QRDecompositionandCholeskyDecompositionof a matrix of exact rational entries give exact factors underevaluate(). The LU of[[4, 3], [6, 3]]is([[0,1],[1,0]], [[1,0],[2/3,1]], [[6,3],[0,1]])(the lower factor held0.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 underevaluate()(it gave floats);.N()decomposes it. The result types state the exact cells:rationalfor the LU factors,realfor QR and Cholesky.- The
Rfactor ofQRDecompositionhas a non-negative diagonal on the exact and the float route. The float route gaveR[0][0] = -5for[[3, 4], [4, 3]], withQnegated to match; it now gives5. CholeskyDecompositionof a matrix that is not symmetric is the errorexpected-symmetric-matrix. It read only the lower triangle, so[[4, 0], [2, 3]]gave a factorLwithL·Lᵀ = [[4, 2], [2, 3]]. A float matrix that is symmetric within rounding error is accepted.Inverse,MatrixPowerandDeterminantno longer claimrationalcells. A float that holds a whole number (2.0) is typed as an integer, so the claim was wrong forInverse([[2.0, 1], [1, 3]]), typedmatrix<rational^(2x2)>with float cells. The inverse of an integer matrix is now typedmatrix<real^(2x2)>, and the determinant of a rational matrixreal. 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 erroralias-without-fixed-point, and theDeclareTypeand Epsiltype aliasroutes give aninvalid-type-declarationerror. Recursive aliases such astree = 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]andlet xs = [if k > 1 { k } else { 0 } for k in 1..3]; xsgave an unevaluatedComprehension; they give[2, 4, 6]and[0, 2, 3]. An infinite comprehension stays as it is. \operatorname{implies}and\operatorname{iff}parse asImpliesandEquivalent.p \operatorname{implies} qwas a product of the symbolsimplies,pandq.\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
kdeclared(tuple<number, number>) -> number, the type of a function whose body callsk(3)iserror, as the value is; it wasnumber. A list of points at a declared point parameter maps on that route too, so the derived type ofk(P)islist<number>.
New Features
- A type handler can read whether the engine is strict. The engine view that
a
typehandler receives (PureEngineView) has astrictproperty, the same value asce.strict.
Resolved Issues
- The structural form keeps a constant name as written again. In 0.168.0,
ce.box(['Power', 'i', 2], { form: 'structural' })gavePower(Complex(0, 1), 2),ebecameExponentialE, andHalfbecame1/2, soM(i) = i^2read structurally had no parameter namedi. A symbol whose canonical form is another expression (i,ImaginaryUnit,e,Half) now keeps its name and is unbound, typedunknown, as in 0.167.0. Every other symbol still binds to its definition:["Add", "t", "t"]witht: tuple<integer, integer>is typedtuple<integer, integer>. The canonical form and the value are unchanged: the canonical form of the structurali^2is-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 typeinteger". - 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 toTruegavea[1] + a[2]instead of3. Withm(a, b) := a + b,m(b)applied to1gave2instead ofb + 1: the free parameter was renamed by name and captured the argumentb. - The type of a call holds its value in more cases.
- The derived type of
Atiserrorfor a base that cannot be indexed (a number, a boolean, a set, an interval), as the boxedAtis. So withh(a, t) := a[1] + a[2] + t, the partial applicationh(3)is typederror, as its value is, not(t: unknown) -> number. - With
kdeclared(tuple<number, number>) -> numberandU(L) := k(First(L)),U([[(1, 2), (3, 4)]])was typednumberfor 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 typedlist<list<number>>(it waslist<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.
- The derived type of
- A
Textrun whose words are a keyword round-trips through LaTeX.Text(p, "and", q)was writtenp\text{and}q, which parses asAnd(p, q). It is now writtenp\text{{}and}q, which prints the same and parses back as text. - An intersection with a
neverarm is a subtype ofnever.integer & neverwas not a subtype ofnever, nor of itself, soce.declareType('q', 'integer & never', { alias: true })threwsignature "(integer & never) -> integer & never" does not match. - A product with a scalar factor of inferred or
unknowntype hasnumbercells (CE-481).L·uwithL: list<integer>anduinferrednumberwaslist<integer>and islist<number>;x ↦ a·xwitha := [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
unknownno longer claims a scalar result (CE-478). Withg := s ↦ 2sandf := t ↦ g(y),fwas typed(t: unknown) -> number, and aftery := [1, 2, 3]the value off(1)was[2, 4, 6]. It is now(t: unknown) -> any, which holds a list, a tuple or a ragged list iny. - The signature of a function that calls itself from a
Mapcallback 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
letof a name its body reads is typed for both bindings (CE-480). Inlet a = g(); let i = 2; function g() { i^2 },gwas typed() -> integer<0..>for the value-1(the first call reads the constanti); it is now() -> number.
0.168.0 2026-10-25
Breaking Changes
-
The
complexPromotioncompile option is removed. It was deprecated in 0.115.0 as an alias ofmode: 'complex', with a one-time console warning. The option is no longer onCompilationOptions,CompileTargetor the options ofcompile(), and a JavaScript caller that still passes it gets no warning: the key is ignored and the compilation runs in the defaultmode: 'auto'. That mode promotes an unknown-signSqrt,Ln,LogorPowerthrough the complex kernels, but unlikemode: 'complex'it does not treat every wide numeric binding as complex, so the emitted code and the reportedmodecan differ from whatcomplexPromotion: truegave. Passmode: 'complex'to keep the old behavior, ormode: 'strict'to forbid promotion. -
The warning for the removed
realOnlycompile 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:
Removed Use BoxedExpressionExpressionSemiBoxedExpressionExpressionInputExpressionComputeEngineComputeEngineThe three were identical to their replacements, so a rename is the whole migration.
-
The
fractionalDigitsserialization option is removed fromtoMathJson(),toLatex()and the LaTeX number format (JsonSerializationOptions,NumberSerializationFormatand the options ofLatexSyntax). Usedigitsinstead:fractionalDigits: nbecomesdigits: { fractional: n }, and'auto'and'max'keep their names.With
toMathJson(), the replacement gives the same output as before. WithtoLatex(), it does not:Call Before ( fractionalDigits)Now ( digits)π, toMathJson, 6 digits3.1415933.141593π, toLatex, 3 digits3.141\ldots3.1422.0,toLatex, 2 digits22.00toLatex({ fractionalDigits: n })cut the digits after thenth, wrote a\ldotsmarker when a digit was cut, and dropped trailing zeros.toLatex({ digits: { fractional: n } })rounds to exactlyndigits after the decimal point and writes no marker. ThetruncationMarkeroption is now read only by the parser.An engine-wide
digits(ce.latexOptions = { …, digits: { fractional: 2 } }) now applies to.latexandtoLatex(), as an engine-widefractionalDigitsdid. Before, onlyfractionalDigitswas read from the engine-wide settings.A standalone
LatexSyntaxserializer (one used without an engine) now acceptsdigits, in its constructor and per call:new LatexSyntax({ digits: { fractional: 3 } })writes{ num: "3.14159265" }as3.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
digitsvalue.toLatex({ digits: -4 })andtoMathJson({ digits: 6 })were accepted at runtime, although theDisplayDigitstype does not allow a number:n ≥ 0meant{ fractional: n }andn < 0meant{ significant: -n }. Such a call now throws an error that names the accepted values, intoMathJson(),toLatex()and a standaloneLatexSyntax. An invaliddigitsset once.latexOptionsthrows when it is set. Write{ fractional: n }or{ significant: n }. -
The
canonicaloption ofreplace()is removed. Useform:canonical: truebecomesform: 'canonical',canonical: falsebecomesform: 'raw', and a canonical form or a list of forms is passed toformunchanged. -
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}withF: list<number>) also compiled the batch body that makesbroadcast()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 withcompile(expr, { broadcast: true }). Without the option the result has nobatchfield, andbroadcast(),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 callsrun()saves the second compilation. A consumer that reads bothrun()andbroadcast()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')andce.box(['InvisibleOperator', 2, 't'])left a symbol with no value and no declarationunknown, where2\cdot ttypes itnumber. They now type itnumbertoo. The product itself (its factors, their order and its LaTeX) is unchanged, and a function application such asf(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>) andt ↦ [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 afterEG,x \in G \setminus Hreports an error, as it already did afterE\cdot G. Use a fresh engine, or declare the symbol, when a name has two roles. -
Compiled Python reads a
Noneinput asNaNin arithmetic (ROADMAP CE-395 and CE-460, user decisions 2026-10-25).Noneis the Python spelling ofMissing;p + 1,1 / p,np.sin(p)andr^2 + 1raisedTypeErrorfor it, and now givenan, as the interpreter and compiled JavaScript do. The conversion applies to an input whose type admitsmissing(number | missing,any), isunknown(a free symbol of a parsed expression,rin2\pi r), or was inferred asnumber(rin\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 declarednumber(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 ≠ 0stays undecided). Avarssource 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 written2.0\frac{1}{3}, which reads back as a product (0.666…). It is now2.0+\frac{1}{3}.Add(2, 1/3)is still2\frac{1}{3}. -
A point function with a second argument was typed
any(CE-469). Withf: (broadcastable<tuple<real, real>>, tuple<real, real>) -> real,f([[(1,2),(3,4)],[(5,6),(7,8)]], (1,1))is typedmatrix<real^(2x2)>, andf((1,2), (1,1))isreal. -
A function literal with a rest parameter is accepted as a callback.
Map((a, ...xs) => …, …)was refused with acallback-arityerror 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,SumorReduceover 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
ReduceandScanused the library kernel for a combiner that only had the same name. With a user-declaredMax(a, b) = 2a + b,Reduce([1, 2, 3], Max)compiled toMath.maxand gave 3, where the interpreter gives 11; the same held for a parameter or a block-local function namedMax, for aMaxgiven in thefunctionsoption, 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. Withd(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 typedvector<integer^2>, notnumber. 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 + CwithM: list<list<integer> | number>andC: broadcastable<number>was typedlist<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
SumandProductwas 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} abcxcompiled in 12.7 s to 18 MB of code, and now in a few milliseconds. A body shared by aSumand aProductat 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>>andb = [],At(At(b, 1), 1)wasNone(Missing); it is nownan, as in the interpreter. A read past the end of a tuple with anunknownslot picks the value from the elements, as the interpreter does. -
Compiled
Lastdeclined an operand thatFirstcompiles, such asAt(tup(x), 1)withx: any. It now compiles asAt(…, -1). -
The ASCII
toString()of aLimitleft out the variable:lim_(a) …. It now printslim_(x -> a) …. -
NLimitof a function that decays to0gave a tiny residue such as−2e-22for7t/(t² + 9)at infinity. A result whose magnitude is below its error estimate is now extrapolated again with a tighter tolerance and snapped to0when it stays below the estimate; a small true limit such as1e-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 now1/(y + z). -
simplify()did not collect constant cofactors of like terms:π(√2 − 1) + π(√2 + 1)is now2π√2, so the integral∫ (x² − 1)/((x − 1)(x⁴ + 1)) dxover the real line givesπ/√2. -
Infinite sums and products: a two-term body whose second term is not a
Negatenow telescopes (Σ 1/(k+x) − 1/(k+x+1)from1to∞is1/(x+1));evaluate()and.N()now give the limit of a divergent or vanishing infinite product of a rational body (Π kandΠ (k+1)/kare+∞,Π k/(k+1)is0); the shifted-factorial rule no longer simplifiesΠ_{k=1}^n (k − 3)to0. -
An unevaluated
Solvefrom a parse keptInvisibleOperatorandDelimiterin 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 theincompatible-typeerror, as without theMissing. -
Mean,Median,VarianceandStandardDeviationof an exactRangeunder.N()walked the float range and gave-8.50000000000000060577forMean(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,ReverseorFilterof 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")withComparableundeclared) gave an arity error. It now gives aprotocol-unknownerror that names the symbol. -
IdentityMatrix(n),ZeroMatrix(2, n)andOnesMatrix(n)with a symbolic extent gave anexpected-positive-integererror;Kernel(n)for a valuelessn: integergave[];Shape(m)for an unassignedm: matrix<integer>gave(-1, -1), andShape(q)/Rank(q)of a free symbol gave()/0. Each now stays unevaluated. -
Sum(Rest(g(3)))andReduce(Rest(g(3)), Add, 0)withg(t) = [t, 2t]stayed unevaluated. A lazy view over a held call is now resolved before the reduction;Most,Reverse,FilterandJoinoverg(3)too. -
The entry type of a declared open record left out its undeclared fields, so
FlatMap(ra, x ↦ [At(x, 2)])withra: record{a: integer}holding{a: 1, b: Missing}was anincompatible-typeerror. A declared record now readstuple<string, any>entries andValues(ra)islist<any>; a dictionary literal keeps its field types. -
Tabulate(w, 3)withwholding an error gave three copies of the error; it is now the error once, asMap(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")]),CountandIsEmptyover a list with a failed cell were typederrorfor the value2;First,Last,At,IsError,Element,ContainsandIndexOfover such a list are nowunknown, andSin([1, err, 3])and[1, err, 3] + 1are 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])iserror, soMap((a, b) ↦ a + b, ss, ys)over strings and integers islist<unknown>;Simplify(Sin(r)·P)withP: booleaniserror;Sinover alist<string>iserror. Map(Abs, [1, Missing, 2])andMap(Negate, …)arelist<integer | nan^3>, as the lambda form is;Abs(w)withw: nanisnan.- 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
unknownfrom a parameter is typedunknowninstead of by its other branch:f := (n, s) ↦ If(n = 0, s[1], [1, 2])applied to(0, [7])gives7, whichvector<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 aliasess, which is read as a list. -
\keyword{if} x > 0 \keyword{then} xwithoutelseparsed to text next to the operands and reported no error. It now parses to the two-operandIf, which isMissingwhen the condition is false, and it stops at an outer comma or semicolon. -
\int dxand\int (dx)were anunexpected-commanderror. They now parse toIntegrate(1, x). -
LaTeX parser cases that were marked
@fixme: a chained\over(1\over 2\over 3) now gives anon-associative-operatorerror, as TeX rejects it;\tan(90-0.000001)\degreeapplies 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 anunexpected-differentialerror. -
The compiled
Mapelement function found the functions its callback calls by name in the drain scope. With an outerh(x) = sin(x) + 1.5, a callbackf(x) = N(h(x))and a block that declaresh(x) = sin(x) + 2.5, the compiled sum used the innerh. 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 aboutNothing; it now declines on every target with a message that says the form has no value. -
Python target:
Powerwith a float exponent was emitted ast ** 0.3inautomode while the analysis read it as promoted. It is nownp.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 thefunctionsoption or avarsinput. -
Compiled JavaScript gave another absence value than the interpreter for an element read of an absent tuple. With
tup := t ↦ (t, 2t),x: anyandx = [], compiledAt(At(tup(x), 1), 2)gaveMissing; it now givesNaN, as the interpreter does. The compiled code now takes the value from the slot types of the tuple, so a slot typedunknownis no longer read as a number (Missing, notNaN), and a numeric slot oftuple<number, string>, or a position outside the tuple, givesNaN. -
Compiled JavaScript over a symbol declared
anythat holds a list gave wrong values: withp = [1, 2]andq = [3, 4],p + qwas the string"1,23,4",p·qwasNaN,Max(p, q)wasNaN, andp < qwas 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.MultiplyandPowerof such a list are entry-wise, as for a barelist. -
Compiled
PointX,PointYandPointZof a value that is not a point (a number, a boolean, a string) gaveNaN. They now throw aRangeError, as the interpreter gives anincompatible-typeerror. An absent value orNaNstill givesNaN. -
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]andx: any,h(x)was typedbroadcastable<vector<2>>, which does not hold the list of matrices thatx = [[1, 2], [3]]gives. It is nowR | list<R | list<any>>over an operand of a top type andR | tensor<cells of R>over a tensor operand (Ris the result for one element). A function with a boolean, string or tuple result over an operand typedanyis typed the same way. A compiledMaxorMinofh(x)[1]now declines, as for any operand that may holdNothing. -
interval-js:
≠with an operand that has no value over the whole cell (\sqrt{x} \ne 0overx = [-1, -1]) gave'maybe'. It now gives'true', as the interpreter and the other targets giveTrueforNaN ≠ 0. -
.N()did not check a value assigned to a callback parameter. The parameter of aMapcallback is typed from the source elements, andevaluate()refuses an assignment of another type. Under.N(),evaluate({numericApproximation: true})andN(…), 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 anincompatible-typeerror for each element, asevaluate()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)withg := x ↦ x + 10andt := ([1, 2], 3)is typedlist<number | vector<2>>(it waslist<number>, for the value[[11, 12], 13]) (CE-5). A function literal with an untyped parameter over alist<any>gets alist<any>arm in its result:(x ↦ (x, x))(A)islist<list<any> | tuple<…>>(CE-16).(1, 2) + qand(1, 2)·qwithq: unknownhavenumbercomponents (they keptinteger, and the value forq = 0.5is(1.5, 2.5)) (CE-373).- With
tv: tuple<number, number> | vector<number^2>,Sin(tv),Sqrt(tv)andPower(tv, 2)are typedtuple<number, number> | vector<2>(they werenumber) (CE-7). Sort(tu)withtu: tuple<unknown, missing> := (1, Missing)is typedlist<missing | unknown>(it waslist<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
errorinside a list) (CE-367). - A
Foldwhose body assigns its accumulator is typedunknown, not from its seed alone (CE-11). AnyandAllare typedboolean | missingwhen a cell can be absent:Any([1, Missing], x ↦ x > 2)evaluates toMissing(CE-8).- With the alias
cyc = cyc | list<integer>andk: cyc := [1, 2],Map(x ↦ x + 10, k)is typedlist<integer>and evaluates to[11, 12]. It was typedcollection<integer>and evaluated toSet(11, 12)(CE-31). If(c, 1, 0)withcof unknown type is typedinteger | list<integer | list<any>>:ccan hold a mask. A condition that can be undecided ([1, 2] = [1, Missing]) addsmissingto the type ofIfandWhich(CE-22).MatrixMultiplyof two vectors is typed as a scalar (integerfor integer vectors). A lazyIdentityMatrix(101)is typedmatrix<integer^(101x101)>, andTabulatewith literal extents has a shape.TrigExpand,TrigToExpandTrigReduceofMissingor of an untyped symbol keep the operand's type, notvalue(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 typedvector<integer^1>for the value[1, 2](CE-34). ce.box(expr, { form: 'structural' })binds symbols to their definitions, asce.function()does: witht: tuple<integer, integer>,Add(t, t)is typedtuple<integer, integer>(it wasunknown) (CE-29).
-
More precise static types.
- An operand that may be absent adds
nanto a numeric result type, instead of widening the type tonumber:Length(At(xs, 1))isinteger | nan | signed_infinity, andSin(m)withm: real | missingisnan | real(CE-41).[-2, NaN, Missing]is typedlist<integer | missing | nan^3>. v / g(1)withv: vector<integer^2>and an integer-valuedgis typedvector<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)]islist<(tuple<…> | tuple<…>)^2>(CE-28). First(Rest(xs))andFirst(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 typednan(CE-42).Map(Sqrt, x)over a union of vector types is typed arm by arm, andMap(Add, xs, ys)over twolist<integer>is typedlist<integer>(it waslist<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 werecollection<number>or-> unknown) (CE-37, CE-38). Simplifyand the other lazy operators type the operands of a held application from their bindings (CE-40).assume(t ∈ ExtendedRealNumbers)typestasreal | signed_infinity(it leftttypedunknown); the other extended sets work the same way (CE-50).q: integer := uu, withuuan undeclared symbol, narrowsuutointeger(CE-39).
- An operand that may be absent adds
-
Arguments and values that were admitted and now are refused when boxed.
g(S)withg: (integer) -> integerandS: list<string>(declared, no value) is anincompatible-typeerror, and so isf(S)withf: (matrix<2x2>) -> integer.h(S)withh: (list<integer>) -> integerstays valid, because the empty list is a value of both types (CE-24).Map(h, s)withh: (T) -> T where T: numberands: list<string>is refused; it failed on each element (CE-30).Q + MwithQa list of lists of points andMa list of lists of integers is oneincompatible-typeerror, 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 aslist<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 typed5; it was anincompatible-typeerror. In the same waywhere T: real<0..10>acceptsg(3). -
Setting
.valuefollows the rules ofce.assign()(CE-49).n.value = 5typesnasinteger(it stayedunknown), a value that does not fit a declared type is refused, andm.value = "hi"on an inferredintegersymbol changes the type asce.assigndoes (the type stayedinteger). 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 threeincompatible-typeerrors forg([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 -> numberbound tox ↦ 2h(x)and thenh := x ↦ x + Random(), the body ofgwas silently rebuilt into a random call; the rebuild now throwsEffectContractErrorand 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 + 1andlet g = k,g(1.5)is reported by the static check, ask(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 of1/xis1, raised to the power 2, and the split of the product into a numerator and a denominator kept that1^2as 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 ofxis still written with a solidus,(0.5\vert x\vert x^{n})/x^2, as before. -
The LaTeX of a function whose parameter is
_1showed 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
missingwhen the argument may be absent. The parameter is now typed as the call binds the absent value. WithP: tuple<number, number> | missing:- At a parameter declared
broadcastable<tuple<number, number>>, a string, a boolean or a list, an absent argument is anincompatible-typeerror, and it adds nothing to the type. Withf := a ↦ (a.x + 1, a.y + 1),f(P)was typedtuple<missing, missing> | tuple<number, number>and is nowtuple<number, number>, as at a parameter declaredtuple<number, number>. - At a parameter declared
realorinteger, the absent value is read asNaN. Withf := a ↦ (a, 1)andf: (real) -> unknown, the call was typedtuple<real, integer>, and its value(NaN, 1)did not fit. It is now typedtuple<nan, integer> | tuple<real, integer>, andf(Missing)is typedtuple<nan, integer>(it wastuple<missing, integer>).
- At a parameter declared
-
Compiled
Digamma,TrigammaandPolyGammagaveNaNat a pole. The interpreter answers~ooat a non-positive integer, and compiled code spells that poleInfinity, as it does forGamma. With a variable argument the compiled JavaScript gaveNaN; it now givesInfinity. CompiledPolyGamma(-1, x)gaveNaN; it is nowLogGamma(x), as in the interpreter, for x > 0 and at a pole. -
The GPU
LerchPhiandPolyLoggave ±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.
Colorreturns anOklchexpression, not a tuple, andColorFromColorspacereturns the colour expression of its space (Hsl(0, 1, 0.5)).doc/86-reference-colors.mdnow shows the real results. -
GLSL and WGSL: a product with a derivative or with a one-operand function lost its grouping.
3·h'(x)withh(x) = x³ + sin xcompiled to9.0 * (x * x) + cos(x), not9x² + 3cos x, and3·Binomial(x + 1, 1), andElementMax,ElementMinorMedianwith one operand, compiled to3x + 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:
OklchandOklabwith an infinite or NaN channel gave the raw channels. They now give the NaN colour, as compiled JavaScript does. -
WGSL:
ContrastingColorwith 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,OrandNotover a list of booleans read the list as one truth value (And(L, q)gaveTrueforL = [True, False]). They are now computed element by element, as in the interpreter. Other list shapes are not compiled. -
Compiled Python:
\arg(x - iy)aty = 0gave −π for a negativex. 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
Lengthcounted"e\u0301x"as 3 where the interpreter counts 2 characters. The compiled function now refuses a string there with aTypeError, as compiled JavaScript does. -
A one-operand
Subtract,ElementMaxorElementMinfrom raw MathJSON compiled to a wrong value:3·Subtract(x + 1)was3 * (-x + 1), and3·ElementMax(x + 1)was3 * x + 1on Python. A one-operandDivideorPower, and aClampwith fewer than three operands, gave invalid code orNaN. They now compile to the operand, or are not compiled, as the interpreter gives an error for them. -
Compiled
MaxandMinof a nested list of unknown type gaveNaN(Max(g(x)[1], 1)withg := 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) + 1withP: broadcastable<tuple<number, number>>gave[4, 5], where the interpreter gives anincompatible-typeerror. It is now not compiled. -
A compiled power with a list exponent with a fractional cell gave
NaNfor a negative base (p^[2, 0.5]for(-4)^0.5). It now takes the complex lane, as a scalar exponent does, and gives2i. -
A compiled
Rootwhose degree is known only at run time (Root(p, 3)over a list, orRoot(x, n)) gaveNaNfor 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 givesNaN. -
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, andaassigned 1000 in a new scope, compiledG(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 functiongat its calls in compiled code. It now does, on JavaScript and interval-js. On GLSL and WGSL avarsentry names a uniform, which cannot be called, so a user function keeps its own definition there; thefunctionsoption 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 aletlocal 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 + 1with a block-localg) gave a string ornull. It is now not compiled. -
Compiled code did not check some parameter types. An argument at a
listortupleparameter is now tested for its shape, and the arguments of an annotated function literal applied directly are tested too; a failed test givesNaN, as the test of anintegerparameter does. An assignment to a typed parameter of a value outside its type is now not compiled. -
freeSymbolslisted 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 gaveNaNfor a negativey, wherey^{-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)!) wastrueatx = 1, because1/0compiled to IEEE infinity. The interpreter givesFalse, 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, asIfandWhichdo, in the interpreter and in compiled code. Before, compiled JavaScript read each row as one condition and gave[NaN, NaN], and the interpreter kept theWhenunevaluated. -
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)withB: 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)thena(1), whenmkreturns a function literal on every path. -
interval-js compiles an index into the tuple that a block-local function returns (
At(f(x), 1)withf := (u) ↦ (u + 1, 2)). -
Compiled Python:
EqualandNotEqualwith 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)and2^vover a vector compile tofract(v)andexp2(v). Before, these forms applied to a scalar only. -
GLSL and WGSL:
GCDandLCMof any number of operands, a vector or a literal list included, compile as a fold over the components. Before,GCDtook exactly two scalars andLCMdid not compile. -
GLSL and WGSL: a
Matchwhose case body holds a loop-formSumorProductcompiles to anif … elsechain. Before, it declined. -
GLSL and WGSL compile
Digamma,TrigammaandPolyGamma(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 forDigammaandTrigammaand 1e-5 forPolyGamma. -
The GPU
Zeta(s)answers down to s = −63. It answeredNaNbelow s = −24.5.
New Features
epsil --workers <n>(also in the REPL and inepsil mcp) gives the engine a pool ofnworker threads (at most 256) and runs each program asynchronously, so afindFitwith a global search scans on the workers and gives the same values. Without the option, nothing changes.--workers autopicks 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.IsPrimecompiles 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))withP: list<tuple<number, number>>) is compiled as4·PointX(P) + 0.3·ton the JavaScript and interval targets, so the points are not built.Reduce(L, Add)(alsoMultiply,Max,Min) compiles on the interval target whenLis not written out but its width is known from its structure. -
A
Mapwhose 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 functionfuses the same compiled element function as a lambda: exact results for an integer body, and machine floats for anN(…)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, asevaluate()does: about 2 ms in place of 180 to 350 ms for 20,000 points. The data preparation ofFindFit.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 anAbortSignalaborted during the evaluation rejects it with aCancellationError. -
The compile-time constant fold no longer walks more than 10,000 elements or iterations, and
CountIf,Any,ReduceandFoldare 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/Maxof 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,AbsandArgof the square or the cube ofx + iyread 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
Sumor aProductonce, 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 (aMap80 levels deep: 434 → 156 ms), and arithmetic on symbols of typereal | +oo | -oono 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 thevmcontext of each test file with an ordinary global object, so a read ofMathorNumberis no longer a proxy trap.
Behavior Changes
- A
Mapof a named function whose body is inN(…)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)withg(x) = N(h(x) + 1)andh(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)withp ≤ 1evaluates to+∞underevaluate()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
Argof a square or a cube now gives a zero imaginary part as+0, soarg((0 − i)^2)isπ, as in the interpreter (it was−π). - Setting
.valueon a symbol checks the value asce.assign()does.n.value = "hi"forndeclaredintegernow throws aTypeCompatibilityError; before, the value was written and the type stayedinteger. - A numeric result whose operand may be absent keeps its handler's type and
adds
nan, instead of becomingnumber.Sin(m)withm: real | missingis typednan | real;Length(At(xs, 1))isinteger | nan | signed_infinity. Code that compares a type string withnumbersees the narrower spelling. AnyandAllcan be typedboolean | missing, when a cell can be absent or the predicate can answerMissing.- The declared result of
MatrixMultiplyismatrix | vector | number. The product of two vectors is a number, which the old signaturematrix | vectordid 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
Mapwith a polymorphic callback whose type-variable bound refuses the element type (CE-30). - An inferred signature keeps its parameter names (CE-60).
(x) => x + 1has 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)gives8(it wasargument-name-unknown), and in Epsilns.D(k: 1, x: 10)withconst ns = {D -> ((x, k) => x - k)}no longer reportsargument-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
Mapis typed by its own type handler (CE-45.2), asx ↦ f(x)is. Withxs: list<integer>,Map(Abs, xs)waslist<number>and islist<integer<0..>>;Map(Sin, xs)waslist<number>and islist<real>;Map(Conjugate, xs)waslist<unknown>and islist<integer>. A function you declare keeps its declared result. A more precise type can change a value: withL: list<vector<number>>,Map(Reverse, L) · Map(Reverse, L)was an entry-by-entry product and is now the matrix product, asL · Land the lambda formMap(x ↦ Reverse(x), L)give. - A free symbol in a list literal is inferred as a number (CE-39.4), as in
x + 1. WithL := [u, v], the value ofAt(L, 1)was typedunknownand isnumber. A symbol used in a list literal and later used as a boolean condition gives anincompatible-typeerror. A parameter of a function literal is not affected (x ↦ [x]keeps the resultlist<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 typedlist<list<unknown>>and is amatrix<2x2>. - A function with a point parameter maps over a matrix or tensor of points
(CE-448). With
g: (tuple<real, real>) -> realandP := [[(1,2),(3,4)],[(5,6),(7,8)]],g(P)was anincompatible-typeerror and is the 2×2 matrix ofgat each point, asPointXandAbsgive. ForP: broadcastable<tuple<real, real>>,g(P)was typedlist<real> | realand isbroadcastable<real>; forT: tensor<tuple<real, real>>,g(T)was typederrorand istensor<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],ywas leftunknownoutside the literal; it is now inferrednumberwhere it is declared. Aftery := "abc", a call of a named function whose captured value no longer fits the type its signature relied on (g(3)was[-3, "abc"], typedvector<2>) givesincompatible-type;Applyof 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)beforegis a function) is not narrowed outside the literal. - A call never maps over the arguments of a rest parameter.
h(...xs) = Length(xs)withh([1,2,3])gave[1,1,1]and is1;h([1,2],[3])gave[2,2]and is2. A fixed parameter before the rest parameter still maps a list. The same holds forApplyandMap. - A rest parameter is typed
tuplein the body (CE-470), andLengthandCountof a tuple areinteger:h(a, ...rest) = Length(rest)was typed-> integer | signed_infinityand is-> integer. - A point-function call over a collection symbol with no value stays
symbolic (CE-468). With
g: (tuple<real, real>) -> realand a valuelessL: list<tuple<real, real>>,g(L).evaluate()gaveincompatible-typeand is nowg(L); afterLhas 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 between0.3and0.7, and used to return whichever it held afterce.iterationLimitapplications, a value that is not a fixed point. The call now stays unevaluated, as the divergence guards ofFixedPointandAnyover 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 largerce.iterationLimitlets such an iteration converge. With an explicit maximum, the last value is still returned, as in Wolfram'sFixedPoint[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/runtimenow exportscreateKernelPool, and its runtime hasloadBroadcastAsync(stored, { workers }): thebroadcastAsync()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 haswillUsePool(cells)andshippable. Only stored code that keeps theshippable: truefield 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
gridlayout ofbroadcast(ROADMAP CE-421, Tycho row 440). Theindexesoption names the inputs that are index axes: the input of each is the axis lengthn, and at each cell the variable is the cell's position along that axis,0ton − 1.f.broadcast({ m: 64, n: 64, F }, { layout: 'grid', indexes: ['m', 'n'] })gives the values of the same grid with the columns0 … 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 asexecuteEpsil()does and returns a promise of the same result. Each top-level statement is evaluated withevaluateAsync(), so afindFitwhose search runs on the worker pool, or an operator with only anevaluateAsynchandler, takes its asynchronous route. Thesignaloption stops the program: the promise rejects with aCancellationError. AwithTimeLimit()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 5parses to["And", ["Less", 0, "M"], ["LessEqual", "M", 5]], and the serializer wrote thatAndas0\lt M\land M\le5. It now writes0\lt M\le5, which parses back to the same MathJSON. AnAndis 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<ckeeps its\land, sincea<b<cis oneLess). A chain inside an\landneeds no parentheses:0\lt x\le1\land y\gt2. -
broadcastAsync()with adeadlinesettles when the workers never start (CE-434). A batch that waited for a worker that never postsready(for example a worker that fails only when it gets a message) never settled, even with adeadline: 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. -
ReplaceAllin a function body reads a parameter as its argument (#476).ReplaceAllholds its target, so inx ↦ ReplaceAll(x, a → b)the rules were matched against the symbolx, and the call returned its argument unchanged:FixedPoint,Mapor 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, asFunction[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 + 1ce.box(['FixedPoint', f, 'a']).evaluate();// Before → a Now → bOutside a function nothing changes: a held target keeps a symbol with an assigned value as the symbol (
x := 5; ReplaceAll(x^2, x → 3)is9), and so does aletlocal of a function body. -
AssignandDeclareevaluate their value on the asynchronous route. WithevaluateAsync(),x = FindFit(…)andlet x = FindFit(…)ran the synchronousFindFit, and an operator with only anevaluateAsynchandler 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.latexkeeps its string, and prettified serialization is cheaper (ROADMAP CE-438, Tycho row 444). A second read of.latexon 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.latexandtoLatex()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.latexof 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.digestis the key to use for a cache. -
The sum of a compiled
Mapis 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. AMapover a set, an impureMap, 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 onevaluate()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, so10⁻²⁰⁰·10⁻¹²³·10²⁰⁰·xwent through a subnormal value and gave9.88·10⁻¹²⁴·x, and some orders of large and small factors gave a coefficient of0. It is now10⁻¹²³·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
SumorProductwith 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, soe = e + erepeated 24 times over2 + √3took 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
SinandCoscompiled 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()answeredsuccess: falseand 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 casLessEqual(Less(a, b), c), anda = b = casEqual(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 anAndof links that share their boundary term:a < b \le cisAnd(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 examplei + 1withideclaredinteger<0..>) is a direct read with no run-time test; a read past the end still givesNaN. When an input breaks its declared type (a fractionaliforinteger, a string in a list declaredlist<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. Withf := (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)], typedlist<tuple<number, number>>; it was([1, 2, 3], 2). With(P, L) ↦ Length(L),Lis refined tocollectionand the list is still taken whole. Compiled JavaScript gives the same values. -
An absent point at a
broadcastable<tuple<…>>parameter givesMissing(ROADMAP CE-420), as at atuple<…>parameter. Withf := (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; withbroadcastable<…>parameters the second element was anincompatible-typeerror. A list of only absent points,[Missing, Missing], now gives[Missing, Missing]for every declaration; at atuple<…>parameter the call was anincompatible-typeerror. A list of points that is absent as a whole (P: list<tuple<…>> | missingholdingMissing) now givesMissingat abroadcastable<tuple<…>>parameter too, and the call's type has themissingoption; it was anincompatible-typeerror. Compiled JavaScript givesundefinedfor an absent point, and for an absent list, at abroadcastable<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 firstawait. AFindFitglobal search ran to its end (270 ms). The evaluation now keeps the limit (and awithStepBudgetbudget) that is in force whenevaluateAsync()is called, until it ends. A timeout is reported as byevaluate(): theFindFitsearch answers its best point so far withtimedOut: Trueafter 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 ofFindFitandFindRoothas arouteentry:"pool"when the workers of the engine's kernel pool solved the grid points, or"calling-thread". With"calling-thread", areasonentry 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 ofevaluate()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 withdone: 0and 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.shippablesays whether a compiled result can run on a worker (ROADMAP CE-427). It isfalsewhen the compiled code draws random numbers from the engine, names a host global (avarsorfunctionsentry given by name), or has a callerpreamble; such a result always runs on the calling thread. It is present wherebroadcastAsyncis. -
The result of
broadcastAsync()says where the batch ran (ROADMAP CE-428).routeis'pool'or'calling-thread', andreason, 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 typesBroadcastAsyncRouteandBroadcastAsyncReasonare exported.routeis a required field ofBroadcastAsyncResult: 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, noglobaloption), the whole search ran in one piece: a 5-point fit ofa cos(b x + c) + d LogGamma(x + c)blocked the thread for more than 4 s, so a page did not respond and anAbortSignalaborted 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. -
EqualandNotEqualof two relations compare their truth values (ROADMAP CE-411, Tycho row 433).["Equal", ["LessEqual", "a", 0], ["LessEqual", "b", 0]]was boxed as the chaina ≤ 0 = b ≤ 0, and the LaTeX(a\le0)=(b\le0)evaluated as that chain after a substitution: both gaveFalsefora = b = 1. Both now giveTruewhen 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 cisAnd(Less(a, b), Not(LessEqual(b, c))); it wasNot(And(Less(a, b), LessEqual(b, c))). The LaTeX commands\nleq,\nleqslant,\ngeq,\ngeqslant,\not\ltand\not\gtare now parsed; they gave anunexpected-commanderror. -
A call of a function with a
broadcastable<tuple<…>>parameter over a list of points compiles (ROADMAP CE-412, Tycho row 437). Withf := a ↦ (a.x + 1, a.y + 1)andPa 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 baretuple<…>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). Withnunbound, a call overMap((u, v) ↦ (u, v), \frac{1}{n}[0...n], \frac{1}{n}[0...n])was anincompatible-typeerror. 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 + tandtdeclaredreal,h((1, 2), [0, 1, 2])was anincompatible-typeerror. 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 typedunknown, 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
_1no longer defines_(ROADMAP CE-416).toString()renamed the parameter with a substitution that created the symbol_in the engine's scope, so a later lazyMapprintedError(incompatible-type …)in place of a call. -
broadcastAsyncreads an input that shares memory withoutas 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: withTuple(x + 1, 10x),x = [1, 2, 3, 4]in the first half ofoutandchunk: 1, the result was[2, 10, 11, 100, 12, 110, 101, 1000]instead of[2, 10, 3, 20, 4, 30, 5, 40].broadcastwas correct. Such inputs are now copied once before the first slice.
Performance
- A
broadcastkernel runs up to 15 times faster (ROADMAP CE-410, Tycho row 432). For a kernel that reads two values of a sharedlist<number>and returns a tuple, 250 000 cells take about 0.55 ms instead of 8.3 ms when the index is declaredinteger<0..>, and about 0.95 ms when it is declaredinteger. A kernel that reads a shared list gets a second compiled body for batches: the list is checked once per batch (aFloat64Array, or a plain array of numbers) and then read directly, and the inputs are passed as arguments. A sharedFloat64Arrayis read in place; it was copied into a plain array on every batch. The cell loop is generated for each compiled body,And/Orof plain comparisons compile to a conditional, and the per-cell call is direct.
0.165.0 2026-10-24
Breaking Changes
- The
IntervalValuetype includes the answer strings of a comparison (ROADMAP CE-210, Tycho row 411). A compiledinterval-jsrunner returns'true','false'or'maybe'for a boolean expression, but the public typeIntervalValuewasIntervalResult | Interval | IntervalValue[]. It is nowIntervalResult | BoolInterval | Interval | IntervalValue[]. Code that narrows anIntervalValuewith a check onkindmust 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))andLa list of two points, the interpreter gives(Missing, Missing, Missing)forW(L[5], 2), and compiled JavaScript gave[NaN, NaN, NaN]. The interpreter answers the marker of the codomain for an absent operand:NaNwhen the operation is provably a number,Missingotherwise. Compiled code now does the same: a coordinate accessor whose type isanyorunknowngivesundefined(the compiled spelling ofMissing) for an absent point, and an element-wiseAdd,Multiply(and the other arithmetic) whose type is not provably a number keeps anundefinedoperand of typeanyorunknown.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)withpabsent is[NaN, NaN]. A numeric operation, such as2·xwithxabsent, is stillNaN. -
A parameter declared
broadcastable<T>maps to the cells whenTcannot hold a list (ROADMAP CE-405, Tycho row 434). Withf: (broadcastable<number>) -> numberandf := x ↦ x + 1,f([[1, 2], [3, 4]])is[[2, 3], [4, 5]], typedmatrix<2x2>. It was a list of twoincompatible-typeerrors, one for each row, while the call was typedmatrix<2x2>: the declared parameter mapped exactly one level down and checked each row againstnumber. Since 0.162.0broadcastable<T>isT | tensor<T>, and an undeclared function and the compiled targets already map to the cells. This applies whenTis 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 declaredbroadcastable<value>keeps the one-level rule, and so does a call that maps such a parameter. An element that is neither aTnor a collection is anincompatible-typeerror that namesbroadcastable<T>. Such a function now compiles to JavaScript through the element-wise call, with each cell checked againstT, where it declined. A call that maps abroadcastable<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 lineEngine version: …. When the package installed on disk has another version, the result also has anengineWarning(or a line in an error message) that says to restart the server. -
The
interval-jsfallback 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):Fibonaccihas 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>>orB: list<boolean>,Mean(R)andMean(B)were valid and were refused only when evaluated, whileHistogram(R, 2)was refused when boxed: the empty list is both alist<list<number>>and acollection<number>. A collection argument is now admitted at a collection parameter only when their element types can match, soMean(R),Mean(B)andMean([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)andL: tuple<number, number> | list<tuple<number, number>>, the interpreter mapsf(L)over the points whenLholds a list, and gives one point whenLholds a point. The call was typedtuple<number, number>, and compiled JavaScript calledfonce on the whole list: for[(1, 2), (3, 4)]it gave["1,21", "3,41"]and reported success. The call is now typedlist<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 declaredbroadcastable<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 anincompatible-typeerror. An absent argument of such a type (… | missing) givesMissing, as an absent list of points does; it was anincompatible-typeerror. 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). Withw: (x: broadcastable<T>) -> list<T> where T: numberandw := x ↦ [x],w([[1, 2], [3, 4]])was the errorincompatible-typewhen 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 boundTto the rows. It now bindsTto 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]]], typedlist<list<list<integer>>>, and compiled code gives the same value. A type variable with no bound (where T) now maps each row whole, aswhere T: valuedoes; each row was anincompatible-typeerror namingT. -
A parameter named
Returncan be called in the body ofSum,Integrateand the other operators that take an expression (ROADMAP CE-401). Since 0.164.0 aReturnstatement in such a body is the errorreturn-in-expression-body. The check also refused a call of a parameter of the enclosing function literal namedReturn:["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() ↦ 2gives 6. AReturnthat calls a bare parameter, of the function or of an enclosing function, also no longer addsnothingto 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). Withl: (broadcastable<tuple<number, number>>) -> unknownandl := A ↦ √(A.x² + A.y²), the signature was refined to-> broadcastable<number>, sol(P)over a list of points was typedlist<broadcastable<number>>andP / l(P)had atensorarm. Each call of the body receives oneT, so the result is now typed with the parameter read asT:-> number, andl(P)islist<number> | number. -
The LaTeX serialization of
ReduceandFoldkeeps the initial value (ROADMAP CE-292).Reduce([1, 2], Add, 10)serialized as\sumof the list without the seed, so the LaTeX parsed back as an expression whose value is3, not13. A seeded reduction now writes its third operand, and the\sum/\prodshorthand is used only when there is no seed. -
A symbol declared with the type
errorparses (ROADMAP CE-294). Withw: error,w,w+1and\int_0^1 w\,dwgave anunexpected-symbolerror. They now parse as terms. A symbol of typeneveralso parses now. -
In lenient mode,
ab√x..5reportsambiguous-radical(ROADMAP CE-298), asab√xand5..ab√xalready did. The line-level ambiguity checks now read the tree after the range-precedence repair. -
Declarewith a type string that does not parse returns an error (ROADMAP CE-101).["Declare", "r", {str: "integer<<"}]threw when it was evaluated. It now givesError("invalid-type-declaration", …)with the message of the type parser. -
Holdaround aFunctionwith no body does not throw (ROADMAP CE-106). Reading the type ofHold(Function()), or evaluating it, threw from the signature of the function literal. The bodyless function literal is now typederror. -
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)andNor(A, A)evaluate to¬A,Implies(True, A)toA,Implies(A, False)to¬A, andImplies(A, A)andEquivalent(A, A)toTrue. 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). Withb ≥ 0andb ≤ 0assumed,b.isEqual(0)istrue; it wasundefined, andce.number(0).isEqual(b)agrees. TheEqualoperator does not read bounds. -
Limitof a function whose body isReturn(v)(ROADMAP CE-400) gives the limit ofv. It gave aReturnexpression as its value. AReturninside a statement block or a branch leaves the limit unevaluated. -
A divergent improper integral with a symbolic exponent stays unevaluated (ROADMAP CE-383).
∫₀^∞ xⁿ dxgave a value under the condition0 < n ∧ n < 0, which nonsatisfies. -
The limit at infinity of a ratio of logarithms (ROADMAP CE-147).
ln(3x)/ln(x)andln(3x)/ln(x^π)give1and1/π; they stayed unevaluated. A ratio that is not an indeterminate form, such as(2 + sin x)/(3 + sin x), still has no limit. -
DeterminantandCharacteristicPolynomialof a matrix with float entries fold their constants (ROADMAP CE-145).Determinant([[x + 1.5, 1], [1, 2]])is2x + 2; it was2x - 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 onx, stays unevaluated. A sum whose term at the finite bound is a pole stays unevaluated. -
Reducers over a
Rangeheld by a symbol use the closed form (ROADMAP CE-80). Withr := Range(1, 10^20),Sum(r),Mean(r),Median(r),Variance(r),Max(r)andMin(r)return at once; they walked every element. -
Dotof two empty lists (ROADMAP CE-220) is0for two numeric vectors and[]for two lists of points, as the compiled code gives. It stayed unevaluated. -
Compiled
Max,Minand the ordering comparisons decline an operand of typecolor,regexporobject(ROADMAP CE-393). The compiled code read such a value as a number, while the interpreter leaves the call unevaluated. With afallback, the interpreter now runs. A custom compile handler of the operator still takes precedence. -
The WGSL
Modkeeps the sign of the divisor when the quotient underflows (ROADMAP CE-246).floor(r / b)gave-0for a tiny remainder of the opposite sign, so the divisor was not added. -
NLimitof a function that rises before it decays (ROADMAP CE-184).NLimit(t ↦ 7t/(t² + 9), ∞)is about0(−2e-22); it wasNaN. 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>andcollection<vector<integer>>narrow tomatrix<integer>; the result wasnever. 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 baretuple, which means a tuple of any length. -
Absof a list or a matrix of numbers is typed from its cells (ROADMAP CE-18).Abs([1, 2])is typedlist<integer<0..>^2>; the type had asigned_infinityarm 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 writematrix<…>for two dimensions. -
Epsil: an error in the body of a
matchcase 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 }givesread-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 thescopeeffect. -
Faster type checks and set operations (ROADMAP CE-315, CE-316).
isSubtypeon a union whose arms are the same type node took exponential time in the nesting depth. Duplicate removal in a set comprehension, inIntersectionand inSymmetricDifferencecompares an element only with the elements of the same hash: 3,604 comparisons in place of 812,704 for a comprehension overRange(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_collectionorcollection, as they decline a list; such a value was put in avecN. - 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
IforWhichbehind 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
+∞, notNaN, for a zero component under a negative odd exponent, as the scalar helper does. - A one-operand
Add,Multiply,AndorOr, andPower(x, 1), from non-canonical input compile with correct grouping on JavaScript and the GPU targets.3·(x+1)^1compiled as3·x + 1. - Compiled JavaScript
\arg(x − iy)isπaty = 0and a negativex, as the interpreter gives; it was−π. compileShaderon GLSL and WGSL converts the arguments and results of trigonometric functions in degree, grad and turn modes, ascompiledoes.
- GLSL and WGSL decline a point component typed
-
Evaluation: six corrections (ROADMAP CE-99, CE-105, CE-108, CE-115, CE-121, CE-129).
- A dictionary that holds an
Errorvalue reportsisValid === false, as a list and a tuple do, andhas()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)andPipe(3, w)stayed unevaluated, whileMap(w, …)gave the error. - An error passed to a function declared with a typed signature carries the frame of that call.
SumandProductwith severalLimitsclauses, where a bound depends on another index, evaluate in either clause order:Sum(k, Limits(k, 1, n), Limits(n, 1, 10))is220.- The
.mul()method no longer stores the current value of a variable in a point or a list: withw := 4,2·(w, 2)keeps(2w, 4)and follows a later assignment ofw. - A callback written
InverseFunction(f)orDerivative(f, n)is accepted by the lazy collection operators:Map(InverseFunction(Sin), xs).
- A dictionary that holds an
-
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 aMeasurementof+oowith aNaNerror.- A
Measurementwhose error is below1e-100prints in exponential notation; it printed the text of aRangeError. GammaandFactorialat an argument whose value is past the exponent range stay unevaluated, and no longer give0to internal callers.Arcoth,Arcoshand 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⁻¹⁰)is11.8594990552752; it was11.8594990139, the value at the double.- The complex
Gammais about 100 times more accurate for an argument withRe zabove 2 (relative error 1.6e-15 on the real axis, it was 2e-13), soHypergeometric2F1with a complexzis 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 integernup to 200 and a negativezhas 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 aslist<list^3>, which the type parser refuses. - An element-wise comparison over a list that can be absent as a whole,
L < 3withL: list<integer> | missing, is typedlist<boolean> | missing; it waslist<boolean | missing>.
- The meet of a fixed-length list with a plain list of lists is
-
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).
Solvefinds 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.DandIntegrateover a parameter of a user function use the argument of the call: withg(f) := \frac{d}{dx} f,g(x^2)is2x; it was0. This also holds when a global symbol has the name of the parameter, and for an argument that holds it: withg(f) := \int f\,dx,g(f + x)isf·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 ofRound; it gave0..N()gives63.1177…on[0, 64]. - A definite integral of
|u|orsgn(u)with a quadraticuis split at the real roots ofu:\int_{-1}^{1} |x^2 - 1/4|\,dxis1/2. TrigReducereduces each element of a list and each side of an equation (it rewrote them in exponential form), andTrigReduce(Missing)staysMissing.SumandProductoverElement(j, Range(1, k))read the value ofk, asLimitsdoes.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, sosin(−i) + sin(i)is0, and simplifies the numerator of a quotient as a term of a sum:(Si(1) − Si(−1))/yis2Si(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 anddon the left side, whiled=\,2was["Equal", "d", 2]: the shape depended on where the space was. It is now["Equal", "d", 2], asd\,=2,\quad d=2andx\,+\,y(["Add", "x", "y"]) are. The canonical form already left the spacing out. The raw form keeps it, and so does thestructuralproperty of a raw expression. A space still separates two tokens:x\,.75e2isxtimes75. -
Meanand 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 ofMean,Median,Mode,Variance,PopulationVariance,StandardDeviation,PopulationStandardDeviation,Kurtosis,Skewness,Quartiles,InterquartileRange,Histogram,BinCounts,Covariance,PopulationCovarianceandCorrelationis 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 ofcollection<any>. A matrix, a symbol declaredmatrix, a ragged list, a collection of booleans, strings or residue classes, and a dictionary are now invalid when they are boxed, with anincompatible-typeerror that names the data parameter. Vectors, sets, ranges, tuples, data withMissingorNothingcells, measurements and symbolic data are accepted as before. The validation now reads the rank of acollection<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 inferredcollection<nothing | number> | distribution | number. - In
Histogram(P + 0.5, 1/100)withPof unknown type,Pis inferredlist<number>(it wasmatrix). - The inferred parameter of a user function
xs ↦ Mean(xs)iscollection<nothing | number> | value.
- A symbol with no value used as statistics data (
-
A
Returnin an operand ofSum,Product,Integrate,D,ND,NIntegrate,LimitorNLimitis 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 aReturnthere has no function to end. It stayed in the value:Sum(1 + Return("q"), (k, 1, 2))was2 + 2Return("q"), andD(Return(x^2), x)was2x·Apply(Derivative("Return", 1), x^2). The expression is now invalid, with the errorreturn-in-expression-bodynaming the operator. AReturninside a function literal in the body ends that function, as before, and aReturninsideHoldis data. -
x/0waits for the value ofx(ROADMAP CE-95, Tycho row 413). Withx: realand no value,x/0was canonicalized toComplexInfinity, so\{x/0 > 0: 1, 0\}compiled to(0 < Infinity) ? 1 : 0and gave 1 at everyx, includingx = 0, where the value is0/0. A quotient by the number 0 now folds toComplexInfinityonly 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 staysx/0throughevaluate(),.N()andsimplify(), and the value decides:Indeterminateatx = 0,ComplexInfinityelsewhere. 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 atx = 0. A limit does not take such a quotient as its value:lim_{x→a⁺} x/(x−a)with no assumption onastays 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
iof the vector to rowiof the matrix:[1, 2] + [[1, 2, 3], [4, 5, 6]]is[[2, 3, 4], [6, 7, 8]], typedmatrix<integer^(2x3)>. It wasError("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 andLessdid. The value is now the same on every route; a compiledSumover 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 anincompatible-dimensionserror. 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()answerstruefor 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 })isfalse, andcouldMatch('list<integer>', { nonEmpty: true })istrue. Only the outer collection must be non-empty:[[]]is a value of bothlist<list<number>>andlist<list<string>>. ThecouldMatchdocumentation no longer says thatlist<integer>could not be alist<string>.
Resolved Issues
-
FindFitwithglobal: truesearches 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 θ)andsec(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² θ)andsin(cos θ)keep their period. -
The type of an element-wise operation over a list of tensors or a
broadcastableoperand (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 typedlist<broadcastable<…>>: withU: vector<real^3>andM: list<broadcastable<number>>,U + Mislist<broadcastable<number>>. It waslist<broadcastable<number> | list<any>>, an arm with no layout on the compiled routes.Sin(T)withT: list<tensor<number>>was typedtensor, 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: withB: broadcastable<number>holding a 3 by 2 matrix,U + B,U^BandU < Bevaluate to 3 by 2 matrices, but were typedvector<3>andlist<boolean^3>. They are now typedtensorandtensor<boolean>. A union with abroadcastablearm (broadcastable<real> | integer) is read the same way. U·Bwas typedbroadcastable<real>, the cells ofU: withB = iit is[i, 2i, 3i]. A product with abroadcastable<E>factor beside a collection now takes the tier ofE(tensorhere). A product of a list literal and such a factor ([1, 2]·b) isbroadcastable<number>, notvector<2>: whenbholds 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]withP: collection<number>) is now typedtensor<boolean>, notlist<boolean^2>:Sin(P)is typedbroadcastable<number>, which admits a matrix. - A list whose elements are each a number or a tensor
(
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 typevalue, and the engine kept the number rules (c/cis 1,c - cis 0) away from it by looking for an operator namedResidueClass. A head with another name got no protection: withMyModdeclared(any, any) -> value,MyMod(2, 4)/MyMod(2, 4)simplified to1, although 2 has no inverse modulo 4. The type now does what the name did:ResidueClass(k, n)is typedresidue, arithmetic over classes too (c · [1, 2]is alist<residue^2>,NaN + cisnan | residue), andℤ/nℤis aset<residue>.residueis a value, not a number: it is disjoint from every numeric type,residue | integeris a union, and a symbol declaredresiduerefuses 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) nameresiduein their parameter types. Every other operator refuses a class at boxing with anincompatible-typeerror:Sin(c),Floor(c),Mod(c, 5)were admitted and stayed unevaluated. Theincompatible-typeerror of these nine operators names the new parameter type:Sqrt("banana")reports "expectedcomplex | infinity | residue", where it reportedcomplex | infinity.Max(c, x)andMin(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 typederrorwhen it is boxed and evaluates to anincompatible-typeerror (Max(c, x)was typednumberand 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 declaredresidue, which is protected before it has a value (r/rstaysr/r). A head with another name gets the admission and the protection, not the arithmetic ofResidueClass: 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 typedvalueor left undeclared is still read as a possible number:w/wis1for it. - A juxtaposed class is a product:
\overline{3}_{7}xparses toMultiply, not to a tuple.compile()declines an expression typedresidueor one that holds a class; an expression that only may hold one (a parameter typednumber | residue) compiles. The documentation lists the type indoc/08-guide-types.md, the rules indocs/TYPE-SYSTEM.md, and how an operator declares it indoc/06-guide-augmenting.md.
-
A comparison with an absent number is absent (ROADMAP CE-93, Tycho row 411). With
w: number | missingholdingMissing,w > 0wasFalse: a numeric slot read its absent value asNaN. ButIf(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 nowMissing, in a numeric slot too, and it is typedboolean | missing. The broadcast form agrees:[1, 2] < wis[Missing, Missing]. ANaNis a value, and a comparison with it keeps the IEEE answer; arithmetic still reads an absent number asNaN(w + 1 < 2isFalse). Compiled JavaScript and Python give the absent value (undefined,None) when such an operand is passed absent, where JavaScript gavefalseand Python raised aTypeError; a chain is read link by link, so5 < 0 < wisfalse. On GLSL and WGSL an absent number can only beNaN, 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; withfallback(the default)compile()runs the interpreter. One case compiled numeric code cannot tell apart: a local variable that holds theNaNof anIfwith 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 thatgeneratorselects. The generator can be an exact radical (√2,∛2),ImaginaryUnit, or the newAlgebraicGenerator(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 - 2stays 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)^2is 2: for α a root of t² - 2,α^2is 2 andExpand((x - α)(x + α))isx^2 - 2. The one- and two-argument forms ofFactorare unchanged. Examples and limits: number-field factoring.
Performance
Solveof a polynomial whose roots have closed forms is about 4 times faster (ROADMAP CE-385).x^4 + x^2 - 1 = 0takes 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 powera·xⁿ + b, or a polynomial inxᵍ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)andExpand((x + 1)^3)take about 0.7 of the time,evaluate()andsimplify()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, noNegate, noDivideand 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 ≠ xtakes its first arm (ROADMAP CE-125).NaN ≠ 2isTrue, butIf(NaN ≠ 2, 1, 0)and, withbholdingNaN,Which(b ≠ 2, 1, True, 0)took no arm:Missingin the interpreter,NaNin compiled JavaScript and Python. A statement-formIfran neither branch. The element-wise form gave the IEEE answer, soWhich([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 valueTrueof a≠with aNaNoperand, as it already used the valueFalseof<,≤,>,≥and=: the examples give1on 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
NaNpair isFalse(ROADMAP CE-94, Tycho row 413).0 < NaN < MissingwasMissing, while0 < i < MissingwasFalse: a pair with no order stopped the chain, but aNaNpair did not, and the absent operand won. A pair with aNaNoperand now makes the chainFalse, wherever the absent operand sits (Missing < 0 < NaNisFalsetoo), on the interpreter and in compiled JavaScript and Python, which gaveundefined/None. A chain whose every pair has the absent operand stays absent:NaN < Missing < 0isMissing.Undefinedis read as absent in a chain asMissingis:NaN < Undefined < 0andi < Undefined < 0areMissing, where they wereFalse. Also fixed: compiled code decides a branch over a chain link by link, as the interpreter does, so withx = 1andw(number | missing) absent,If(x < 0 < w, 1, 2)gives2, where it gaveNaN. Rule:docs/ERROR-MODEL.md. -
.N()of an integral whose integrand has no value isNaN(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.NIntegrateand the compiled routes gaveNaN. When every sample is the undefined value (NaN, a selection with no selected arm, arithmetic withMissing), the integral is nowNaNunder.N(), also for a double integral;∫₀¹ t·Missing dtisNaNtoo. An integrand that still holds a free symbol (∫₀¹ t·D dt, an undeclaredg(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 becomesReduce(L, Multiply, 1)at canonicalization, and the type of aReduceis the reducer's own result type, which isunknownforMultiplyover an element of unknown type: withL: list<unknown>,Sum(Rest(L))was abroadcastable<number>andProduct(Rest(L))wasunknown; withV: list<vector<2>>,Product(Rest(V))wasunknowntoo. A seededAddorMultiplyfold with the identity as its seed is now typed asSumorProducttype the collection when the elements are of unknown type or are numbers or rows:Product(Rest(L))is abroadcastable<number>andProduct(Rest(V))isinteger | vector<2>(the seed for an empty list, or a row). A tuple arm keeps the reducer's typing, andScankeeps its running values. -
Compiled
EqualandNotEqualread aNaNoperand as absent and answeredMissingwhere the interpreter answersFalseorTrue(ROADMAP CE-386). Withg := t ↦ [t, 2t]andx: any, the elementg(x)[5]atx = 3isNaN(an index past the end of a list of numbers), and the interpreter answersFalseforg(x)[5] = 1andTrueforg(x)[5] ≠ 1. The compiled equality over an operand that may hold an object-domain absence (here typedmissing | number | vector<2>) carried a guard that readNaNas absent, and answeredMissing. The guard now tests the object absence only (undefined): aNaNoperand compares as IEEE says on both routes, andg(x)[5] = 1atx = "a", where the element is the object absence, still answersMissingon both.IsMissingandCoalescekeep readingNaNas absent. The guard is also emitted for an element read or a call typedanyorunknown, which admits the absent value without amissingarm (a declared symbol of such a type stays the scalar the host passes): withg: (unknown) -> list, the elementg(x)[1]is typedany, andg(x)[1] = 1atx = []answeredNaNwhere the interpreter answersMissing. 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 orNaN(ROADMAP CE-391).evaluate()gives+∞for∫₀^∞ eˣ dx, but.N()gave a Monte-Carlo estimate that changed at each run (5007912 ± 2900000), andNaNfor∫₀^∞ x dx. When the integrand has one sign on the interval and does not decay faster than1/x(or overflows) on the infinite tail,.N(),NIntegrateand a compiled integral now give+∞or−∞. When the sign changes,.N()andNIntegratetake the answer of the exact route, as compiled code already did with the closed form:∫₀^∞ (x − 1) dxis+∞and∫₀^∞ (eˣ − 5) dxis+∞(it was a Monte-Carlo value). An integral with no value (∫₀^∞ sin x dx,∫_{−∞}^∞ x dx) still givesNaN. -
.N()of∫₀^∞ x¹⁰⁰·e⁻ˣ dxwas wrong by 45 orders of magnitude (ROADMAP CE-394). It gave1.9·10¹¹² ± 1.8·10¹¹²; the value is100! ≈ 9.3·10¹⁵⁷. In double precisionx¹⁰⁰is∞ande⁻ˣis0pastx ≈ 1200, so the integrand wasNaNon 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 asexp(100·ln x − x), on.N(),NIntegrateand compiled code: the result is100!to 10 digits. An integral too large for the change of variable of the quadrature (170!) isNaN, 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³ dxand∫₀^∞ −1/x³ dxgaveComplexInfinity, 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 isIndeterminate. -
A function that receives itself as an argument and recurses through a lazy
Mapstayed unevaluated (a regression in 0.160.0, #470, ROADMAP CE-387). WithG = (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 theApply(…)back, where 0.159.0 gave2;ReduceandSumin place ofMaxfailed 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 lazyMapthen readselfwith 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 capturedxas a number cell, whilex ↦ [x]is(unknown) -> list<unknown>since 0.162.0. The value["a"]ofg("a")()does not fit avector<1>. The derivation of a signature now types a nested literal again with the parameters of the enclosing literals added to its own:gis(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 earlyReturnof its body; a call of the nested literal inside the body is typed from the derived result. -
Solveof 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¹² = 0gave the root10¹², whose value is10¹², 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 = 0held 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 = 0gives−1,1/2and2(it gave0.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]andxfree, the signature ofgis(unknown) -> list<unknown>, so the engine typesg(x)[1]asunknown, and the complex lane of the JavaScript target declinedg(x)[1] + g(x)[2]("scalar arithmetic over a list-valued operand");Trace(m(x)) + 1withm := t ↦ [[t, 1], [2, 3]]declined the same way, andSum(1 + v(x)[2], k from 1 to 3)withv := s ↦ [s, k]andk = ideclined in every mode. The emitted code reads an untyped free input as one scalar (x + 1is_.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 avector<2>,g(x)[1]is anumber, 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] + 1withv := s ↦ [s, 2s]compiles to(_fn_v(x)).y + 1.0on GLSL and WGSL, where it declined ("the base (typelist<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) + 1withh: (unknown) -> unknownand no body, names the operand and its type ("a possibly list-valued operand (h(x), of typeunknown)"), where it said "a list-valued operand". -
Compiled JavaScript gave
NaNorfalsefor an absent element where the interpreter givesMissing(ROADMAP CE-368). Withg := t ↦ [t, 2t]andx: any, the elementg(x)[5]is absent atx = [1, 2, 3](the index is past the end) and atx = [], and the interpreter answersMissingforg(x)[5] + 1,Sin(g(x)[5]),-g(x)[5],g(x)[5]·g(x)[5]andg(x)[5] < 1. The compiled code spelled the absent elementundefinedand applied the scalar function to it:NaN, orfalsefor the comparison. The run-time broadcast (_SYS.bcast) and the checked product (_SYS.mulChecked) now answerundefinedwhen an operand is absent beside scalars only, and a compiled comparison answersundefinedfor an absent cell. Beside a present list the absent operand is stillNaNin each cell (g(x)[5] + [1, 2]is[NaN, NaN]on both routes, and so isg(x)[5] + g(x)[1]atx = [1, 2]), and a comparison with a list answers an absent cell for each (g(x)[5] < [1, 2]is[Missing, Missing]). The compiledSumof a list with an absent element is unchanged (NaN, a numeric slot, as in the interpreter). The ten tests oftycho-row-425-index-of-broadcastable-call.test.tsthat recorded the divergence are agreement checks again. -
A surplus tuple operand of
Numerator,DenominatororNumeratorDenominatorgave a tuple of errors (#467, contributed by enumeratio). A call with an operand past the declared arity is oneunexpected-argumenterror 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 waslist<…>ortuple<…>): it keeps the declared result type of the operator,nothing | numberforNumeratorandDenominatorandnothing | tuple<number, number>forNumeratorDenominator. -
simplify()gaveFalsefor a relation whose two sides are the same function call.f(a) = f(a),sin(a) = sin(a)andg(x) + 1 = g(x) + 1simplified toFalse, andf(a) ≠ f(a)toTrue;evaluate()gave the correct values. The simplifier rewrote the relation asf(a) - f(a) = 0, that is0 = 0, and the factoring step then divided both sides by their common coefficient, 0, which gaveNaN = NaN. Such a relation now stays as it is, asx = xdoes, andFactor(0 = 0)is0 = 0(it wasNaN = NaN). The same error madeEqual(c, c)Falsefor a valuecof 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, andMax,Meanand the other statistics) disagreed with the interpreter:- With
L: list<unknown>,Sum(L) + 1andProduct(L) · 2did not compile, althoughLusually holds real numbers. They now compile, and a complex entry of the fold is added as complex: over[[1, 2], [i, 3]],Sum(L) + 1is[2 + i, 6]andReduce(L, Add, 0) + 1is[2 + i, 6]; the second gave["[object Object]1", 6]. In thecomplexmode, the same parents overlist<list<number>>compile too, and a barelistis read as a list whose cells may be complex, aslist<unknown>is:L + 1over[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
nullas 0 in+,*andMath.min. WithL: list<number | missing>and[2, null, 3],Scan(L, Multiply, 1)gave[2, 0, 0],Reduce(L, Add, 0)gave5andReduce(L, Min)gave0;Reduce(L, Multiply, ImaginaryUnit)threw. Each is nowNaNfrom the absent element on, as in the interpreter ([2, NaN, NaN],NaN). TheProductandSumof 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 asNaN. - The
SumandProductof a list of booleans or strings (list<boolean>,list<list<string>>), theReduceandScanof a list of booleans withAdd,Multiply,MinorMax, and theMax,MinandMeanof a list of booleans no longer compile; the interpreter gives anincompatible-typeerror or leavesMax(True, False)unevaluated. The compiledSumof[[true], [false]]was[1]. Withfallback(the default),compile()runs the interpreter for these.
- With
-
Compiled
Multiplyof a fold and a matrix did not compile (ROADMAP CE-388). WithM: matrix<number>andL: list<matrix<number>>(orlist<tensor<number>>, orlist<unknown>),Sum(L)·M,M·Reduce(L, Add, 0)and2·Sum(L)·Mwere refused at compile time, socompile()ran the interpreter. They now compile to the matrix product, decided on the shapes of the values as in the interpreter:Sum(L)·Mis[[2, 2], [5, 3]]forL = [[[1, 2], [3, 4]], [[1, 0], [0, 1]]]andM = [[0, 1], [1, 0]]. With complex entries (L: list<matrix<complex>>, or anAt(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 overlist<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. -
Multiplyof a lazy collection and a matrix paired their elements (ROADMAP CE-390). The value ofScan,ReverseorMapis a lazy collection, andMultiplyread it as a plain collection: beside a matrix, it paired each element with a row of the matrix. WithLholding the vectors[1, 2]and[3, 4]andM = [[0, 1], [1, 0]],Reverse(L)·Mwas[[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)·Mover 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 typedlist<list<integer | missing>>, while[[2, 3], [3, 4]]is amatrix<integer^(2x2)>. So it could not be assigned to a symbol declaredmatrix<(number | missing)^(2x2)>,ShapeandRankof it were()and0,Multiplyof two such matrices paired their rows, andDeterminantandInversewere anincompatible-typeerror. 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 asNaNwhere 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), itsDeterminantisNaN, and itsTrace, which reads the diagonal only, is6.Transpose,Flattenand 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 typedlist<(integer | missing)^2>, andSin([Missing, 1])vector<2>.
0.162.0 2026-10-23
Breaking Changes
-
Multiplyof 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, asMatrixMultiply, also when the factors are symbols typedtensor<number>,list<list<number>>,list,any,broadcastable<number>ornumber | matrix, and under.N(). Before, a product of two such symbols multiplied each entry (and.N()did so even formatrix<number>symbols), while a literal or computed factor gave the matrix product; sop·pnow equalsp^2. Migration: useHadamardProduct(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·pstaysq·p;p·q − q·pis no longer 0), andisSame()and the hash tell them apart.simplify(),Expand,FactorandDkeep that order:(A·B)^2is no longerA^2·B^2, the product rule ofDkeeps each factor in place, andD(f^n)for a matrix-valuedfuses 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>ismatrix<2x4>). - Two tensors of rank 3 of the same length multiply each pair of matrices, as
Powerdoes; a tensor of rank 3 beside a matrix or a vector stays unevaluated; a number times a tensor of rank 3 scales every cell; aRangebeside a matrix is read as a vector. - Compiled JavaScript:
matrix<…>operands call the matrix product;tensor<number>,list<list<number>>andbroadcastable<number>choose by the shape of the values when the code runs. Python usesnp.matmulfor matrix types. interval-js throws (its result is thenentire) for a list of lists times another list. Types that may hold points (barelist,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 wasnullor wrong values). Two tensors of rank 3 multiply each pair of matrices, as in the interpreter (it threw, and a compiledProductgaveNaNentries); a tensor of rank 3 beside a matrix or a vector throws when the code runs. Where the interpreter gives anincompatible-dimensionserror, compiled code givesNaN. A compiledMultiplyfold (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 compiledSumstill checks that its elements have one shape. - A product boxed while its symbols are
unknownis 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^-1is the matrix product). - The
Derivativeof a function declared with codomainanykeeps that signature (it was typed(any*) -> number), so expressions built on it are typedanytoo.
- The canonical form keeps the written order of factors that may be matrices
(
-
has()on a dictionary is the symbol test; the key test ishasKey().has(v)asks whether the symbol (or function operator)voccurs 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')wasfalseand.has('a')wastrue; now.has('u')istrue,.has('a')isfalse(a key is a string, not a symbol, unlessaalso occurs in a value), and.has('Dictionary')istrue. The key test moves to the new methodhasKey(key)ofDictionaryInterface(records included). Migration: replacedict.has(key)withdict.hasKey(key)wherever you test whether a dictionary or a record has a key. A calldict.has(key)still compiles, becausehas()exists on every expression, and now returns a different answer. The other symbol queries read dictionary values too:.symbols,.unknowns,.freeVariables,.subexpressions,.errorsand the references of a dictionary, or of an expression that holds one, now include what its values hold (.errorsof{"a" -> Error("oops")}lists the error, though the dictionary stays valid).has(),.subexpressionsand these queries visit a value shared by several entries once. -
A declared type is a contract on every value the symbol takes.
s: stringnow refusesMissingandUndefinedas it refuses5, on the engine's assignment route and in Epsil (static check and run time alike); a declared element contract is checked too, soxs: list<string>refuses["a", Missing]. An author who wants absence writes it:string | missing,list<string | missing>, or a refining declaration (list,list<unknown>). Anumberdeclaration still admitsNaN, the numeric absence marker, so a numeric restriction such as3 {a > 0}(typedinteger | nan) binds ton: numberas before; a restricted non-numeric value such as(1, 2) {a > 0}(typedmissing | tuple<integer, integer>) needstuple<number, number> | missing. This reverses the rule of 2026-09-09 under which a declared type described the successes only and a symbol declaredstringcould reportstringwhile holdingMissing. Decision P1 and P1b ofdocs/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-typeerror 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)andIsSquareMatrix(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)isNaN,Reverse(Missing)isMissing), operators that own their absence semantics (Length,Equal, the reducers,Coalesce,IsMissing), operators that hold their operands (Expand,Integrate,ForAll), and a parameter typedexpression,symbol,anyorunknown. 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>andset<T>now readTas the type of the elements, the values that indexing returns. A matrix of integers is alist<vector<integer>>and alist<list<integer>>. It is no longer alist<integer>. Before, the subtype relation also readTas the type of the cells (a list type with no dimensions "admitted any shape"), while indexing, the library and the compiler readTas the element type. The two readings together let a wrong value through: withL: list<number>, the assignmentL := [[1, 2], [3, 4]]was accepted,L[1] + 1was typednumberand gave[2, 3],Mean(L)was accepted and failed at run time, and a compiledSum(L)gave the string"01,23,4". Now the assignment is refused: a value of typematrix<integer^(2x2)>is not compatible withlist<number>.list<integer>andlist<list<integer>>are not related. Their meet islist<never>, the type of the empty list[], which is a value of both. For the same reasoncouldMatch()andtypesOverlap()answertruefor two lists with no dimensions, whatever their elements.vector<U>,matrix<U>,tensor<U>andlist<U^dims>readUas the type of the cells, the values at the depth of the rank. The elements of a matrix are its rows:matrix<integer>is alist<vector<integer>>. Alist<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,matrixandtensorhave cells and may have a shape, written in one place: two rows of three integers arematrix<integer^(2x3)>, and a 2×3×4 array istensor<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. Writematrix<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 "Writematrix<integer^(2x3)>, with the shape in one place"); writelist<list<integer>>for rows of any lengths.
vector,matrixandtensoraccept every cell type that is not a list:matrix<boolean>,matrix<string^(2x2)>,vector<string^2>(printedlist<string^2>),tensor<boolean>andtensor<tuple<number, number>>are valid. Before, avectoror amatrixcell had to be able to hold a number, somatrix<boolean>was a parse error whilematrix<any>was not. The bare namesvector,matrixandtensorstill 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 isvector<…>when the cells can hold a number andlist<E^n>otherwise, rank 2 ismatrix<…>, rank 3 or more istensor<U^(…)>, and an unknown rank istensor<U>. The types themselves do not change, only their spelling:Value Before Now [True, False]list<boolean^2>list<boolean^2>(same)[[1, 2], [3, 4]]matrix<integer^(2x2)>matrix<integer^(2x2)>(same)M < 3, withM := [[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 integers list<integer^(2x3x4)>tensor<integer^(2x3x4)>Determinantof a matrix of booleansrefused refused (same) [(1, 2), (3, 4)]is stilllist<tuple<integer, integer>^2>, andSin(M)ismatrix<2x2>.tensor<U>is an array of any rank (1 or more) with cells of typeU, and baretensoris a numeric array of any rank. Before,tensor<U>meantlist<U>(tensor<boolean>waslist<boolean>, which does not hold a matrix of booleans), and baretensormeantlist<number>. The element type of atensor<U>isU | tensor<U>: a cell, or a tensor of a lower rank. A matrix of integers matchestensor<integer>, but notlist<integer>. Alist<U>with a number typeUand avector<U>denote the same values:list<integer>matchesvector<integer>, andvector<integer>matcheslist<integer>.broadcastable<T>isT | tensor<T>for aTthat cannot hold a list (a number type,boolean,string, a tuple,integer | missing). A matrix of numbers matchesbroadcastable<number>, a matrix of integers matchesbroadcastable<integer | missing>, and alist<list<number>>does not matchbroadcastable<number>(its rows can have different lengths).broadcastable<boolean>isboolean | tensor<boolean>, so the matrix of booleans that a comparison over a matrix gives is one of its values, andbroadcastable<tuple<number, number>>is a point or an array of points of any rank.broadcastable<T>no longer matchesT | list<T>: to accept a number or a numeric array of any rank, writenumber | tensor, and for booleansboolean | tensor<boolean>.The relation is covariant again:
vector<value>matchesvector<any>, andlist<expression^3>matcheslist<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>islist<never>. A meet of two list types isneveronly when their shapes cannot be the same:vector<integer^2> & vector<integer^3>isnever.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 resultRthat cannot hold a list. WithU: list<integer> | matrix<integer>,U + 1istensor<integer>(it waslist<integer | vector<integer>>). WithT: tensor<integer>,T < 3istensor<boolean>. With an operator declaredbroadcastable: trueand the signature(number, number) -> number, the call with[[1, 2], [3, 4]]and[10, 20]istensor(it waslist<number>). Mapover a matrix whose function keeps the length of the rows keeps the shape of the matrix: withh: (real) -> real,map(x ↦ h(x), [[1,2],[3,4]])ismatrix<real^(2x2)>(it waslist<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
gthe literal(x: T) -> tuple<T, T> where Twith the body(x, x),g([[1, 2], [3, 4]])ismatrix<tuple<integer, integer>^(2x2)>(it waslist<tuple<integer, integer>>). Over a ragged list, see Resolved Issues. - A conditional protocol conformance
type list<T> is P where …bindsTto the elements of the receiver, as the generic solver does. For a matrix, the elements are the rows. Withtype list<T> is Named where T: number,name([1, 2])uses the conformance as before, butname([[1, 2], [3, 4]])now gives aprotocol-implementation-missingerror:Tisvector<integer^2>, which is not anumber(before,Twas the cell typeinteger). - A cell pattern
vector<T>orlist<T^N>in a generic signature bindsTto the cells of a tensor of rank 1, and a matrix no longer matches it:first: (x: list<T^N>) -> T where T, Napplied to[[1, 2], [3, 4]]is anincompatible-typeerror (it was typedvector<integer^2>). A plain patternlist<T>bindsTto the rows of the matrix. NormandTracespell their tensor parametertensorand accept a matrix as before. The result ofIntegratespells its array armtensor.Meanand 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,-bistensor(it waslist<number>, which does not hold a matrix value ofb); with no value, it isbroadcastable<number>as before. WithC: indexed_collection,C < 2islist<boolean | list<any>>(it waslist<boolean>): an element ofCmay 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 Tandu: list<integer> | set<integer>,f(u)islist<tuple<integer, integer>> | tuple<set<integer>, set<integer>>(it waslist<tuple<list<integer> | set<integer>, …>>, which holds neither value). An operand of typecollection<integer>, which may be mapped or passed whole, gets both answers. PointX,PointYandPointZof a list of points have the type of the coordinate:PointXof alist<tuple<string, number>>islist<string>(it waslist<number>), and of alist<tuple<integer, integer>>it islist<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 aRangeErrorat 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 nowT | list<T>": for aTthat cannot hold a list, the collection part is nowtensor<T>. Every subtype oflist<T>is still abroadcastable<T>, and a tuple is still not abroadcastable<number>. - "The meet (intersection) of two collection types of different kinds is the
narrower kind with the meet of the elements":
list<number>andcollection<list<integer>>no longer share a matrix. The type reducer now giveslist<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>>>andlist<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>andlist<list<integer>^2>are now parse errors. A matrix is still not avector<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>>islist<never>again, since no matrix is alist<number>, and the examplematrix<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>, andlist<vector<integer^3>>as alist<integer>are removed.matrix<string>andvector<collection>are now parse errors,list<vector<integer^1>^2>is a parse error, andlist<vector<integer^3>>is not alist<integer>. - In Resolved Issues, "Subtype relation:
matrix<T>is a subtype ofbroadcastable<T>": still true.couldMatch()still answerstrueforlist<integer>andlist<list<integer>>, but the reason is now the empty list: a matrix is not a value oflist<integer>.
The rules are in
docs/TYPE-SYSTEM.md, section "Lists, tensors andbroadcastable". - A list with two or more axes:
New Features
-
FixedPoint(f, x)appliesffromxuntil two successive values are the same. It was declared but did nothing: a call stayed unevaluated.FixedPoint(x => (x + 2/x)/2, 1.0)is1.414213562373095…, andFixedPoint(Cos, 1.0)is0.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;Ngives their numeric fixed point. The result type is the result type off. -
The presence assertion
x?!(AssertPresent(x)in MathJSON,⁈is an alias): the value ofx, or anexpected-presenterror naming the operand whenxis absent (Missing,Undefined,NaN,Indeterminate, the setisMissingrecognizes).Nothingpasses through, an error operand propagates. Its type is the present part of the operand's type:xs[i]?!withxs: list<string>is astring, sof(xs[i]?!)is accepted at a parameter that excludes absence, andlet y: string = xs[i]?!too. It is a postfix operator at the factorial's precedence and must abut its operand; as for3! + 1, writex?! + 1, notx?!+1. Compiled: JavaScript, the interval JavaScript target and Python raise with the messageexpected-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 withoutnan); 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
isMissingguard proves present.if isMissing(v) { "none" } else { f(v) },if !isMissing(v) && v != "b" { f(v) }andisMissing(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 afunction'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 areal,integerorcomplexdeclaration is reported too, since these types excludeNaN:let r: real = zs[i]forzs: list<real>(typednan | real) was refused only when the program ran; writezs[i]?!,zs[i] ?? 0, or declarereal | nan. A value with another type outside the declaration, such asa / 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²)) dzis now√(π/2)·√(s²)·Erf(z/(√2·√(s²))), and∫ exp(z²/(2s²)) dzthe same form withErfi; 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²} dzwith a bareastays unevaluated, because ata < 0theErfform needs an imaginary argument, which the compile targets do not have, and so do∫ e^{−s² z²} dzand∫ e^{−|a| z²} dz, whose closed form is0/0ats = 0ora = 0while the integrand is 1. A symbol declaredcomplexdisqualifies the reading. A definite integral with an infinite bound carries the condition the way∫₀^∞ e^{−cx} dxis1/c {0 < c}:∫_{−∞}^{∞} exp(−z²/(2s²)) dzis√(2π)·√(s²) {0 < √(s⁻²)}, and the normal distribution function1/(√(2π)s) ∫_{−∞}^{x} exp(−½ z²/s²) dz, with or without a mean, is a guarded closed form inErf. Its compiled JavaScript callserfinstead 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,valueorunknown. 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.FindRootandFindFitare typed as their result record:FindRoot(x^2 - 2, [(x, 1)])isrecord{parameters: record{x: real}, converged: boolean, residualNorm: nan | real | signed_infinity, iterations: integer, stationary: boolean}(FindFithas nostationary). So the Epsil static check reportslet 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,SingularValuesandCharacteristicPolynomialcarry the dimensions and the cell type of the result:PseudoInverseof a 3×2 matrix is a 2×3 matrix, andIdentityMatrix(3)ismatrix<integer^(3x3)>. TrigExpand,TrigToExpandTrigReduceof a numeric operand arenumber;TupleFromof a list of known length is a tuple of that many components (TupleFrom([1, 2, 3])istuple<integer, integer, integer>).- A pipe whose stage is the name of a library operator is typed as the call:
16 |> Sqrtisreal(it wasunknown), and[3, 1, 2] |> Sortislist<integer>. So the static check reportslet 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, butg(Set(1, 2))is[Set(1, 2)]andg("a")is["a"]. The signature is now(unknown) -> list<unknown>:x ↦ [x, 1]and(x, y) ↦ [x, y]givelist<unknown>, andx ↦ [[x]]giveslist<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: withm := t ↦ [[t, 1], [2, 3]],m(2)is amatrix<integer^(2x2)>andm(r)withr: realamatrix<real^(2x2)>; a call with a free symbol is typed by the signature,list<list<unknown>>. The reading of an operand typedunknownas a number stays where an operation demands a number:x ↦ x + 1is still(unknown) -> number. Consequences for a function with such a signature called with a free or anany-typed argument: an index into the call is typedmissing | unknown(it wasmissing | number | vector<2>), a sum of its elementsbroadcastable<number>, andDotof such an index the widemissing | value; the values are unchanged, and each type holds its value. Withf(x) := (-x, x),h(a) := f([a, a])is typed(unknown) -> list<tuple<number, number>>as before (the call mapsfover the list), and an integral over a list integrand keeps its shape (∫₀¹ [x, 2x] dxis avector<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)) + 1withg := t ↦ [[t, 1], [2, 3]]and a freexin the complex lane,Sum(1 + v(x)[2], …)withv := s ↦ [s, k]on the JavaScript and GLSL targets,Product(Rest(g(x)))andMax(At(m(x), 1)). The workaround is a declaration,x: numberorg: (number) -> matrix; ROADMAP CE-377 records the compiler change that removes the declines. ROADMAP CE-25; the rationale is indocs/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(anErrorexpression, or a failed application such as1/"a") is left out of the element type when another element of the same level succeeded.[1, 1/"a", 3]is now typedvector<integer^3>, as[1, 2, 3]is; it waslist<unknown>.[[1, 1/"a"], [3, 4]]ismatrix<integer^(2x2)>(it waslist<list<unknown>>), andSet(1, 1/"a")isset<integer>(it wasset<unknown>). A level whose elements all failed keeps its type:[1/"a", 1/"a"]is stilllist<unknown>, and a set whose elements all failed is stillset<unknown>. The error is still in the expression:isValidisfalseanderrorslists it. As a result, the value of an element-wiseIforWhichwith at least one successful cell matches the static type of the expression:If([True, Missing, False], 1, 0)is typedlist<integer>and evaluates to[1, Error(…), 0], typedvector<integer^3>; that value was typedlist<unknown>. A tuple is unchanged:(1, 1/"a")is still typederror.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 expliciterrorarm). Withs: list<number>,s := [1, Error("oops")]throws, and theAssignandDeclareoperators give anincompatible-typeerror. The message now names the failed element ("The value holds the failed elementError("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 typedlist<unknown>. A parameter binding does not refuse it: a function whose parameter is declaredlist<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), andxs |> literalwhen 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: withM: list<real>andp(n) = s·n,Apply(a ↦ Map(p, a), M)islist<number>(it wascollection<unknown>), and'abc' |> (s ↦ s)isstring. The derivation stops (and keeps the literal's own result type) for a body with an earlyReturn, 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
Returnvalues, andNothingwhen it can return nothing (Return(), or a final emptyBlock):x ↦ Block(If(x > 1, Return('big')), 2x)is-> number | string. - A
SumorProductindex with the same name as a parameter no longer sets that parameter's type. Mapwith a callback whose result type isunknownhas elements of typeunknown:Map(u ↦ Q, M)islist<unknown>(it waslist<real>).
- A function literal's result type includes its early
-
Inside a function or a
Block, a stored value takes the current values of the variables it names; a closure keeps the variable. WhenAssign,Declarewith 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: withf := x ↦ Block(Declare(r), Assign(r, If(x > y, x, 0)), Assign(x, 100), r),f(3)isIf(y < 3, 3, 0), and a block local no longer escapes its block unbound (it gaveIf(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 wasu ↦ 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), andFilterlikewise. -
Compiled
Powerof a value whose depth is not known follows the interpreter's matrix power. For a base typedtensor<number>,tensor,list<list<number>>orbroadcastable<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]]^2is[[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 throwsexpected-square-matrix, as the interpreter answers; this now also holds forMatrixPower,InverseandPowerovermatrix<…>, which returnedNaN. When compiled code cannot tell whether the exponent is exact (for examplep^(2x)withx: 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 (barelist,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 withfunctionstakes precedence, as before. -
An index into a
broadcastable<T>whoseTis a tuple, a string, a record or a dictionary is typed by what it can return. Withp: broadcastable<tuple<number, number>>holding(1, 2),At(p, 1)is1, but it was typedmissing | tensor<tuple<number, number>> | tuple<number, number>, andTake(p, 1)is[1], but it was typedlist<tuple<number, number>>. Withp: broadcastable<string>holding"abc",At(p, 1)is the character"a", but it was typedmissing | 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 ofTreturns: the slots of a tuple (anothingslot included),characterfor a string, the field types of a record (anunknownfield included) and the value type of a dictionary, also for such an arm of a unionT.At(p, 1)is now typedmissing | number | tensor<tuple<number, number>> | tuple<number, number>, and a selector overbroadcastable<string>is typedlist<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)gaveMissing; it now givesNaN, 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>, abroadcastableof one, an alias of one,string | list<string>on Python) now decline, so thefallbackoption decides: they read a string by UTF-16 code unit or by code point, soAt(p, 1)of"abc"gaveundefined(JavaScript) orNaN(Python), andFirst(p)of"😀b"half of a surrogate pair. Such an operand that holds a list of strings, which compiled correctly, now declines too. JavaScriptFirst,SecondandThirdof an operand typedany,valueorbroadcastable<any>decline, asAtandLastalready did. -
An index into a
broadcastable<T>value is typed by what it can return (Tycho row 425). Withg(t) = [t, 2t]andx: any,g(x)is typedbroadcastable<vector<2>>: a numberxgives a vector, a listxa list of vectors.g(x)[1]is now typedmissing | number | vector<2>(it wasmissing | vector<2>, which does not hold the number thatg(3)[1]gives), soRepeat(0, g(x)[1])is valid. The same reading applies toFirst,Second,Third,LastandAtwith a list of indices, and to the elements ofTake,Drop,Rest,MostandSliceof such a value. Other changes that follow:Sum,Product, andReducewithAddand 0 orMultiplyand 1, over elements that may be numbers or rows are typed as a number or a row (Sum(Rest(m(x)))withm(t) = [[t, 2t], [3t, 4t]]isinteger | matrix<2x2> | vector<2>; it wasnumber), withnanwhen the operand may be absent.- Arithmetic on a union of lists with dimensions maps over the list:
2·vvforvv: vector<2> | vector<3>islist<number>(it wasnumber). Product,ReduceandFoldwithMultiplyover elements that are matrices or vectors are accepted (they wereincompatible-type).PointX/PointYandDotof a union of numbers and vectors are typed from the vector arms;PointXofstring | list<string>reads the character or element at the position.- Compiled JavaScript
At,First,Second,ThirdandLastover elements that may be numbers or lists choose the out-of-range value from the value read, as the interpreter does:NaNfor a list of numbers,Missingotherwise.Last([])of a list that is not of numbers (a matrix included) is nowMissing(it wasNaN). - Compiled
SumandReducewithAddover elements that may differ in shape throw when two elements have different shapes, as the interpreter answersincompatible-dimensions; compiledProductover such elements declines. - Compiled
Repeatthrows when its count is not an integer when the code runs (it rounded the count, or returned[]). - Compiled JavaScript
Max,Min,Mean,MedianandVarianceover elements that may be numbers or lists, or over an operand that may be absent and holds lists, decline (they gaveNaN). PythonMean,MedianandVarianceof atensororbroadcastableoperand check the rank when the code runs and raise for a matrix; Python folds over atensor<number>decline.
-
Element-wise
IfandWhichover a mask of any rank. A condition that is a nested list ofTrue/False/Missingnow selects at every level. It stayed unevaluated before. WithM := [[1, 2], [3, 4]]:If(M < 3, M, 0)is[[1, 2], [0, 0]], typedmatrix<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
Addrefuses it:If(M < 3, [10, 20], 0)isError("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 aRangeErrorat run time when a nested mask meets an array element. Declare such an armmatrixorlist<tuple<…>>to avoid it. A mask that is flat at run time is not affected. -
The types of
IfandWhichhold every value they can give.- A condition of unknown rank types the result
list<C | list<any>>, whereCjoins the arms:Which(C < 2, 1, True, 0)withC: indexed_collectionislist<integer | list<any>>(it waslist<integer>). - A
broadcastable<T>arm contributesT: withB: broadcastable<number>,If(B < 0, -B, B)isbroadcastable<number> | list<list<any> | number>. It wasbroadcastable<broadcastable<number>>, a type with no meaning. Thelistoption 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.
- A condition of unknown rank types the result
-
The Python and interval-JavaScript targets refuse a condition that may be a list in
If,WhichandWhen, so thefallbackoption decides. WithB: broadcastable<number>, Python emitted0 if np.less(B, 0) else B, which raises for an array of two or more elements, and the interval target gaveemptyforIf(B < 0, 0, B). The compile ofB < 0on its own on Python no longer overflows the stack. GLSL and WGSL do not change. On JavaScript,When(v, B < 0)withB: broadcastable<number>now restricts element by element whenBholds a list: it compiled as one scalar test and gavevforB = [-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,SliceandAt(also with several indices) are linear in their container for a fixed selection, asPointX/PointY/PointZalready 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 scalar0: withQ := (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, wherecompile()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 owncompilehandler, statedderivative,lazy,broadcastableor 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
NaNat 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 withNaNwhen a panel and both of its halves have a majority ofNaNsamples, and the integrand next to theNaNset 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}\,dxon the compiled real route was0.6667(the part below 0 was left out) and is nowNaN, as the interpreter's real route is. Removable points no longer stop the quadrature:\int_0^{64}\frac{u}{u}dxwithu = x - \lfloor x\rfloorgives 64, where the quadrature gaveNaNand only the Monte-Carlo fallback gave an approximate value. An integrand that overflows toNaNin a tail it has decayed in (the logistic density on [0, ∞)) keeps its earlier result. -
A
Dthat has no closed form is not worked out again (Tycho row 424; the fix is under Resolved Issues). When the derivative of aDnode 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
SumorProductbound 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 anincompatible-typeerror;string,boolean, a list or a tuple type, and a literal string,NaNorMissing, 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: withy: number | tupleholding(3, 4),\prod_{n=1}^{y} nstays unevaluated, where a closed form would have givenFactorial((3, 4)), a tuple. Itsjavascriptcompile answersNaNfor a value that is not a number, whereMath.floorread the string"3"as 3 andtrueas 1; a bound typed as a number,unknownoranycompiles 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
javascriptcompile,ND, and a derivative of a function with no closed form underN()) 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 isNaNwhen 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:NDof\cos(20x)at 0.05 is −16.829419, the derivative, where it was −14.55;NDof\sqrt{x}at 0.05 is 2.236068 where a sample below 0 made itNaN;NDofx\lfloor x\rfloorat 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. -
FindRootpolishes 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}froma = 1ends ata = 10^6exactly (it stopped at a residual of 0.14), and\sin(10^8 a) = 0.5at a residual of 6e-9 instead of 3e-5.FindFitis unchanged. -
FindFitandFindRootstop 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 thantolerance · 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 itsstationarykey can be true without the gradient test. Theconvergedkey of aFindFitrecord 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,FilterandTakeWhileof a string held by a symbol of a wider type give a string: withc: list<integer> | stringholding"aab",Dedup(c)is"ab", not["a", "b"], and so isDedup(Filter(c, p)).FilterandDedupover a dictionary give aDictionary, not aSetof entry tuples, also when the dictionary is an arm of a union type and through nestedFilterandDedupviews. A view over a dictionary of more than five entries prints as written (Dedup(d)).- The preview of
Map(x ↦ x + 10, c), withcdeclaredmissing | list<integer>, is the list[11, 12], notSet(11, 12). Mapof 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 typedlist<integer>and gave an error for each row.Map(Sqrt, [[1, 4], [9]])is typedlist<vector<1> | vector<2>>; it waslist<list<number>>.- The types of
FlatMapover a source with absent cells, ofMapof an operator whose declared result isany(First,PointX), ofMapover an alias of a tuple (list<number>, wascollection<number>), and ofMapover a dictionary arm of a union hold their values again.
-
The type of a
Mapdid not hold its value in two cases.Map(x ↦ If(x = 1, "x", x), [1, 2])was typedlist<integer | string^2>, and its value["x", 2]is alist<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 typedlist<integer | nan>for the value["Missing", 3]:First([])isMissing, since the element[]states no element type, and the type is nowlist<integer | missing | nan>. -
A sum with an infinite term of unknown sign folded to that infinity.
∞·c + 1evaluated to+∞for acwith no known sign: the sum read the coefficient∞of the term and ignoredc, although the sum is−∞atc < 0and0·∞atc = 0. Such a term now stays in the sum, also beside a pure infinity (∞·c + ∞is∞ − ∞atc < 0),∞·c − ∞·cisIndeterminate, and a term whose cofactor has a known sign still folds (−∞·3 + 1is−∞). Found while fixing Tycho row 428: the value of∫_{−∞}^{x} exp(−(z−m)²/(2s²)) dzat its lower bound,Erf(−∞·√(s⁻²) − m·√(s⁻²)), folded to−1and lost the condition√(s⁻²) > 0that the same integral carries atm = 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>>, boxingTrace(g(2))(the parameter ofTraceisnumber | tensor) widened the result ofgtolist<list<unknown>> | number | tensor, andDeterminant(g(x)) + 1then declined to compile ("operand is not an indexed collection"). A use now refines only a result that the body leavesunknown(k := x ↦ xused ink(1) + 2gets the resultreal, as before); a result that the body states, and that may fit the parameter, is kept. -
Dof the inverse of a matrix function stayed unevaluated. Withf: (real) -> tensor<number>andf := x ↦ [[1, x], [x, 2]], the callf(x)is now typedmatrix<real^(2x2)>from the body, sof(x)^{-1}canonicalizes toInverse(f(x)), andD(f(x)^{-1}, x)was an unevaluated derivative ofInverse(it was differentiated as a negative matrix power while the call was typedtensor).D(Inverse(f), x)is now-Inverse(f) · f' · Inverse(f), and agrees with a finite difference. -
AtandFirstrefused an operand that may be absent or of unknown type, andPointXof such an operand was typedmissing. Withg: (unknown) -> list<unknown>andx: any,g(x)[1][2]was anincompatible-typeerror (atx = [1, 2]its value is2), andPointX(g(x)[1])was typedmissing, as if always absent; it ismissing | unknown. -
Compiled
Sum,ProductandReduceover a list of elements of unknown type gavenullor a string when the elements are lists. WithL: list<unknown>andL = [[0, 1], [2, 3], [4]], the compiledSum(L)was the string"01,23,4"(the interpreter gives anincompatible-dimensionserror: the rows have different lengths), and withL = [[1, 2], [3, 4]]the compiledProduct(L)wasnull(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, andProduct(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. AMultiplyfold over elements typed as lists of cells of unknown type (list<unknown> | unknown, the elements ofRest(g(x))) still declines (ROADMAP CE-98.3). -
The compiled statistics over a
list<unknown>read each row as a number. WithL: list<unknown>andL = [[1, 2], [3, 4]], the compiledMax(L)wasNaN(the interpreter gives 4).Max,Min,Mean,Medianand the other statistics now decline such an operand, as they decline a barelistand alist<any>, and the interpreter evaluates it. -
Product,ReduceandMapover a list of ragged lists that hold matrices were anincompatible-typeerror. WithL: list<unknown>andL = [[M, [5]], [N, [2]]](M,N2×2 matrices), the elements ofLare typedlist<matrix<integer^(2x2)> | vector<integer^1>>, and the check of the callback refusedMultiplyandNegatefor them, soProduct(L)wasReduce(L, Error(incompatible-type), 1), while the product of the two elements written asp·qhad 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. Withg: (unknown) -> list<unknown>andx: any, the elementg(x)[1]is typedmissing | unknown, and it is a list whenxis a list. Compiledg(x)[1] + 1gave the string"1,21"atx = [1, 2], a product gaveNaN,g(x)[1] < 1gave onefalse, andSin(g(x)[1])was typednumber, soSin(g(x)[1]) + 1also joined strings. A value typed byunknown,anyorvaluebesidemissingornothingonly is now read as that type, both when typing and when compiling:Sin(g(x)[1])is typedbroadcastable<number> | missing, and compiled arithmetic, the cell functions (Sin,Sqrt,Abs,Floor, …), the comparisons, anIfthat selects the element, and aBlocklocal that holds it broadcast at run time, as they already did for agdeclared(unknown) -> list. They give the interpreter's values:[2, 3]and[false, false]. ABlocklocal 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, andSin(y)was typednumber. A symbol the user declaredany, a global with an assigned value and a free variable stay scalar code, as before. A numberxgives the same values as before. The cost: an index into a list whose elements are typedunknown | missing(L[i] + 1withL: list<unknown | missing>) now compiles to the run-time broadcast_SYS.bcast, as an index into alist<unknown>or alist<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 callf(L)for a listLevaluates to a list of pairs and is typedlist<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 customtypehandler derives a call (context.derive), was typedtuple<number, number>, the type of one element. Both now give the same type, for a list, a matrix (each cell is mapped), abroadcastable<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: withh := a ↦ f([a, a]),h(1)is typedlist<tuple<integer, integer>>(it waslist<tuple<number, number>>), and withg: (T) -> T where T(orwhere T: number),Map(g, rows)over alist<list<integer>>is typedlist<list<integer>>. A generic function whose type variable has a bound no longer takes its result from a list element that breaks the bound: withg: (T) -> T where T: numberand a list of stringsL,g(L)was typedlist<string>; each element of its value is anincompatible-typeerror, and the call is now typedlist<number>, and so is the list option of an argument typednumber | list<string>(the call is typedlist<number> | number). -
Sumof elements that may be lists was typednumber. Withg(t) = [t, 2t]declared(unknown) -> list<unknown>andxdeclaredany,Sum(Rest(g(x)))was typednumber, but atx = [1, 2]it is[2, 4].SumandProductnow give a type that holds a list when their terms may be lists:broadcastable<number>for terms typedunknown(the typeAddgives for such operands), as inSum(L)forL: list<unknown>orL: list;anyfor terms that have ananyoption, as withgdeclared(unknown) -> list;list<any> | numberfor rows whose cells are not typed, as inSum(M)forM: list<list<unknown>>; andnumber | vector<2>forSum(g(x))wheng(x)is typedbroadcastable<vector<2>>. This applies to theSum(body, limits)form too:Sum(L[k], k, 1, 2). Terms typed as numbers, points or rows of numbers keep their type. A compiledSum(g(x))orProduct(g(x))over such agnow compiles to the interpreter's value (it declined before), and a compiledSum(L) + 1forL: list<unknown>now declines (it gavenullfor a list of rows). -
Arithmetic over a cell of unknown type took the tier of the other operand. With
u: broadcastable<unknown>,2 · uand2 + uwere typedbroadcastable<integer>and0.9 · ubroadcastable<real>, although a cell ofumay be any number. They are now typedbroadcastable<number>, and so are[1, 2] · u,[1, 2] + uand[1, 2] - u(they werevector<integer^2>, butumay hold a matrix). A free symbol in a list literal is now narrowed tonumber, as the same symbol in a scalar product is:2 · [y]is typedvector<1>andyis inferrednumber(they werevector<real^1>andreal). Withw := x ↦ 2 · [x],w(i)was typedvector<real^1>with the value[2i]. -
The
complexcompile mode did not reach the cells of a list input. Withmode: 'complex', a scalar input declarednumberused complex arithmetic, but alist<number>,tensor<number>,matrix<number>orlist<unknown>input did not:Add(p, 1)with a cell1 + igave 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 isfalse). The default andstrictmodes emit the same code as before. -
Compiled statistics of complex data gave
NaN. CompiledMean,Variance,StandardDeviation,PopulationVarianceandPopulationStandardDeviationover complex data now give the interpreter's values in the default andcomplexmodes:Mean([3, 4i])is1.5 + 2i, and the variance family is real. Thestrictmode 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, andStandardDeviation([1e200, -1e200])is1.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>>, atensor, or the selectors of abroadcastable<vector<2>>), compiledSum, andReducewithAddorMultiply, joined the rows as strings ("01,22,43,6") or gaveNaN; they now combine element by element, as the interpreter does. CompiledMax,MeanandReduce(…, Max)over such elements gaveNaNor compared rows as strings; they now decline. PythonMax/Minof an operand that may be absent or a list returned the operand itself; they now reduce it, and giveNaNfor an absent or empty operand. -
HadamardProduct, and the element-wise sum, difference, quotient and power of two tensors, gaveundefinedcells when the two tensors had different data types.HadamardProduct([[0.5, 1], [1, 3]], [[1, 2], [3, 4]])threw aTypeErrorwhen 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,TrigToExpandTrigReducebind their operand when they are canonicalized, as the other operators do. The operand is still not evaluated before the rewrite. SoNothingis removed from the argument list:TrigExpand(Nothing)is the error of a missing argument, asSin(Nothing)is (it wasNothing, andTrigReduce(Nothing)was0). -
A
FindFitevaluated withevaluateAsyncand 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 asRandom()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
SumorProduct. Every check of the form "does this expression mentionk?" asked a dictionary for a key namedk(see thehas()/hasKey()entry under Breaking Changes), so\sum_{k=1}^{2} \mathrm{If}(x<1, \{"v"\to k\}, 0)evaluated to2 \cdot \mathrm{If}(x<1, \{"v"\to k\}, 0)with the boundkleft in, andsimplify()gave3\,\mathrm{If}(\dots k \dots)for the sum to 3 and\mathrm{If}(\dots k \dots)^3for the product. The terms are now evaluated one by one, andsimplify()keeps the sum. The compiler checks that look for a function literal or aReturnnow 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)!; withGamma := x ↦ x + 100, the interpreter gave\Gamma(0.5) = 100.5, and thejavascriptandinterval-jscompiles of any expression that called it failed ("a user definition shadows the library operatorGamma"), while the same function namedGcompiled. Bothdeclare(name, "function")anddeclare(name, { signature })followed byassignnow compile\Gamma(\frac{1}{2}) + xto 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})}dywithk = 1has no real value at x < 0, where the integrand is not real on[x, 0). The adaptive quadrature bisected theNaNpanels until its budget of 1500 panels was spent, and then each route drew 10⁷ Monte-Carlo samples, whose mean wasNaNagain. Now x = −0.2 and x = −1 answerNaNin 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 complex1.0000 − 0.3691i(the principal value, which the interpreter integrates in two parts) and the compiled real value isNaN; 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}dtdiverges at t = 0 for x ≤ −1. The interpreter answered+∞, because it finds the pole from the expression with the value ofx, 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}dtat x = 0.3 gave8.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 answersNaNin 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 was5.7e-10, and it is now2.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). Withha path joined from 29 linear pieces, the derivative went through the generic chain rule, which readsPointYas 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,PointZandAt(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: withg := t ↦ (t, t^2),D(PointY(g(u)), u)was a tuple, and is now2u.PointZof 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>) -> unknownrefused the function literal(x) => (x, x):ce.assignthrew a type error, becauseprovablyAdmits('nothing', 'broadcastable<unknown>')answeredtrue, as if the type stated that aNothingvalue is possible. Abroadcastable<T>is now read as the union of its two parts, sobroadcastable<unknown>states nothing aboutNothing, asunknowndoes not, and the assignment is accepted. A generic function literal over a ragged list gave a type that does not hold the value: withgthe literal(x: T) -> tuple<T, T> where Twith the body(x, x),g([[1], [2, 3]])was typedlist<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 typedlist<list<tuple<integer, integer>>>, which holds the value. -
A
SumorProductbound refused a union type with a number arm (Tycho row 421). Withxdeclaredbroadcastable<number | tuple>,\sum_{n=1}^{x} n^2,\sum_{n=x}^{5} nand\prod_{n=1}^{x} nwere invalid, so a function such ass(a) = \sum_{k=1}^{a} kover that declaration never registered, while the bound of an integral and the operands ofModandMaxaccepted 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 belownumber. Nows(3)is 6 ands([1, 2, 3])is[1, 3, 6], as over a parameter declaredany. -
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 aSumwhose derivative is 0, a step of the variable (Floor,Ceil,Round,TruncateorSignof 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 tojavascriptandinterval-js; the derivative of order 2 closes too. At a jump the result is the value of that sum, asD(⌊x⌋)is 0 at an integer. A bound that is not a step (x) or not pure keeps the derivative unevaluated. AProductwith such a bound still stays unevaluated, and its numerical derivative is now right between the jumps andNaNat 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 forFloor; it stayed unevaluated. -
FindRootreportsconverged: falsefar from a root of a steep equation (Tycho row 419). The root test of 0.161.0 allows each residualtolerance · (1 + Σ_j |J_ij| · max(1, |θ_j|)). Its Jacobian part is a first-order claim, that a zero lies within a relative change oftoleranceof the parameters, and it had no check:2 - \sin(10^{18} a) = 0has no real root, and its Jacobian, up to 10^18, let a residual of 1 to 3 pass at every point (converged: truefroma = 1, froma = 0and under the bounda \le 0). An equation whose residual is abovetoleranceis 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 reachtolerance, change sign, or touch 0 to within the rounding of the parameters (a root of even multiplicity, such as10^{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.5and\sin(10^{10} a) = 0.5, which oscillate faster than the tolerance can resolve, go from 100, 100 and 93 starts reportedconvergedto none;\sin(10^8 a) = 0.5,a^3 - 2a = 10^9,10^{12} a = 1ande^a = 10^{30}stay converged. The check costs at most two residual evaluations per equation, at the points where the solve would stop. -
FindFitreportsconverged: trueat 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 reportedconverged: falseafter 200 iterations, since the record of a fit readconvergedfrom 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 nowconverged, 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 themax(1, |θ_j|)of the root test ofFindRoot, under which a degree-10 polynomial overxin [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 stackedFindRootform 4 and 39 instead of 20 and 92; a point whose residual is still dropping (1 + (10^{12} a)^2 = 0froma = 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 reportsconverged: falsebesidetimedOut: true, as the record's contract says; a root solve that timed out while polishing a root reported both true. TheFindFitrecord still has nostationarykey.
0.161.0 2026-10-21
New Features
-
FindRoottakes the options dictionary ofFindFit(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, asFindFitdoes 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_ifor each data point, andd = 0) is searched for its best frequency; the constant phase coefficients of the equations (3.22incos(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 atx = 0readsa 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 thestationaryentry below). With a kernel pool on the engine (ce.workers),evaluateAsync()scans the grid on the workers, asFindFitdoes. -
Parser.binderSymbol()andParser.rebindSubscripts()are public, for a custom LaTeX dictionary entry that binds a name (awithclause, 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}withn: list<number>isAt(n, 0)elsewhere); the two parser methods that the built-in binders use for that were internal, so a custom clause could not: withna list,n_{0} + 1 \operatorname{with} n_{0} = 2through a custom infix entry that read the binder withparseSymbol()was a parse error.binderSymbol(expr)gives the name that an expression declares in a binder position (n_0for theAt(n, 0)that the symbol parser made fromn_{0}; a symbol is its own name;nullotherwise), andrebindSubscripts(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
-
Factoruses certified univariate factorization over the rationals, including nonlinear factors such asFactor(x^4+4) = (x^2-2x+2)(x^2+2x+2). Thenumericsentry point exposesfactorRationalPolynomialand its types for structured exact factors, multiplicities and explicit incomplete results when resource limits or certificate checks prevent completion. -
.N()ofPower,Exp,Lnand the other numeric heads of a number operand is about 1.6x faster atce.precision = 30: the check for a collection operand no longer runs a type match on a number literal (#465, contributed by enumeratio). -
GammaLnof a complex argument is the principal logarithm of Γ(z) in every region, as documented, andLogGammastays 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), whileGammaLn(-2.5 + 0.1i)was already principal,-0.1031 - 3.0313i. The imaginary part is now reduced to(-π, π], soGammaLn(-7.3 + 2i)is-13.3277 - 1.5238i(it was-13.3277 - 20.3734i), andGammaLn(25.5 - 40i)is31.7745 - 1.2258i(it was31.7745 - 139.4559i). Real arguments are unchanged:GammaLn(-2.5)is stillln|Γ(-2.5)|. -
Compiled
GammaLnof a negative real isln|Γ(x)|on JavaScript and the shader targets, as the interpreter gives (#463, contributed by enumeratio).GammaLn(-2.5)compiled to JavaScript gaveNaN; it is now-0.0562, andGammaLn(-7.3)is-7.7791. A pole (0,-1,-4) is+Infinity. The GLSL and WGSL_gpu_gammalnused the Stirling series, which is meaningless for x < 0; it now reflects (ln π - ln|sin πx| - ln Γ(1 - x)) and answers+Infinityat a pole, as_gpu_gammadoes.
Behavior Changes
-
unknownis an inference marker, not a content type. Every type is now a subtype ofunknown, the absence markers,errorandanyincluded:ce.type("missing").matches("unknown")andce.type("any").matches("unknown")aretrue, andlist<integer | missing>matcheslist<unknown>.unknownstill proves no contract: it matches onlyanyand itself. Barelist,list<unknown>andlist<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]reportslist<integer | missing>, where it stayedlist<unknown>),list<any>is a contract that never moves. Baretupleandrecordrefine their slots and fields the same way (t: tuple := (1, Missing)reportstuple<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])withfs: list<unknown>islist<unknown>(it waslist<integer>for a value that could hold an error), andFilter(tu, p)overtu: tuple<unknown, missing>islist<missing | unknown>(it waslist<missing>for the value[1]). A union keeps an absence arm besideunknown(missing | unknown) and absorbs a value arm (integer | unknownisunknown); a union with anerrorarm reduces toerror, as before. Anincompatible-typediagnostic shows a generic parameter whose variable got no bound by the variable's name,indexed_collection<T>, instead ofindexed_collection<unknown>. A lambda whose parameters are not inferred yet,(unknown) -> number, still does not satisfy(any) -> number.expressionandvalueare the contracts that exclude the immediate markers. Design and decisions:docs/plans/2026-10-21-unknown-inference-marker.md. -
FindRootreportsconvergedonly at a root (Tycho row 414).FindRoot(a^2 + 1 = 0, a)gaveconverged: TruewithresidualNorm: 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.convergedis 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|))withJthe 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}froma = 1is a root;a^2 + 10^{-7} = 0is 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 systema^2 = 4, b = 10^{11}stalled after one step ata = 2.5, which was reported as converged, because a step of 1 inawas negligible next to the norm of the whole parameter vector. The record gainsstationary, 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 readconverged. The gradient test alone no longer stops a root solve:FindRoot(10^{-6} (a^2 - 4) = 0, a)froma = 1stopped at its start point as converged, since the absolute gradient tolerance passes everywhere on a residual scaled small, and now reachesa = 2. TheFindFitrecord 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, thenlet a = String(g(2)), thenfunction g(k) { 2k },awas"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 theincompatible-typeerror). This also holds on each iteration of a loop body, when a block is evaluated again (withevaluate()orevaluateAsync()), 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 inROADMAP.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: startBlock(Tick(), w + 1).evaluateAsync()withTick()an operator that only has anevaluateAsynchandler, then runce.pushScope(); ce.declare('w', 'integer'); ce.assign('w', 5)before awaiting it. The result was6; it is noww + 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-jstarget,0^xover an exponent interval has the value 0 (Tycho row 417).compile(ce.parse('0^{x}'), {to: 'interval-js'})answeredemptyfor 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^0is 1 in the interval library), a clipped enclosure for one that reaches below 0, andemptyfor a wholly negative one, where no point of the box is defined; the scalar-exponent power answered a clipped enclosure[+∞, +∞]there and now also answersempty. 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
casesblock and in a set-builder whose variable appears only in the condition (the two residues of Tycho row 410). Withndeclared 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 accessAt(n, 0)and the block declared a functionAt; such a row of acases,dcasesorrcasesenvironment now gets a second reading, the declaration of the localn_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 asAt(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 withnundeclared. The parser'senvironmentName()gives a dictionary entry the innermost environment being read, andblockRowAlternative()the second reading of a row. -
Solveno longer lets a called user function's global take the place of the unknown. Withh := a ↦ a − k,Solve(h(k) = 1, k)gives[k + 1], and[3]withk := 2; it stayed unevaluated, and before that it gave[]. The unknown is renamed in the equation and in the specs, asIntegrateandLimitalready rename their variable. This covers domain specs, a bundled{h(k) = 1, k ∈ 1..3}, systems andNSolve. 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]whentis declaredreal; it stayed unevaluated. The root1/xis the pole~ooatx = 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]andSolve(x^2 = 2, x)gives[√2, −√2]; both gave[]. Withx := 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
Solveover a finite domain whose equation or predicate has a free parameter stays unevaluated:Solve(x − a = 0, x ∈ 1..5)andSolve(x > a, x ∈ 1..5)were[]. -
The bounds of a
Limitsclause and the collection of anElementclause ofSum,Product,IntegrateandNDSolveFunctionread the enclosing symbol, not the clause's own index. Withk := 2,∫₀ᵏ k dkgives2(wask²/2),∏_{k=1}^{k} kgives2(wask!),Σ_{k=1}^{k} kgives3(stayed unevaluated) andΣ_{k ∈ [1, k]} kgives3. AlsoΣ_{k=1}^{3} Σ_{k=1}^{k} kgives10on every route (was3·Σ_{k=1}^{k} k), and∫₀ᵏ k dkwith a freekreportskas an unknown. -
FindFitwith theglobaloption 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)andsin(b x) / cos(b x)have the period π, and(sin(b x) cos(b x))²has π/2. Their basins inbare half or a quarter as wide as those ofa cos(b x). The grid was spaced for the period 2π, so the search could return a wrong frequency with a large residual: for examplebover [0.3, 300] on 200 points over [0, 10], with the truebbetween two grid points.tan(b x)²keeps the period π oftan, becausetan(θ + π/2) = −cot θ. -
FindRootdid not move a parameter read throughPointXof a point (Tycho row 415). OnPointX(PointList(2a, 2)) = 4the solver stopped after one iteration at the start value: the symbolic derivative of the residual was the generic chain rule'sPointList(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.Dnow differentiates an accessor of a literal container through the operand it selects:PointX,PointYandPointZof aTupleor aPointList, andAtof aTuple, aPointListor aListwith a literal index (D(PointY((2a, a^2)), a)is2a,D(At([2a, a^2], -1), a)is2a); over aPointListthat zips (a component is a collection) the accessor reads the differentiatedPointList, soD(At(PointList([a, 2a], 5), 1), a)is the point(1, 0); andFindFit/FindRoottake 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^2evaluated by extracting the content of the sum, whose denominator (the least common multiple ofk²over the terms) is past the double range;lcmgaveInfinityand the next call threwRangeError: The number Infinity cannot be converted to a BigInt.rationalGcdnow uses the bigint form past the safe integer range (with a non-negative denominator), and the publiclcmof thenumericsentry point answersNaNfor a non-integer or non-finite operand, asgcddoes, 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 := 2min the engine,\{\sum_{k=1}^{3} k > 0: 1, 0\}compiled to a test ofm(_.m !== undefined), so an unsuppliedmmade the branch take no arm; theSumindexkis 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 asx_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()orFindFit.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 aFindFitscan, 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 newcancelGraceoption ofcreateKernelPool()(default 250 ms, at most 2147483647;Infinityturns it off) after an abort, or after the deadline of its task, is terminated and replaced by a new one fromcreate; the tasks that waited for it run on the new worker. WithoutSharedArrayBuffer(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()])gaveh(k)andq(k), where the same two evaluations awaited one after the other gave2kand2k. A synchronous evaluation of such a block while an asynchronous one was pending also gaveq(k). Both now give2k. Related fixes: when a symbol holds such a block (p),ce.symbol('p').evaluateAsync()now gives2k, asevaluate()does; a block in an operand of a function application stays a nested block on the asynchronous route, as on the synchronous route (withf(a, b) = (a, b),f(Tick(), B(h))gave(0, 2k)and now gives(0, h(k))); a sticky unresolved call read again after anawaitin 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 + 2instead oft + 3), and two evaluations whose runs of one block alternate no longer lose each other's locals (t + 1instead oft + 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)is10 + h(1): theh(1)computed atk = 1is not resolved by the assignment atk = 2(the unreleased callable-binding change had made it12). A later evaluation resolves the stored call as before:r = ce.parse('g(3)').evaluate(), thence.assign('g', …), thenr.evaluate()is9. ABlockevaluated directly by the host counts each statement as its own evaluation, as the Epsil runner does, soBlock(r := fn(3), fn := Function(2z, z), (r, fn(3)))is still(6, 6), while the same block nested in another expression, or adoblock in an Epsil statement, keepsfn(3). -
In an Epsil program, a
functionstatement is installed before the first statement runs when a statement before it calls the function:let a = String(g(3))thenfunction g(k) { k^2 }gives"9"(it gave"g(3)";let a = g(3)gave9on both routes already, by resolving the stored call whenawas read). The installation is skipped, and the program behaves as before, when atypestatement 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) -> integerstatement followed byfunction g(k) { 2k }now checks a call written before thelet:g(1.5)isincompatible-typeon every route (it was3on 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, andindexed_collectionnow admit arbitrary contents, matching their<any>forms. Baretupleaccepts every arity and slot type, including absent coordinates;tuple<any>still has one slot. Barerecordcovers 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, andIterateresults retain possibleNothingin 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.Shapepreserves 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 nowT | list<T>. It wasTor an indexed collection ofTother than a tuple or a string, so the subtype relation was not transitive:tuple<integer>is anindexed_collection<integer>, which was abroadcastable<integer>, buttuple<integer>was not one. Now every subtype oflist<T>(a vector, a matrix, aRange) is abroadcastable<T>, and a tuple, a string, a set and a value typed onlyindexed_collection<T>are not. A tuple is now provably disjoint frombroadcastable<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.assignof a value typedindexed_collection<T>to a symbol declaredbroadcastable<T>is refused, as it is for a symbol declaredlist<T>; andMap(f, [(1, 2), (3, 4)])withf: (broadcastable<number>) -> numberis oneincompatible-typeerror 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>islist<integer>,set<number> & collection<integer>isset<integer>, andtuple<number, number> & indexed_collection<integer>istuple<integer, integer>. So is the meet of a list with dimensions and a list with none:list<number^2> & list<integer>islist<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: withtype alias json = list<json> | integer,Norm(j)forj: jsonis valid (it was refused when boxed), and withj = 5it evaluates to5. -
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) -> integerrefuses an argument typedlist<integer> | integer, asf: (list<string>) -> integerrefuses an argument typedlist<integer>. Two collections whose elements are at different depths now overlap when a tensor can be in both:list<list<list<real>>>andlist<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>andlist<list<integer>^2>are no longer subtypes ofvector<unknown>andlist<any^2>, aslist<integer^(2x2)>already was not. The relation was not transitive:list<integer^(2x2x2)>is alist<list^2>, which was avector<unknown>, butlist<integer^(2x2x2)>is not avector<unknown>. The meet follows:list<any^3> & list<list^3>isnever(it waslist<list^3>), and the two types do not overlap. A list with no dimensions is unchanged:list<list^2>is alist<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>>waslist<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>islist<integer^(2x2)>(it wasnever), andlist<number^2> & list<integer^-1>islist<integer^2>(it wasnever). A list with a fixed length and no common element isnever:list<number^2> & list<string^2>waslist<never^2>. A barelistis read aslist<unknown>:list<any^3> & listislist<unknown^3>(it wasnever). The overlap test follows:list<number^(2x2)>andlist<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> | integerandtype alias B = tuple<B> | string,A & Bisneverand the two types do not overlap (they were kept as written and overlapped).couldMatch()reads the arms of a recursive alias: withtype alias json = list<json> | integer,couldMatch(number, json)istrue(it wasfalse). The test reads recursive aliases by their finite values, while the subtype relation reads them coinductively: withtype alias V = tuple<V>,Vis a subtype ofAand ofBalthoughA & Bisnever.Vhas no value, so no value is lost. -
A generic alias whose arguments grow at each unfold (
A<T> = B<list<T>> | integerwithB<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()andprovablyDisjoint()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: withp: tuple<integer, integer>andx: integer,p / xwas typedtuple<rational, rational>, butx = 0gives(~oo, ~oo). The same applies to a list of points and to a value typedbroadcastable<vector<…>>. A divisor proven not to be zero keeps the precise type:p / 4andp / nwithn: integer<1..>aretuple<rational, rational>. Also,i / xis typedimaginary | infinity(wasimaginary), and(1 + 2i) / xis typednumber(wascomplex). An imaginary divisor is never zero, sow / jwithw: complexandj: imaginarystayscomplex. -
A collection divided by itself is no longer folded to
1:L / LwithL: list<integer>staysL / L, and withL = [1, 2]it evaluates to[1, 1]. The foldx / x → 1still 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 typedrationalalthoughx = 0gives~oo. A real numerator that may be infinite, divided by an imaginary number, is typednumber(wascomplex):+∞ / iis~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 introducesNothing. 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 staysany; a shadowed global cannot supply a narrower result or introduceNothinginto 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
Nothingfrom callback inputs. Opaqueanycallback results remainany; 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 returnNothing. 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
Nothingcoordinates:(1, Nothing, 3)has three positions. A tuple whose coordinates are all missing remains a tuple.Zippreserves these coordinates and all-missing rows, so source positions stay aligned. JavaScript and Python compiled tuples retain all-missing tuples; compilation refuses possibleNothingcoordinates until targets can represent omission distinctly. Lists andTupleFromretain their existing immediate omission behavior. -
Numeric aggregates skip
Nothingyielded by raw lazy views.Meanof a view exposing1, Nothing, 3now returns2instead of an incompatible-type error; its denominator counts only retained data. The other univariate statistics follow the same rule, and extrema such asMinnow finish evaluating after an omitted datum. All-omitted input follows each operator's ordinary empty-input rule (Sumgives0,Productgives1). Source counts and positions are unchanged;Missingstill propagates normally. Aggregate compilation refuses omission-capable source types, includingcollection<any>, until targets can distinguish omission from missing data. -
Filterdrops immediateNothinginputs without calling its predicate. Filtering a raw view yielding1, Nothing, 3with an always-true predicate now yields1, 3, rather than an incompatible-type error cell or a retained omission.Count(source, predicate)follows the same selection rule.Missingand nested markers still reach the predicate. Result types exclude immediatenothing; compilation refuses sources that might contain it until targets can distinguish omission from missing data. -
Mapbypasses its callback for immediateNothinginputs and preserves their positions. A strict numeric callback over a view yielding1, Nothing, 3now yields its two results andNothing, rather than an incompatible-type error cell. Multi-source maps bypass the whole row if any input isNothing.Missingand markers nested inside a datum still reach the callback. -
Covariance,PopulationCovariance, andCorrelationskip whole aligned observations containingNothing, 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 containNothinguntil targets can preserve its distinct representation. -
Dedup,FilterandTakeWhileof a string held by a symbol of a wider type give a string, as they do for a string literal: withc: list<integer> | string := "aab",Dedup(c)was["a", "b"]and is now"ab";Dedup(Filter(c, p))is a string too. -
FilterandDedupover a dictionary give aDictionary(also when the dictionary is an arm of a union type, through nestedFilter/Dedupviews, and for a barerecord); they gave aSetof entry tuples (Set(("a", 1))).Filterover a record is typeddictionary<any>. -
Mapof a user function with scalar parameters over a list of lists is typed with lists as elements (Map(f, pr)withf: (number) -> numberislist<list<number>>, waslist<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>>).Mapof 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 holdMissing, such a literal is typed with theNaNit gives for an absent cell:Map((x: integer) ↦ x + 1, [1, Missing])islist<number>(waslist<integer>, value[2, NaN]). AMissingthat 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)withc: missing | list<integer>) previews as a list; it previewed as aSet. The preview, and a bounded materialization, ofDedup,Filter,JoinorAppendover more than five dictionary entries show the expression as written (Dedup(d)); they showed anincompatible-typeerror. -
Two mutually recursive aliases that describe the same values are subtypes of each other: with
a = list<b>andb = list<a>,a <: bis nowtrue(it wasfalse, a conservative cutoff). A self-referential alias such asR = list<R>is a subtype oflist,list<unknown>,list<list<unknown>>andvalue;J = list<J> | missingis still not a subtype ofunknown. -
Dedup,FilterandMapaccept a source typedmissing | string(typedmissing | stringorlist<character> | missing); it was anincompatible-typeerror.Mapof a broadcastable operator over ragged rows (Map(Sqrt, [[1, 4], [9]])) giveslist<vector<1> | vector<2>>; it was anincompatible-typeerror. -
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 isNaNorIndeterminatealready evaluated toFalseon its own; anIfor aWhichon it now takes the next arm (the else arm) instead of no arm. This holds in the interpreter and on thejavascript,pythonandinterval-jstargets. Before and after:\left\{\frac{0}{0}>0:1,0\right\}(also with<,≤,≥,=), andIf(0/0 > 0, 1, 0):Missingbefore,0now.\left\{x > 0: 1, 0\right\}compiled to JavaScript and run at{x: NaN}:NaNbefore,0now. 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 givesNaN, also through arithmetic (2x > 0). The guard tests a variable of any type (p.x > 0testsp), 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 nestedIf,WhichorWhen:If(If(x > 0, y, 1) > 0, 1, 0)atx = -1is1withynot supplied.0 < \sqrt{x}compiled tointerval-jsatx = [-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 0atx = [-1, 1]was'true'.0 < \sqrt{x}there stays'maybe'. - A sum of such a piecewise,
\sum_{n=0}^{10000} M(n)withM(n) := \{K(n) > 0: 1, 0\}andK(0)undefined, is now10000instead ofNaN.
Not changed:
≠keeps its behavior (\mathrm{NaN} \ne 0isTrue, and a branch on a≠with aNaNoperand still takes no arm:Missingin the interpreter,NaNonjavascriptandpython,'maybe'oninterval-js).Missingkeeps its Kleene reading (Less(Missing, 1)isMissing): a comparison with a nested else-lessIfor default-lessWhichthat selects no arm still takes no arm (If(If(c > 0, 1) > 0, 1, 0)atc = -1isNaNcompiled), and oninterval-jsthat value is anemptywith amissingflag, soWhich(x > 5, 1) > 0overx = [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 aNaNoperand 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 of1/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 nowFalse, alone and in the condition of anIfor aWhich, which then takes its next arm.+∞and−∞are real and keep their order. An equality with the pole isFalse(the pole has no value, asNaNhas 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 infiniteSumorProductor aLimit), and the imaginary part of a number literal is read exactly (1 + 10^{-800}iis 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 decidesFalsealso next to a free symbol (x < i,x < \frac{1}{0}), asNaNdoes. A chain stops at its first pair that isFalse. Before and after:0 < \frac{1}{0},0 < i,0 < \ln(-1),\pi + i < 4 + i,x < i: unevaluated before,Falsenow.\frac{1}{0} = \frac{1}{0}and\ln(-1) \le \ln(-1):Truebefore,Falsenow.0 < i < \mathrm{Missing}:Missingbefore,Falsenow.\int_0^1 x^{i\pi} dx:\frac{1}{1+i\pi}with the condition0 < 1 + i\pibefore,\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 root2iwith the condition0 < 1 + ibefore,2inow (a base that is not real has the conditionsb \ne 0andb \ne 1).\left\{\frac{1}{0} > 0: 1, 0\right\}and\left\{\sqrt{-1} > 0: 1, 0\right\}: a heldWhichbefore,0now.\sum_{n=0}^{10} M(n)withM(n) := \left\{\frac{1}{n} > 0: 1, 0\right\}: a held expression before,10now.
Not changed:
\frac{1}{0} = 0isFalse,i = iisTrue,i = 0isFalse,\frac{1}{0} \ne 0isTrue(≠is not part of the decision),0 < \inftyand\infty = \inftyareTrue, andMissingkeeps its Kleene reading. The methodsisLess(),isGreater()and the others still answerundefinedfor 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\}atx = 0gives1onjavascriptand onpython(with a NumPyx; a plain Python float raisesZeroDivisionError), where the interpreter gives0;\frac{1}{x} = \frac{1}{x}atx = 0istruethere. Oninterval-js,\frac{1}{x}over the point[0, 0]isempty, so that branch already gives0. A non-real value at run time already gavefalse(\left\{\sqrt{x} > 0: 1, 0\right\}atx = -1is0). A closed comparison with such an operand now folds to the value of the interpreter onjavascript,glsl,wgslandpython, with or withoutconstantFold:\left\{\frac{1}{0} > 0: 1, 0\right\}compiles to0(it was1), and0 < \frac{1}{0}and\frac{1}{0} = \frac{1}{0}tofalse(they weretrue).i < 2compiles tofalse(it was a compile-time decline;i < xstill is one).interval-jsdoes 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 IEEEInfinity:x < \frac{1}{0}atx = 1istrueonjavascript,pythonandinterval-js, where the interpreter givesFalse. -
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 avector<collection>asmatrix<integer>always was (a string is a collection, so only its rank was read before), andmatrix<integer>is avector<integer | list>; the elements of avector<unknown>orvector<any>stay scalars, andmatrix<unknown>is not avector<unknown>. A list declared in the nested spelling answers as its flat spelling toward every target:list<vector<integer^1>^2>is alist<number>, acollection<number>and anindexed_collection<real>, aslist<integer^(2x1)>is (it was amatrixonly).collection<E>andindexed_collection<E>peel any number of outer axes, aslist<E>does:list<boolean^(2x2x2)>is acollection<vector<boolean^2>>. The meet follows:list<string> & vector<collection>keeps the string matrices (it wasvector<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 alist<integer>and acollection<integer>but not amatrix<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 amatrix<T^(MxN)>parameter it pins no length (it pinnedN). A random check of 72,000 pairs of list and collection types against concrete values finds no loss (it found 18).
Resolved Issues
-
Factormerges 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
Multiplydeclared not commutative (ce.declare('Multiply', { commutative: false }, { extend: true })) removes its factors 1:a·1·bisa·b, andFactor(a·b·a)isa·b·a. They were kept, andFactor(a·b·a)gavea·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)withL: list<list<integer>> := [[1, 2], [3]], andSin([[1, 2], [3]]), arelist<list<number>>(they werematrixandlist<number>). A nested list of points plus a number (L + 0.5withL: list<list<tuple<integer, integer>>>) is refused as anerror, as a flat list of points is. Rows of points beside numbers keep point cells forDivide,Power,Mod,Root,Log,Round,RemainderandArctan2, with the rank of the deepest operand (Power(NQ, V)islist<list<tuple<number, number>>>, waslist<number>), also when the numbers can be absent; a divisor cell that may be zero givesnumbercoordinates. On the derive route,Subtractof a nested list is no longer one level too deep, andDivideof a nested list of points keeps its points. -
Subtype relation:
matrix<T>is a subtype ofbroadcastable<T>, andcouldMatchno longer answersfalsefor pairs that share a subtype (tuples with ananyslot againstindexed_collection,tupleagainsttuple<any>,record{a: any}againstdictionary<integer>, a flat list against a nested list such aslist<integer>againstlist<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)witha: L,Lan alias oflist<integer | missing>, islist<integer | missing>; it waslist<unknown>. A self-referential alias (cyc = cyc | list<integer>) no longer overflows the stack. -
Totalno longer builds the unionany | unknown.Zip([1, Undefined], [2, Undefined])is typed withmissingelements, as withMissing. -
Collection operator types:
FlatMapover a source with absent cells keeps the absent cell (FlatMap(l, x ↦ [x, x])withl: list<integer | missing>islist<integer | missing>, waslist<number>; with an annotated parameter, theNaNof an absent cell is in the type; a callback that can answer a list orMissingis spliced arm by arm, soFlatMap([[1, 2], Missing], x ↦ Reverse(x))islist<integer | missing>).couldMatchof a record against a collection of entries compares each required field with its own key (record{a: integer}cannot matchcollection<tuple<"b", integer>>).Mapover an alias is alist(wascollection<number>).Mapof an operator whose declared result isany(Map(PointX, ["a", "b"])) islist<any^2>, not a copy of the source type.Mapof a function literal overanyelements whose body can return a collection (x ↦ Sqrt(x)) allows a collection element.Mapover mixed non-numeric elements no longer states a length. AMapover a union with a dictionary or record arm types that arm as a collection of entries. -
The derivative of a
Sumno 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. Withk := 2andg := a ↦ a·k·x,Σ_{k=1}^{5} g(k)is30x, but its derivative inxwas55(the sum ofk², the globalkread as the index): it is now30, as it is for the same sum with the indexj. Whenkhas no value, the derivative was55too: it is now15k. When this occurs, the derivative renames the index to a fresh name, such ask_1: the derivative ofΣ_{k=1}^{n} g(k)isΣ_{k_1=1}^{n} k·k_1. -
\int \sin x + 1parsed toIntegrate(0) + sin(x) + 1: the integrand was lost. It now parses toIntegrate(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} - 1parses toIntegrate(x/2, x) - 1(it wasIntegrate(x/2, x) + 1), and\int 1 - \frac{dx}{x}toIntegrate(1 - 1/x, x)(thedxstayed in the integrand). A parenthesized term in such a sum (\int (\sin x\,dx) + 1) no longer becomes a tuple, and withdiagnostics: truethe 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
ndeclared aslist<number>, a subscript onnis an index:n_{0}isAt(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 to3·At(n, 0). It is now["Sum", "n_0", ["Tuple", "n_0", 1, 3]]and evaluates to6, as whennis 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 awhereclause or of a;block (n_{0} \coloneq 3; n_{0}+1is4, not an assignment to a function namedAt), 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), afor … from … to … doindex, 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 isAt(n, k), and in\frac{\partial n_{0}}{\partial x}, the differentiand isAt(n, 0). A top-level assignmentn_{0} \coloneq 3is not a binder: it is still the errorexpected-a-symbol, as before. -
The variable of
IntegrateandLimitno longer reads a symbol of the same name inside a called user function. Withk := 2andg := a ↦ a·k·x,\int_0^1 g(k)\,dkwasx/3and is nowx, the value of\int_0^1 g(t)\,dt;\lim_{k\to 1} g(k)wasxand is now2x;\int g(k)\,dkwas-x k^3/6 + k x k^2/2and is nowx k^2. Whenkhas no value,\lim_{k\to 1} g(k)is nowk x(it wasx) and\int_0^1 g(k)\,dkisk x/2(it wasx/3). These operators inline the body ofgand select the variable by its name, so thekof 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)\,dkis5x(the bound is the index of the sum), and\int_0^{k_1} g(k)\,dkisx k_1^2. An indefinite integral whose result also holds the globalkkeeps the fresh name, and the printed result shows it:\int g(k)\,dkis\frac{1}{2}k x k_1^2whenkhas no value (k_2whenk_1is declared). That result is correct: thekofgand the variable of the antiderivative are different symbols.SumandProductalready gave these answers.Solve(g(k) = 1, k)andSolve(\{g(k) = 1, k \in 1..3\})had the same capture and answered[](no solution); they now stay unsolved. -
IntegrateandSolveinline a function that is declared with a function type and then assigned (g: (real) -> real, theng := a ↦ a·x), as they do for a function that is only assigned.\int_0^1 g(t)\,dtstayed unevaluated and is nowx/2. Withg := 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,LimitandSolve. Withr(x) := r(x − 1) + 1,\int_0^1 x^2 + r(3)\,dxran 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 aCancellationErrorat 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 declaredanyorvalue, is typedany. It wasunknown, and arithmetic reads anunknownoperand as a number: withP: any(orP: list<any>),P[i]wasunknownandP[i] - P[j]wasbroadcastable<number>, althoughPcan hold points and(3, 4) - (1, 1)is(2, 3). NowP[i]andP[i] - P[j]areany. This applies toAt(a single index, a gather, several indices, a dictionary key, a dynamic index into a tuple or a record with ananyfield),First,Second,Third,LastandField(D.awithD: dictionary<any>isany, wasunknown). A function of such a read isanytoo:Sqrt(P[i]),Sin(P[i])andh(P[i])withh: (number) -> numberwerebroadcastable<number>. A symbol whose type is inferred from its uses is not changed: withPundeclared,P[i]is stillunknownandP[i] - P[j]still makesPanindexed_collection<number>(and a read ofRest(P),Take(P, 2),Sort(P), … isunknownas well). A barelist(a synonym oflist<unknown>) still givesunknown. A tuple of such reads still compiles:(P[1], P[2])withP: list<any>orP: collection<any> | tuplecompiles to JavaScript, since a list element is neverNothing. -
SumandProductof terms typedanyorvalueare typedany. They werenumber: withP: any := [(3, 4), (1, 1), (0, 0)],Sum(P[k], k, 1, 3)andSum(P)evaluate to the point(4, 5). -
A read of
Take(P, 2),Rest(P),Drop(P, 2),Most(P)orReverse(P)withPundeclared isunknown. It wascharacter | missing, the element of thestringoption of the typelist<unknown> | string, soRest(P)[i] + 1was anincompatible-typeerror.
New Features
-
broadcast()andbroadcastAsync()of ajavascriptresult 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>andi: 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 newsharedoption names any other one ({ shared: ['G'] }). A shared input is a plain array of numbers or aFloat64Array, is not a column of thecellslayout and not an axis of thegridlayout, and goes to each worker of the pool once perbroadcastAsync()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 asbroadcastOutputs, which a stored result keeps forcreateJavaScriptRuntime().loadBroadcast()and the pool's workers; the newoutputsoption overrides it. An element missing from an array answer isNaN, and a non-array answer or a thrown cell isNaNin every slot.doneofbroadcastAsync()is still a count of cells.
An
interval-jsresult throws aTypeErrorfor either option. - A shared input is bound once for the whole batch and read by index in the
expression: with
-
The grid of the
globaloption ofFindFitnow 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. Witha cos(b x + c) + d x + eon 200 points overx ∈ [0, 10](trueb = 2.2),bover[0.3, 120]andcover[0, 2π]gave a sum of squared residuals of 390 atb = 0.3; it now findsb = 2.2(about1e-27). Three changes:- A parameter that multiplies a variable inside
Sin,Cos,Tan,Cot,SecorCsc(bincos(b x + c)) gets grid pointsπ / (rate · span of x)apart (half that forTanandCot), 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
SinorCosmultiplied by a linear amplitude (cina cos(b x + c)), searched over at least one period, is solved by least squares with the linear parameters, sincea cos(b x + c) = p cos(b x) + q sin(b x). It is then no longer sampled, and the search overbandccosts as much as a search overbalone. 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 anunexpected-argumenterror, 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 andresidualNorm: NaN, since their residual over every row was not computed. On 1,000 rows withbover[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). - A parameter that multiplies a variable inside
-
FindFitwith theglobaloption runs its grid scan on the engine's worker pool on the asynchronous route. When the engine has a kernel pool (ce.workers, seecreateKernelPool()),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 ofevaluate(). 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 asLogGamma, 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'siterationLimitapplies on the workers too. An abort of thesignalofevaluateAsync()rejects the evaluation with aCancellationError, whatever the reason given toabort(); 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 withevaluateAsync(), so data from an operator that has only an asynchronous handler is read.evaluate()does not use the pool. Measured witha cos(b x + c) + d x + e(benchmarks/workers/fit-parallel.mts, load average 6 on 18 cores): 1,000 rows withbover[0.3, 628]take 62 ms with 8 workers and 76 ms with 4, against 230 ms forevaluate(); 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 nowTrue, andIndeterminate ?? 5returns5, as forNaN. Exact arithmetic and reducers still produceIndeterminatewhere they did before.Nothingpasses through fallbacks and is not missing data. -
Coalesce(x, RuntimeError("absent"))retains the successful type ofxinstead of becomingnever. Coalescing removes only whole-value absence: a list's missing cells and a possibleNothingresult remain in its type. Compilation refuses absence tests that might confuseNothingwith 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)wasIf(Error(incompatible-type), x)and is nowIf(x > 1, Nothing, x), as on thece.boxroute.f(a, \mathrm{Nothing}, b)gives the same arguments asf(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 + 1was the productx·1and is nowx + 1;x\vphantom{y} + 1wasx·1and is nowx + 1;a \cdot b \displaystyle + cisab + 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 + 1wasx·(y + 1)and is nowx·y + 1, and2 \displaystyle \frac{1}{2} + 1was3and is now2. A switch at the start of a group still applies to the whole group ({\displaystyle x+y}). -
LaTeX:
\smash{x}givesx; before, its content was discarded (\smash{x}+1was1). 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 anunexpected-tokenerror and is nowx. -
LaTeX: a keyword that does not apply is text, as a word that is not a keyword is.
x \text{where}(no binding afterwhere),x \text{for} y(no comprehension or condition afterfor) andx \text{if}(nothen) were aSequenceofxand anunexpected-operatorerror; they are nowxfollowed by the text, asx \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\textspelling too:x \text{return} ywas an error and is nowxfollowed byReturn(y), asx \operatorname{return} yalready was. The input after such a statement is read too:a \text{return} b \text{return} cwasafollowed byReturn(b), withoutc, andx \operatorname{break} ylosty. The same applies after a prefix operator that ends an operand:x \neg y \neg zlostNot(z), andx \lnot y )dropped the)with no error; it now reports anunexpected-delimitererror, so that input is no longer valid. -
LaTeX: a keyword operator with nothing after it has a
missingright operand, as the symbol operators have:x \text{and}isAnd(x, Error("missing")), asx \landis. The right operand wasNothing, sox \text{and}becameAnd(x)and the error was lost. The same applies toor,iff,if and only ifandsuch that.
Resolved Issues
-
Compiled
GammaRegularized(a, z)is correct for a non-positive ordera, as.N()is (#457, contributed by enumeratio). Withaandzdeclared real,compile(GammaRegularized(a, z)).run({a: -1.5, z: 0.001})wasNaNand is now8894.8453395772…. At a non-positive integerathe compiled value is0forz > 0andNaNatz = 0, as the interpreter answers; atz = 0and a negative non-integerait is±Infinityby the sign ofΓ(a). The kernel works in logarithms at a large negative order, soGammaRegularized(-150.5, 120)is-7.65e-105, not0. A value outside the double range is±Infinity, or a signed0, 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 toscipy.special.gammaincc, which isnanfor everya < 0, and the two-operandGamma(s, z)refused to compile for a constants ≤ 0. Both now use a helper that keeps SciPy for a positive order and computes a non-positive order itself; the helper also fixesGamma(200, 1000), which overflowed toinf(its value is6.3e162).compileLambda()cannot hold the helper, as forPower. A lambda keeps the inline lowering only where it is correct:GammaRegularizedwith a constant order of at least1e-20, andGamma(s, z)with a constant order from2.5e-4to7.5. Any other order fails with "use compileFunction"; before, such a lambda gavenanor a wrong value. Gamma,Digamma,Trigammaandln|Γ|near a negative integer: the reflection formula now takes the sine of the exact remainder, soGamma(-10.000000001)has at least 12 correct digits instead of 7. The interval enclosure ofGamma(-1.000001)is under1e-12wide (relative), not3.5e-9.GammaRegularized(a, z)for|a| ≤ 1/2andGamma(s, z)for-1 < s < 1/2and a smallz:GammaRegularized(1e-9, 0.99)had five correct digits, andGamma(-0.9999, 0.001)wasNaN.Binomial(n, k)for annclose to a negative integer:Binomial(-51.999999999999986, 87)was0(its interval enclosure was[0, 0], which did not hold the true value-2.1e38), andBinomial(-426.1545291590769, 635.8454312348206)was-Infinity, not-1.18e305.
- The Python target gives the same values.
-
Log(1, base)threads over a list of bases instead of folding to the scalar0:Log(1, [-10, e, 27])was0and is now[0, 0, 0], asLog(8, [2, 4])is[3, 3/2]. TheLog(1, b) = 0fold now applies only to a base that cannot be a collection, so a symbol declaredreal | list<real>is not folded either. A tuple base threads, as it already did forLog(8, (2, 3)):Log(1, (2, 3))is(0, 0). A set base is anincompatible-typeerror, as it already was forLog(8, Set(2, 3)), where it was the scalar0before (#455, contributed by enumeratio). -
LaTeX: a chain of
\textkeyword operators such asa \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
Sumfollows a call in a term or a bound into the body of the called function (Tycho ledger row 405). Withg := a ↦ a·x,D(Sum(g(k), Limits(k, 1, 5)), x)was0: the rule read only the names written in the term, andxis not one of them. It is now15, the value that the derivative of each call (D(g(2), x)is2) 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)withha 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²andx := 5,D(h(k), x)was0: the body ofhwas found by evaluatingh, which gave25k. The body is now taken from the function literal, with the arguments in place of the parameters, and is not evaluated, soD(h(k), x)is2kxwith and without a value ofx. The derivative follows the name of its variable into the body of a called function in both cases;Integrate,Limit,SolveandSumkeep 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 ofqin its parameter, withq(a) := a x²andx := 5, is25. -
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 ofGapplied to wildcards was anError; it is now the derivative ofh(k).D(G(Sin, x), x)iscos(x), and a function literal argument is applied:D(G(t ↦ t·x, k), x)isk. The derivative ofApply(t ↦ t·x, k)isk; it wast * Apply(Derivative("Apply", 1, 0), (t) => t * x, k), with the parametertfree in it. A recursive function (f(x) := x·f(x−1), or two functions that call each other) is not expanded: its derivative staysApply(Derivative(f, 1), x), as before. A function composed with itself is expanded: withh := a ↦ a·x²,D(h(h(x)), x)is5x⁴. A call in an argument is never taken for the parameter (G(h, f(x))ish(f(x))), and a function with no body passed as an argument keeps its own name (D(G(u, x), x)isu′(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)andD(e^{g(k)}, x)were0: the rules tested only the names written in the operand. They are now2k²xandk·e^{kx}. -
On the
interval-jstarget, 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 as5/11) over[−1.1, −0.9]wasempty, whileevaluate()and thejavascripttarget give−1atx = −1; it is now[−1.0443, −0.9532]. The same holds forx^{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|^ewith the floate, 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,DedupandDifferences, as a list with an absent element already was.Map(x ↦ x + 10, t)witht: tuple<integer, missing>was anincompatible-typeerror; it is now typedlist<number>. An alias oflist<integer | missing>is accepted there too. The result of these operators and ofTake,Reverse,SortandUniqueover a source that can be absent as a whole keeps its absent elements in its type: withw: missing | list<integer | missing>,Dedup(w)was typedlist<number> | missingand is nowlist<integer | missing> | missing, andMaxBy(w, …)ismissing | number.Dedupover a tuple or a list with a stated length is typedlist<…>:Dedup([1, 1, 2])was typedvector<integer^3>, which does not hold its value[1, 2].
New Features
-
Batch evaluation of compiled functions. A
javascriptorinterval-jscompilation result hasbroadcast(inputs, options): the inputs of every cell at once, oneFloat64Arrayper free variable (alo, hipair per cell forinterval-js) or one number for every cell, and oneFloat64Arrayof results: one value per cell, or alo, hi, kindtriple per cell with theINTERVAL_KINDcodes (interval,empty,entire,singularPole,singularJump,partial,boolTrue,boolFalse,boolMaybe,error). Agridlayout takes the arrays as the axes of a regular grid, andoutis a caller-owned output array. Cell for cell, the value is the onerun()gives; the conversions run once per batch instead of once per cell, which measured 20 % faster thanrun()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 asignal, adeadlinein milliseconds and achunksize, 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/intervalrebuilds a storedinterval-jscompilation result with no engine behind it, ascreateJavaScriptRuntime()does forjavascriptcode; the result now carriesruntimeVersion,preambleOnceandpreamblePerCall. Both runtimes gainloadBroadcast().
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. WithE: list<integer>assigned[],First(E),Last(E),At(E, 5)andE[1]wereMissing, which their typeinteger | nandoes not hold; they are nowNaN, as a read past the end of a non-empty numeric list already was. A list of strings, an undeclaredE := []and the literalFirst([])stayMissing. -
A lazy view keeps a
Nothingresult in its own display, and erases it when it becomes a list. Withm = Map(x ↦ If(x > 1, Nothing, x), [1, 2]),m.toString()was[1]whilemcounted 2 elements andm.at(2)wasNothing; it now prints[1, Nothing]. The same holds forTabulate,Table,ScanandIterate. A list built from the view erasesNothingas a list literal does, and its count agrees:ListFrom(m),m.evaluate({ materialization: true }),[...m, 9],ListJoin(m, [9]),Sort(m)andTally(m)hold only1, andLength(Sort(m))is1(it was3). A set-kind view does not countNothing:Map(x ↦ If(x > 1, Nothing, x), Set(1, 2, 3))has 1 element. -
An overloaded function now accepts a symbol declared
errorthat has no value, as a function with one signature does. Withce.declare('e', 'error')andovdeclared((number) -> number) & ((string) -> string),ov(e)is valid, typednumber | string, and evaluates toov(e). Before, it was the error "expectednumber | string, goterror", whileg(e)withgdeclared(number) -> numberwas valid. The same applies to a call typederroronly by its declared result type (h(2)withhdeclared(number) -> error), and to an overload with a generic arm (((string) -> string) & ((T) -> T where T: number), also typednumber | 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: withf(a: number) = a + 1andf(a: string) = "s",f(e)staysf(e); it was theno-matching-clauseerror. A clause whose parameter is typederrororanyis 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, typedstring, and evaluates toReverse(e); it was refused. A parameter typedunknownnow accepts such an argument on every route: a library operator with the signature(unknown) -> unknownand a function literal(a: unknown) ↦ arefused 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
rdeclaredrealandGdeclared((T) -> T where T: integer) & ((string) -> string),G(r)is typedinteger, as with the ground form((integer) -> integer) & ((string) -> string). It was typedstring. -
A sum or a product written with no bounds (
\sum_k f,\prod_k f) runs overk = 1, 2, 3, …with every method:evaluate(),N(),simplify()and the derivative give the same result as for\sum_{k=1}^{\infty} f. OnlyN()did this before;evaluate()andsimplify()kept the sum as it was. Now\sum_k \frac{1}{k^2}evaluates to\frac{\pi^2}{6},\sum_k x^kto\frac{x}{1-x}when|x| < 1,\sum_k \frac{x^k}{k!}toe^x - 1,\sum_k kto\inftyand\prod_k (1 + \frac{1}{k^2})to\frac{\sinh(\pi)}{\pi}.\sum_k ystays as it is underevaluate()andsimplify(), as\sum_{k=1}^{\infty} ydoes: its value depends on the sign ofy. The derivative of\sum_k x^kis\frac{1}{(1-x)^2}when|x| < 1. The expression keeps its form with no bounds (\sum_{k}yis 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__awas writtenx^{a}, which reads back as the powerPower(x, a), andaleph__pluswas written\aleph^{+}, which reads back asPseudoInverse(aleph). Nowx__ais\operatorname{x^{a}}andaleph__plusis\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_1is\operatorname{x^{a}_1},x_1__ais\operatorname{{x_1}^{a}}(it wasx^{a}_1, the same asx__a_1), and two superscripts are nested,R_doublestruck__0__plusis\operatorname{{\mathbb{R}^0}^{+}}(it was\mathbb{R^{0,+}}, which does not parse). A style on the base stays on the base:x_bold__ais\operatorname{\mathbf{x}^{a}}(it was\mathbf{x^{a}}, which reads back asx__a_bold). A name with a base of more than one letter is written in\operatorname{}too:mass__earthis\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_boldis\operatorname{\mathbf{\_x}}and_x_hatis\operatorname{\hat{\_x}}(they were\operatorname{\_\mathbf{x}}and\operatorname{\_\hat{x}}, which do not parse), and_x_1_boldis\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_boldis\mathbf{\mathbf{x}_1}(it was\mathbf{x}_{\mathbf{1}}, which reads back asx_bold_one_bold). A name with two such subscripts is written in\operatorname{}:x_1_bold_1_boldis\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_pluswas writtenx_{+}, which reads back asSubplus(x);x_1_pluswas{x_1}_{+}(Subplus(x_1)), andx_a_dagwas{x_{a}}_{\dag}, which is a parse error. Now they are\operatorname{x_{+}},\operatorname{{x_1}_{+}}and\operatorname{{x_{a}}_{\dag}}.x_staris\operatorname{x_{\star}}(it wasx_{\star}, which also read back). The operators do not change:Subplus(x)is stillx_+. The parser reads\circin a name ascircle, sox_{\circ}is the symbolx_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 ofx_{a\unicode{"2012}}) is writtenx_{a\unicode{"2012}}. It was\operatorname{x\unicode{"2012}_{a}}, which reads back asx____002012_a. A subscript that starts with an escape,x_____002012, is writtenx_{\unicode{"2012}}(it wasx^{,}_{002012}, which does not parse). -
A name with the
degaccent reads back: the parser reads\degreein the body of\mathrm{}or\operatorname{}as the accent, so\mathrm{x\degree}is the symbolx_deg(it was a parse error). Outside of a name,x\degreeis stillDegrees(x). -
A subscript of digits with only the
uprightstyle is written without the style:x_1_uprightis writtenx_1(it wasx_{\mathrm{1}}, which reads back asx_one). No spelling reads back asx_1_upright, andx_1is the nearest name. -
The term of
Tableis a function body: a name that a branch of the term assigns isread-before-declarationin 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 ofSumandProductkeeps 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
scopeeffect:function g() { s = 0; for k in 1..2 { k = k * 10; s = s + k }; s }gives30(it was refused). Compiled code does not compile such a loop. -
Compiled JavaScript and interval code throws
read-before-declarationwhen 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 gaveundefined. 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 adoblock, as in Python:function g(c) { if c { t = 1 }; t }gives1forg(true)and theread-before-declarationerror forg(false)(both gave the symbolt). Aletin a nested block still declares a local of that block only, and a function literal owns the names of its own body.epsil checkreports a read before the first assignment in a nested block, and does not report a read after anifthat assigns in only one branch. -
In a program evaluated as one block, a
letof a library constant name (i,e,Pi,ExponentialE, …) shadows the constant only from its statement on: withfunction g() { i + 1 }, thenlet i = 2,g()gives1 + ion every route (the block and compiled routes gave3). A laterletof 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}, withga function that was not declared, isPower(g(x), -1). It was the quotient1/g(x), which is the element-wise reciprocal wheng(x)is a matrix. Wheng(x)has a matrix value, the power is now the matrix inverse: withgreturning[[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 returnsany, to a symbol declaredany, and to a value of a type such asnumber | matrix. In a product with a matrix the power keeps its position:A·g(x)^{-1}·Astays 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 stillInverse(M)orMatrixPower(M, -n).evaluate()andsimplify()of a product keep the power:2g(x)^{-1}stays2g(x)^{-1}(it was2/g(x)), and with the value above it is[[2, -2], [-2, 4]]. The quotient1/g(x)is still a quotient. The LaTeX of the power isg(x)^{-1}, which reads back as the same power:y g(x)^{-1}is writtenyg(x)^{-1}, not\frac{y}{g(x)}.simplify()combines the integer powers of such an operand asevaluate()does: in a product with no matrix,f(x)^2 f(x)^{-1}isf(x)andf(x)^{-1} f(x)^{-1}isf(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)^1isg(x), and(f(x)^{-1})^{-1}isf(x)(it wasf(x)^1, which evaluated to\sqrt[1]{f(x)}). -
A sum of quantities where at least one operand is a temperature with an offset (
degCordegF) now converts each operand to kelvin, with its offset, and adds the values in kelvin. The result is inK. 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 now47567/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 indegC:10 degC + 10 degCwas20 degCand is now566.3 K(283.15 K + 283.15 K). A difference of two temperatures is the difference of their values in kelvin:20 degC - 10 degCwas10 degC(which reads as 283.15 K) and is now10 K;20 degC - 10 degFwas-3.33 degCand is now290/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 unitdegC. So20 degC - 2 · 10 degCis20 degC - 20 degC, that is0 K. -
\sum_k yand\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 werey. -
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: infunction g(k) { t = k + 1; t }, thenlet 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 thescopeeffect 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-declarationerror, as for aletlocal read before itslet:function g(k) { t = t + 1; t }gavet + 1forg(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 checkreports the read before the program runs when the order is certain from the source. A block outside every function (a hostBlock, or a top-leveldoblock or loop body) is not changed:Block(x ≔ x + 1; 2x)is still2(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 }gave1. Aforloop whose collection evaluates to an error stops, and the error is its value (the loop ran no turn and the error was lost).epsil checkalso reports aletlocal used before itsletwhen 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 = πand30 deg = π/6areTrue, and6 percent < 0.07isTrue(they stayed unevaluated). Two exact values are compared exactly, and a float within the tolerance.isEqual(),is(),isIdenticallyEqual(), the order methods,Equal,NotEqual,Lessand the other relations,Max,Min,SortandClampagree (Max(6 percent, 0.05)is6 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}isQuantity(Pi, rad)(it was a product), and so are10^{-30}\,\mathrm{rad},\frac{1}{2}\,\mathrm{m},\sqrt{2}\,\mathrm{m}and2\pi\,\mathrm{rad}. A magnitude with a variable (x\,\mathrm{m}) is still a product. -
The
reciprocalandfactorfraction styles no longer put a symbol, a number or a function in parentheses.a/bwasa\times(b)^{-1}and\frac{1}{b}(a); it is nowa\times b^{-1}and\frac{1}{b}a. In thereciprocalstyle the denominator gets parentheses where the base of a power does (a sum, a product, a power:a\times(2b)^{-1}). In thefactorstyle 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 thefactorstyle (\frac{1}{b}, not\frac{1}{b}(1)). The default style uses thefactorstyle 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 declaredscopeeffect". A declaration of the same name made later is not written: withlet t = 0after the function,[g(1), t]is[2, 0], also whentis declared by a later program on the same engine or withce.assign(). An assignment to a name declared before the function (a top-levelletor assignment, a parameter or a local of an enclosing function) still writes that binding and is thescopeeffect. A function defined before a top-levelconstdoes not write the constant, andepsil checkdoes not report it; a function defined before aconstof the same block is still reported. -
Or,And,Nand,Nor,Implies,AnyandAlldo not depend on the order of their operands when an operand fails. ATrueoperand ofOrorNor, aFalseoperand ofAndorNand, aFalseantecedent or aTrueconsequent ofImplies, and a deciding element ofAnyorAlldecide the answer wherever they are, also after a failed operand:Or(1/"a" > 0, True)isTrue, asOr(True, 1/"a" > 0)already was,And(1, False)isFalse, andAny([1, 1/"a", 3], u ↦ u > 2)isTrue(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||).XorandEquivalentneed every operand and are not changed.ForAll,ExistsandExistsUniqueover a finite domain follow the same rule:Exists(x ∈ [1, 2], …)whose body fails atx = 2and isFalseatx = 1gives the error (it wasFalse),Exists(x ∈ [1, 2], x > n)stays unevaluated (it wasFalse), andForAllover{1, 2, 3}and over[3, 2, 1]give the same answer. -
AnyandAllover a source that is not known to be finite (Range(1, +oo),Repeat(5)) read at mostiterationLimitelements (1024 by default) and then stay unevaluated, asIndexOfdoes. Before, a walk with no decision did not end; a decision after the limit, such asAny(Range(1, +oo), u ↦ u > 5000), was found after a long walk and now needs a largerce.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 mand0 m = 0 sareFalse,6 W ≠ 6 misTrue, andisEqual()andisIdenticallyEqual()returnfalse(they stayed unevaluated or gaveundefined). A quantity with a dimension and a number are not equal either:6 m = 6isFalse, and a quantity is never equal to a string or a boolean (6 m = "a"and6 percent = TrueareFalse). Order has no meaning across dimensions, soLess,isLess(),Max,Min,SortandClampstay 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 vstill declares a localk. 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 bareAssignin a block, which changes akoutside the block, is written\mathrm{Assign}(k, v)and reads back without aDeclare: it was writtenk\coloneq v, which read back as a local, so a LaTeX round trip left the outerkunchanged. A typedDeclarein a block was dropped and is written withlet.letalso works in awhereclause and in a row of a one-columncasesblock. A function defined asf(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 namedletis read as one, asifandwherealready are. Afterlet,=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 aninvalid-typeerror. A name is declared once per block:x\coloneq 1; x\coloneq x+1; xis2(it was avariable-redeclarationerror). The rows of a one-columncasesblock are read as a;list is, so ak\coloneq vrow declares a localk(it changed thekoutside). -
Assigning a value to a constant has a stable error code, and
epsil checkreports it.ce.assign()and theAssignoperator still throwCannot assign a value to the constant "c", and the error now hascode: 'constant-assignment'andconstantName. In Epsil,const c = 1thenc = 2is reported before the program runs, as a static error with the codeconstant-assignmentand a note on theconst; before,epsil checkreported nothing and the run reported aruntime-errorwhose code was the message text. The check covers aconstof the program at any depth and each name of a destructuringconst, wherever the assignment is, also in the body of a function defined before theconst. Alet, 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,UnitConvertand the LaTeX parser give this form, so an evaluated quantity and the parse of its LaTeX are the same expression:(4 m)^(-1/2)isQuantity(1/2, Divide(1, Sqrt(m)))(it wasPower(Power(m, 1/2), -1)),(m/s)^2ism^2/s^2, andm^(2/3)·m^(2/3)ism^(4/3)(it wasm^1.3333333333333333). The unit ofAvogadroConstantisDivide(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)is6 A·V(it was6 W),(2 N)·(3 m)is6 N·m(it was6 J), and(1 J)/(1 kg)staysJ/kg.UnitSimplifystill gives the named unit (UnitSimplify(6 A·V)is6 W), and equality is not changed:6 A·Vequals6 W. -
A
ListorSetliteral that keeps a failed cell ([1, Error(…), 3]) is typedlist<unknown>(it waserror, so a variable that held it was a scalar): withfs := [Error(…)],ListJoin(fs, [2])is[Error(…), 2](it was[[Error(…)], 2]), and a variable declaredlistaccepts the value. An absent cell keeps its arm:[Error(…), Missing]is typedlist<unknown | missing>. -
A
ListorSetthat holds a failed cell can be iterated. In Epsil, withg(x) = 1/xandxs = [1, g("a"), 3], aforloop runs one turn for each cell, andMap,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,Mapreturned the list unchanged, and the comprehension stayed unevaluated.LengthandCountgive the number of cells (3), andFirst,At,ReverseandTakekeep 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,Meanand the other statistics,Sort,ArgMaxandMaxBy. A comprehension guard that fails on an element keeps the error in that position, asFilterdoes; 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, andMap(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, while1 ∈ [1, 1/"a"]isTrueand[1, 1/"a"] = [2, 1/"a"]isFalse. 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)isFalse.IndexOf,Countwith a value,Union,Intersection,SetMinusandSymmetricDifferencefollow the same rule.Unique,TallyandDedupfollow 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, whileUnique([1/"a"])is[1/"a"]. The list still carries its error (isValidisfalse), and it still does not compile. ATupleorDictionarywith 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 typednumber). -
isGreater,isGreaterEqual,isLess,isLessEqualand the operatorsGreater,GreaterEqual,Less,LessEqualread the range of a declared type: withp: integer<0..>,p.isGreaterEqual(0)andp ≥ 0aretrue(they wereundefined). A float within the tolerance of a number is not ordered strictly against it:ce.number(1e-12).isGreater(0)isfalse, asisEqual(0)istrue(it wastrue); an exact value is still ordered exactly (10^{-30} > 0).
Resolved Issues
-
A
Tablewhose 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 unevaluatedTabulate(…), 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
scopeeffect error: its body keeps its own locals. -
A numeric type with an excluded bound excludes it when a value is checked: with
gdeclared(real<0<..>) -> real,g(0)is anincompatible-typeerror (it gave a value), onevaluate(),.N()and a symbol holding0; the compiled JavaScript call givesNaNand the interval call givesempty. -
A compiled call of a function whose parameter has a declared type agrees with the interpreter. With
let g: (integer) -> integerthenfunction g(k) { k + 1 }(org = (k) => k + 1), the compiledg(1.5)gave2.5where the interpreter gives anincompatible-typeerror, andg(x)withx = 1.5did 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 givesNaNand the interval call givesemptyfor 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^-30or3·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
Aa matrix andg,kfunctions that were not declared,['Multiply', 'A', ['Multiply', g(x), k(x), g(x)]],A\cdot(g(x)\cdot k(x)\cdot g(x))andA(g(x)k(x)g(x))wereA·k(x)·g(x)·g(x), because the nested product was sorted on its own before the matrix was seen. They are nowA·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 asA(2\frac{1}{3}), an imaginary number such asA(2i)) is read as before. -
Parsing a nested juxtaposition in parentheses takes a time proportional to its depth.
(g(x)(g(x)(…)A)A)Awith 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
glslandwgsltargets, the product of the imaginary unit and a complex value is a rotation of that value:izwithzcomplex compiles tovec2(-z.y, z.x),i\tanh(z)to_gpu_cmul(vec2(0.0, 1.0), _gpu_ctanh(z))andt + i\tanh(z)adds the two complex terms. They compiled tovec2(0.0, z),vec2(0.0, _gpu_ctanh(z))andvec2(t, _gpu_ctanh(z)), and a shadervec2constructor reads only the first component of avec2argument, so the value was(0, Re w)wherei·wis(−Im w, Re w). The product with a real value (itisvec2(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 onjavascript,interval-js(_IA.powRational(x, 5, 11); it answered an empty interval),glsl,wgslandpython, and\sqrt[2.2]{x}gives the same (it compiled toMath.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 as0.3keeps its complex value. Oninterval-jsthe 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,Rootof a negative radicand under a non-integer degree isa^(1/b)asPowerreads it:Root(-8, 0.5)is64(it was −64),Root(-8, 2.5)is(-8)^{2/5}= 2.297 (it was −2.297) andRoot(-8, 2.4)is the complex principal value. -
Atwith 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]andAt([(1, 2), (3, 4)], [NaN, 1])is[Missing, (1, 2)], the same hole an out-of-range member gives. The call stayed unevaluated. The compiledjavascript_SYS.atagrees (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 -> realthenfunction 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·xandc := 5in the global scope, afterpushScope()anddeclare("c", "real"),g(c)isc·x(it was5x), and withdeclare("i", "real")in the pushed scope,g(i)isi·x(it wasImaginaryUnit·x). A call frame's parent is the scope that defined the function, so the body's read of the argument found the outercor the libraryiby 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 ofx_1_2, of{x_1}_2, and the canonical form ofSubscript(Subscript(x, 1), 2)) is now written{x_1}_2; it wasx_{1,2}, which reads back asSubscript(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_2is{\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_2is\mathrm{x1_2}(it wasx_{1,2}). A single subscript (x_1,x_{12}), a subscript with a comma list typed asx_{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: withAa matrix, the symbolA_1_2is written{A_1}_2, which reads back asSubscript(At(A, 1), 2)(A_{1,2}read back asAt(A, 1, 2)). -
A symbol with a style and a subscript reads back as the same symbol.
\mathbf{x}_1(the symbolx_bold_1) was written\mathbf{x_1}, which reads back as the different symbolx_1_bold; it is now written\mathbf{x}_1, andx_bold_1_2is written{\mathbf{x}_1}_2. The parse of\mathbf{x_1}, the symbolx_1_bold, was writtenx_{\mathbf{1}}, which reads back asx_one_bold; it is now written\mathbf{x_1}, andx_1_2_boldis 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_uprightis still writtenx_{\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 symbolx_1_2, asx_{1}_{2}is. It wasx_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 symbolx_1_2_bold. -
A sum or a product written with no bounds (
\sum_k x^k, which isSum(x^k, Limits(k, Nothing, Nothing))) is no longer read as a sum over the single pointk = Nothing:\sum_k x^{k-1}evaluated to\frac{1}{x},\sum_k x^kand\prod_k x^kto an error, and\sum_k yand\prod_k ybecamey. It runs overk = 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} kgave\frac{1}{2}. -
\sum_{k=1}^{\infty} \frac{x^k}{k!}evaluates toe^x - 1. It wase^x - x^0, which has no value atx = 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^ksimplified to\frac{x - x^{\infty}}{1 - x}, andx^{\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})^ksimplifies to2. -
simplify()of a sum or a product over an infinite range (\sum_{k=1}^{\infty} f, or\sum_k fwith 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 0simplified toIndeterminate; it is0, also underevaluate().\sum_k ysimplified to\infty \cdot y, which is wrong fory = 0; it stays as it is. A body of known sign gives\inftyor-\infty(\sum_k 3is\infty).\prod_k 1simplified toIndeterminate; it is1. A product of a constantcis the limit ofc^n:0for|c| < 1,\inftyforc > 1.evaluate()andN()now give the same values (\prod_k \frac{1}{2}is0; it stayed unevaluated). Forc ≤ -1, and for a free body (\prod_k ysimplified toy^{\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 (kfrom\inftyto 5) is not read as an infinite one.\sum_k (1 + xk)simplified to\infty, which is wrong forx < 0, and\sum_k kxto\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)^ksimplified toIndeterminate; it stays as it is, as underevaluate().
-
simplify()of a geometric series with an exact ratio keeps it exact:\sum_{k=1}^{\infty} (\frac{1}{\sqrt{2}})^ksimplified to the float2.414…; it is now1 + \sqrt{2}, as underevaluate(). The finite form\sum_{k=0}^{n} (\frac{1}{\sqrt{2}})^kkeeps 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!}ise^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 theNothingmarker 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))gave6j. It now stays as it is undersimplify(), andevaluate()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 theincompatible-typeerror in its position, and the other elements are kept ([3, Error(…), 7]); the comprehension stopped at that element. Aforloop still stops on it. -
A trigonometric function of an angle quantity with an exact magnitude is exact:
\sin(30\,\mathrm{deg})evaluates to1/2(it was0.4999999999999999), because the angle is converted with the exact factor of its unit (30 degisπ/6). -
A comprehension whose domain evaluates to an error has that error as its value:
Length,Count,Sumand an element read of it give the error. -
An Epsil function declared with a type and then defined (
let g: (integer) -> integer, thenfunction g(k) { k + 1 }) compiles on JavaScript and interval-js and gives the interpreter's value; the compilation failed withUnknown 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
scopeeffect, or a body whose effects are not in the stated effects (pure). Withlet t = 0beforefunction 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"]]and2\,\mathrm{m/m}are2(they wereQuantity(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}and2^{-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, asEqualdoes:Quantity(2, m).isEqual(Quantity(1.5 + 0.5, m))istrue(it wasundefined). -
Quantity arithmetic and unit conversion of float magnitudes use the working precision:
(π·(1 rad)).N()is3.14159265358979323846 rad(it was3.141592653589793 rad), and1.1 m + 1.3 cmis1.113 m. Atprecision = 'machine', machine numbers are used as before. -
Two quantities with different logarithmic units are not equal:
1 dB = 1 NpwasTrue, 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}and2.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 was2-\frac{3}{4}, which reads back as5/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 asg·(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 }andop(w)gave a new error, "expectedunknown, goterroratw". So did a parameter typed as a collection (list,tuple<…>) or asfunction, 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 ofifandWhich. A function with several clauses (op(a: number),op(a: string)) gave "expectednumber | string, goterror". The callback of a collection operator gave "expectedfunction, goterror":Any(xs, w),All(xs, w),Filter(xs, w),Reduce(xs, w),Scan(xs, w),TakeWhile(xs, w),MaxBy(xs, w)andMap(w, xs). The call now evaluates to the error ofLn, with the frame of the call, as it already did for a parameter typednumberor with no type, and forSort(xs, w). An element read directly (Map(w, xs).at(1), a walk ofFilter(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 still1/2. An argument of the wrong type is still refused, andMap(w, f), with the arguments in the wrong order, still reports the wrong order. -
A symbol declared
errorthat has no value, and a call typederroronly by its declared result type (g(2)withg: (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: numberapplied toegave "expectednumber, goterror"; they are now valid, asSqrt(e)and a function declared(number) -> numberapplied toeare. The condition ofIfandWhichadmits them too, as a parameter typedbooleandoes, 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 (fdeclaredfunction), or with a symbol that holds an error, is typed withunknownelements:Map(f, [1, 2])islist<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 as2 + 2/3; it is now the product4/3, as2\frac{x}{3}is a product.2\frac{1}{3}is still7/3. A mixed number in raw form (["InvisibleOperator", 2, ["Divide", 1, 3]]) is written2\frac{1}{3}with every fraction style; with thereciprocalstyle it was2\times3^{-1}, which reads back as2/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 declaredany, and a value of a type such asnumber | 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: withAa 2×2 matrix,A·g(A)·Aevaluated toA^2·g(A), andA^2·g(A)·A^3toA^5·g(A). Both are wrong whengreturns a matrix that does not commute withA.evaluate(),simplify()andExpandnow keepA·g(A)·A. A quotient whose denominator can be a matrix also keeps its position, because the quotient is element-wise:(A/g(x))·Aevaluated toA^2/g(x),A·(1/g(x))·g(x)·AtoA^2, and, withBa matrix,(A/B)·AtoA^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. -
Expanddoes 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, soA·(B + r)is notA·B + r·A:Expand(A·(B + r))gaveA·B + A·r, and now staysA·(B + r). A sum of matrices is still distributed, and each term keeps the order of its factors:Expand(A·(B + C)·g(x))isA·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)wasa) + (b, and the range of the statementlet 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 ofa. -
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 statementlet v = Ln(w) * 2underlined2, becauseLn(w) * 2is2·Ln(w)in canonical form; it now underlinesLn(w). This also applies to a library operator such asXorand to a user function markedcommutativeorassociative: withw = Ln("a")andopcommutative,op(z, w)underlinedz. When the operand is not sure, as inLn(w) + Ln(w), the whole call is underlined. -
A function marked
commutativeorassociativepasses an error value through, as the same function without the word does. Withw = Ln("a")andfunction op(a, b) commutative { [a, b] },op(z, w)gave a new error, "expectedunknown, goterror", in place of the error ofLn. A parameter without a type is now checked asanyat the call, as it is without the word. -
In the unit argument of the siunitx commands
\qty,\SI,\unitand\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-uniterror with the text of the argument:\qty{3}{foo}is["Quantity", 3, ["Error", "'unknown-unit'", ["LatexString", "'foo'"]]]. It was anunexpected-commanderror 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^kgave the same expression back, and now gives9x^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^kis\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)is2x + 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 is0. When a bound depends on the variable, the derivative stays unevaluated. -
A fraction written with the
nice-solidusfraction style reads back as the same division.ce.parse('x/10').toLatex({ fractionStyle: () => 'nice-solidus' })gives{}^{x}\!\!/\!{}_{10}, and parsing this LaTeX gaveSuperscript(Error('missing'), x); it now givesDivide(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 writtenx+1_{2}, which reads back asx + 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})is0, and\sqrt{t^2+3t} - \sqrt{t^2+t}gives1(they stayed unevaluated). The same is true for a radical minus a term that grows at the same rate:\sqrt{t^2+t} - tgives1/2at+\infty,\sqrt{t^2+t} + tgives-1/2at-\infty, and\sqrt[3]{t^3+t^2} - tgives1/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)|is2. 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 floats1.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 float3.14159265356268, with wrong last digits),x\sin(\frac{\sqrt{2}}{x})is\sqrt{2},x^2(1-\cos(\frac{\sqrt{3}}{x}))is3/2(it was1.4999999839369884), andx(e^{1/x}-1)is1(it stayed unevaluated). The limit code reads the value from the series of the function withx = 1/uatu = 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
Adeclaredmatrix<2x2>,A^2 A^3evaluates and simplifies toMatrixPower(A, 5), andA A^2toMatrixPower(A, 3)(they stayed products, such asMatrixPower(A, 2) * MatrixPower(A, 3)).A^2 A^2isMatrixPower(A, 4)(it wasMatrixPower(MatrixPower(A, 2), 2)), and(A^2)^3isMatrixPower(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^3stays as it is.A^2 A^{-1}stays as it is too, becauseA^2 A^{-1} = Ais true only whenAis invertible. -
Solveanswers an equation with a trig function of a periodic argument, such assin(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)) = 0is[], because|sin(x)| ≤ 1 < π/2. The solver uses each period of the outer function that the innersinorcosreaches:sin(4·sin(x)) = 0holds for4·sin(x) = 0,πand-π, and the roots ofsin(x) = -π/4are now in the answer. A scan of one period with an interval proof checks each list.tan(4·sin(x)) = 1andcot(4·sin(x)) = 1are answered the same way, with the half-turn period oftanandcot. 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 witharcsinorarccosof a constant in[-1, 1]is kept. Withxdeclaredreal,sin(x) = arccos(9/10)gives[arcsin(arccos(9/10)), π − arcsin(arccos(9/10))], andcos(sin(x)) = 9/10gives its four roots; both gave no answer, because the type ofarcsin(c)iscomplexwhen it does not use the value ofc.sin(x) = 2still 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)andarccos(1/3) − πare in the same family, but their difference did not evaluate to 0: the solver now usesarccos(-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, butsin(x) = -1gave[-π/2, 3π/2]and3cos(x) + 3 = 0gave[π, -π], two roots that differ by the period2π. 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 asconstructorortoString, was replaced by that inherited property:ce.box(["Add", "x", "constructor"]).subs({ x: 1 })gave"Undefined" + 1. It now givesconstructor + 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 + 1gives[2, 3], and so doesdo { let f = (a) => a + 1; [f(x) for x in [1, 2]] }; both stayed unevaluated. Amaporfiltercallback 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 aSum, aProductor a comprehension in a function body calls the parameter:(g) ↦ Σ_{k=1}^{3} g(k)applied toa ↦ a + 1is9(it wasg(1) + g(2) + g(3)), andΣ_{k=1}^{3} h(k)uses a functionhassigned 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
\inis a list, not an interval:\prod_{q\in [a\mapsto a+1, a\mapsto 2a]} q(3)is24. -
In a function body, an assignment in an
ifbranch to a local that a later statement of the body assigns writes that local: withfunction g(k) { if k > 1 { t = 5 }; t = t + 1; t },g(2)is6(it wast + 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)]]), andFilter([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 declareskin 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:SumandMeanover it give the error,Lengthgives5andFirstgives1. Before,Sumgave back the view itself andLengthstayed unevaluated. -
A pipe into
Mapover a list with a failed cell gives the same value as the direct call:[1, 1/"a", 3] |> Map(f, _)andApply(z ↦ Map(f, z), [1, 1/"a", 3])stayed an unevaluatedMap, and a loop over that value ran no turn. Substituting such a list withsubs()also keeps the expression bound. In Epsil, a statement whose value is amaporfilterover a list literal with a failed cell, such aslet ys = map((u) => u, [1, g("a"), 3]), is reported as aruntime-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,arcminandarcsecis a rational multiple of π, so180 deg = π radisTruewith no rounding, and1 deg < (π/180 + 10^-30) radisTrue(it wasFalse, 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
forloop or comprehension header is reported. Withg(x) = 1/x,for x in [1, g("a"), 3] { n = n + 1 }ran 3 turns and reported nothing, because the body does not usexand a header is not a statement. The loop still runs 3 turns, and the list gets aruntime-errorwith the same message and code as the statementlet 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 gavexsits 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 typetupleadmits value slots only. So[...t, 5]and{...t, 5}spread it (they are now thespread-tupleerror, as for any tuple), andJoin(t, [3, 4])witht := (1, Missing)had 4 elements (it now has 3, the first of which is the tuple).(1, 2) = p, withpdeclaredtuple<integer, integer | missing>and no value, wasFalse; 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 anincompatible-typeerror. A compiledDotof 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) = xwith an undeclaredxwasFalse, and so was the comparison with a symbol declaredtuple<integer, integer> | missingorinteger | tuple<integer, integer>.[1, 2].isEqual(x)wasfalsefor an undeclaredxor for a symbol declaredlist<integer> | integer; it is nowundefined. A literal is still unequal to a value that cannot have its kind:[1, 2] = (1, 2)and(1, 2) = 5areFalse, 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 andAtof a symbol of such a type threw or loggedMaximum call stack size exceeded. Such an alias is now a subtype of what its other arms are subtypes of: withcyc = cyc | 0,Norm(c)is valid and evaluates to|c|, where it was anincompatible-typeerror, andJoin([3, 4], c)withcyc = cyc | tuple<number, number>appendscas one element. An alias that names itself inside a constructor, such asjson = 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.5was typedlist<integer | list<integer> | real>, which does not hold its value[[1.5, 2.5], 3.5]; it is now typedlist<list<real> | real>. The same holds for an element whose type is an alias of a list (list<myints> + 0.5islist<list<real>>), for a list of ranges ([1..3, 5] · 0.5), and for a list of tuples with an imaginary component timesi. A sum or product that stays an integer keeps its type:[[1, 2], 3] + 1is stilllist<integer | list<integer>>. -
The static type of a collection operator keeps the
missingelement of an operand whose cells can be absent. WithL: list<integer | missing>,Join(L, [2])andListJoin(L, [2])were typedlist<number>, which excludes theMissingcell of the value; they are now typedlist<integer | missing>, asAppend(L, 2)already was. The same holds forDeleteAt,Partition,Chunk,ChunkBy,GroupBy,Zip,TakeWhile,DropWhile,RandomShuffle,Dedup,Cycle,ListFrom,SetFrom,DictionaryFrom,Values,Mapwith a function literal,Find,MaxBy,MinBy, and the set operations (Union(S, {2})withS: set<integer | missing>isset<integer | missing>, where it was the bareset). A position or a count over such a list is an integer:IndexWhere,ArgMax,ArgMinandCountIfwere typednumber, andPositionlist<number>.Flattenof such a list islist<any>, where it was the barelist. An operand typed withanyelements is treated the same way, becauseanyadmits an absent value:Flatten(M)withM: list<list<any>>andUnion(A, {2})withA: set<any>arelist<any>andset<any>, where they were the barelistandset. -
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). Withr: imaginary | integer,r + 1was typedimaginary | integer, which excludes its value1 + i; it is nowcomplex. The same holds for an alias of such a union, for list and tuple cells ([i] + [-i]isvector<complex^1>, where it wasvector<imaginary^1>with the value[0]), for a list of points plus a point, and forimaginary | nan, which becomescomplex | nan. A sum of two lists of ranges,[1..3] + [1..3], is typedlist<list<integer>^1>, where it waslist<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>andL := [[1, 2], Missing],L + 1was typedlist<list<number>>, but the absent element becomesNaN; it is now typedlist<list<number> | nan>. A function over a list of rows and numbers keeps the rows:Sin(L)withL: list<list<integer> | integer>was typedlist<number>and is nowlist<list<number> | number>. An absent row of points staysMissing, also when the point type is the baretuple(it wasNaN). -
Collection operators whose result can hold
Missingsay so in their type.Iterate(f, Missing)and anIteratewhose callback can returnMissingare typedlist<any>, where they were the values-onlylist.First([]),Last([])andAt([], 2)are typedmissing, where they wereunknown.Mapwith a callback that is declared for numbers or booleans over a source whose elements can be collections (Map(Sqrt, U)withU: any) admits a collection element; with a callback such asIsPrimeorNotover a list that can holdMissing, the element type has amissingarm.Mapwith a user function that has no declared result types the call: withg := c ↦ If(c == "a", "zz", c),Map(g, ["a", "b"])was typed as the source and is now typed by whatgreturns. AZipelement whose items are all absent isMissingthrough.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 aZip. -
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 written1/10+1/10\imaginaryI, which reads back as1/10 + 1/(10i), the opposite imaginary part. It is written1/10+(1/10)\imaginaryI(found by Tycho's round-trip audit of its Desmos corpus, whose failing trees are now part ofnpm run check:roundtrip). -
A mixed number keeps its tree in a LaTeX round trip (Tycho ledger row 390).
2\frac{1}{2}is the sumAdd(2, 1/2), which was written2+\frac{1}{2}withprettify: falseand read back as5/2. It is written as the mixed number whenever the parser rebuilds the same sum from it, with or withoutprettify:-2\frac{1}{2}was written-\frac{1}{2}-2, and500\frac{999}{1000}was written500+\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}is4(it was the sumAdd(2, 2)), andAdd(2, Rational(3, 1))is no longer written2\frac{3}{1}, which read back as 6. -
Printing an unevaluated lazy view does not run a side effect.
toString(), thelatexgetter andtoLatex()evaluated the cells of a view over a list literal to show a preview: printingDedup([1, H(1), 2]), withHdeclaredpure: false, calledHtwice, a view overRandom()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)]]andMap(w ↦ w(1), fs), withfsholding such functions, print as written. -
In Epsil, a
functionstatement in a function body works when its name is also the name of a library constant.function o() { function i() { 2 }; i() }theno()gaveError(ErrorCode("constant-assignment", "i"))instead of2; the same happened fore,Piand 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()andisGreaterEqual()gaveundefinedorfalsefor1000 mand1 km, because the unit symbols counted as unknowns; now they agree withEqual.NotEqualis the negation ofEqual(1000 m ≠ 1 kmisFalse,6 W ≠ 7 WisTrue; both stayed unevaluated).Max,Min,ClampandSortorder such quantities with no tolerance:Max(1 km, 999 m)andMax([1 km, 999 m])are1 km(they stayed unevaluated). The comparison converts both quantities to the finer of the two units, so1 eV = 2 eVisFalse(it wasTrue, because the difference in joules was smaller than the tolerance). It applies temperature offsets:0 °C = 273.15 KisTrueand0 °C < 1 KisFalse(they wereFalseandTrue). 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) misFalse. Under.N(), the exact magnitude of a quantity decides, as it does for a number:1 m < (1 + 10^-30) misTrue. The conversion is exact between two identical units and for every unit defined by an exact decimal number (in,ft,mi,lb,eVand all SI prefixes); the angle units andDaare 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 mwasTrueandx m < y mwasFalse. Quantities of different dimensions, and a quantity and a plain number, stay unevaluated. -
SortandOrderingof a lazy source give the error of an element that fails: withh(2)an error,Sort(Map(h, [2]))was[Error(…)]andSort(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])wasFalse. ARangeor anIntervalkeeps its arithmetic test, and an infinite source is not walked. -
Count(xs, p)gives the error when the predicate fails on an element, asCountIfdoes. Before, it counted that element as selected: withh(2)failing,Count([1, 2, 3], (u) => h(u) > 0)was3. -
On
interval-js, aWhichor anIfarm 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}waspartial(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
Foldwhose step reads its accumulator by index, such asq(s[1])withq: (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 asbroadcastable<tuple<integer>>, so compiling the fold at alist<integer>parameter, or compilingacc + fold[1], threw. The fold with seed[1, 0, 1]from the issue is now typedlist<integer | nan^3> | vector<integer^2>. -
An
IforWhichwhose condition may be a list of booleans or a single boolean keeps, in its type, each branch whose element type is not known. Withu: list,If(u[2] != 0, p, u)was typedbroadcastable<tuple<integer>>forp: tuple<integer>, but returnsuitself when the condition is a singleFalse. -
A seeded
FoldorReduceover 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 typedstring, but is0forn = 0. -
Assumptions in nested scopes combine: with
assume(b ≥ 0)and, in a nested scope,assume(b > 0),b.isPositiveandb.isGreater(0)aretrue(they wereundefined), whatever the order of the two facts. Withp: integer<1..>andassume(p ≥ 0),p.isPositiveistrue. -
On the asynchronous route, a user function whose argument contains a free symbol awaits its body: with
f := x ↦ AsyncOnly(x), whereAsyncOnlyhas only anevaluateAsynchandler,f(1 {0<t})gives the value ofAsyncOnly(1 {0<t})(it gaveAsyncOnly(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 operatorNstill evaluates, and(2 m)^nis 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)], typedlist<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
Unionwith more than 100 elements counts and lists a repeated element of a list operand once (Union(L, S).countgave 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 checkreports afunctionstatement for a name that an earlierconstbound, with the codeinvalid-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) dxis1005(it was1005 − 503 ln(−1005) + 503 ln(1005), which is1005 − 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 dxis2√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 3is0(it stayed as it was), andln 2 + (1/2) ln 3is(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) dxsimplifies 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), soarctan(−3 + 2√2) + arctan(3 − 2√2)is0.
- A sum of logarithms of positive rationals or surds with rational
coefficients:
-
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) dxisln(3)/2(it wasiπ/2 + artanh(2)).
Performance
-
On the
javascripttarget, a seeded random draw that reads no input of the compiled function, such asWithRandomSeed(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 by9θ + 8φ(0.027 ms per call against 0.0002 ms forsin(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 everyce.declare. Declaring 1000 functions with such a signature takes about 10 ms, where it took about 47 ms (#453). -
Equalof two sets, the duplicate removal ofUnion, 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 aUnionof 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 failingg("a")take about 3 s (they took about 20 s).
New Features
FindFittakes an optional fifth argument, a dictionary of options. Theglobaloption,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 MathJSONdictspelling 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; itsiterationsis the total over every solver run, and a deadline that expires during the search returns the best point found withtimedOut: True. An unknown option key, a name that is not a parameter, or a range that is not two finite numbers withlo < hiis anunexpected-argumenterror. 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 modela\cos(bx+c)+dx+e, the fit from the default start has period 8.0 and a sum of squared residuals of 0.36; withbsearched over(0.1, 6)it has period 2.9 and 0.0057.Integrategives 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 ofFloor,CeilandFractionalPartwith polynomials, polynomially weighted series and telescoping products, and reciprocal pairs of high-degree rational functions. For example,∫₀¹ x(1−x)^99 dxis1/10100,∫₁²⁰¹¹ √x/(√(2012−x)+√x) dxis1005, and∫_{1/5}^{5} (1+x²)/(x²+x⁹) dxis24/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 asSolveand returns numeric approximations of the solutions.Sum,ProductandTableaccept the shorthand iterator{i, n}, from 1 ton:Sum(k^2, {k, 3})is14. 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], andDivisors([6, 8])is[[1, 2, 3, 6], [1, 2, 4, 8]].Boole,DegreesandDMSalso accept lists. - Collections can provide three optional handlers,
countOf,indexOfandatBigint.PermutationsandCombinationsuse them to answerElement,At,IndexOfandCountwithout 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}, andListFrom({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,PartitionandScan, consistently reject sets. Convert a set to a list before using these operations. Solvereturns families for periodic equations:Solve(sin(x) = 1/2, x)gives[2πt + π/6, 2πt + 5π/6], with integert. Substitute integers fortor 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.Factorial2supports negative integers:(−1)!! = 1,(−3)!! = −1, and(−5)!! = 1/3. Negative even integers give~ooinstead ofNaN.- Quantity arithmetic preserves exact magnitudes:
Quantity(3, m)·πis3π m, and3 m / 2 mis3/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)is5.0, andDivisors(12.0)contains floats. Non-integer inputs remain errors where integers are required. - Values supplied as
complex.jsobjects are inexact. For an exact Gaussian integer, usece.number({ re: 2, im: 3 })orce.box(["Complex", 2, 3]). - Global declarations cannot replace library constants such as
PiorTrue. 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 defaultsrepeatingDecimaltofalse. SVDandSingularValuesgenerally leave exact matrices symbolic underevaluate(); 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])andMean([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
letorconstbefore its declaration reportsread-before-declaration. - Products and quotients of square roots stay exact when the combined radicand
is above 10⁶:
\sqrt{1009}\sqrt{1013}is√1009·√1013instead of the float1010.998…, and√1009/√1013is no longer evaluated to0.998…. Use.N()for a float.(√−13)^3evaluates to−13√13·i. - An element-wise
Whichor else-lessIfwith a point branch givesMissingat 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 givesNaN. D(x ↦ a+b, x)canonicalizes toD(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,fyand2freport the sameincompatible-typeerror asf\times yand2\times f; they were products. An application such as2f(x)does not change. IsSquare,IsTriangular,IsOctahedral,IsCenteredSquare,IsHappyandIsAbundantof a symbol stay unevaluated; they answeredFalse.- An ordering comparison of a set and a list, such as
Less(Set(5, 6), [1, 2]), is anincompatible-typeerror; it was compared element by element. JoinorAppendof a dictionary and an element that can never be a key-value entry is anincompatible-typeerror when boxed:Join(Dictionary(x: 1), [2, 3])stayed an unevaluatedJoin.- After
ce.declare()gives a function a signature, an Epsilfunctionstatement for that name replaces the body and keeps the declared signature; it added a clause. A statement with parameters outside the declaration reportsinvalid-clause-definition. cantorEnumerateComplexNumbers()yields pairs of fractions[[n, d], [n, d]]. The exportedfactorial2()gives the recurrence value for an odd negative integer.
Issues Resolved
- Numeric evaluation of user functions, blocks and lazy
Mapnow agrees with direct evaluation, including exact arguments near a rounding boundary:Map(Sin, [Pi]).N()is[0], andMap(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).
Solvehandles 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} = 4gives2.0and-2.0; real-domain pure powers avoid computing complex roots.- Improved
Series,LimitandResidueat algebraic and symbolic points, including1 + √2andπ/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)is4x. - Lazy collections with unknown lengths support more positional reads and
reductions:
Last(FlatMap([1, 2, 3], x ↦ [x, x]))is3. Incomplete collections remain symbolic rather than returning partial results, and operations requiring all elements respectmaxCollectionSize. - 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. Tabulatewith a non-positive dimension and ascendingTableranges with no elements return[]:Table(k², {k, 2, 0})is[]. Use an explicit negative step to count down.- Large
Permutations,CombinationsandPowerSetno 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 usesQuantity(...). Temperature conversion gives20 °C = 68 °Faccurately. - LaTeX round trips preserve derivatives, range steps, blocks, products after
sums or limits, and
Ifwithout an else branch. Small negative numbers and unit exponents print correctly, including withprettify: 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
varsobject. 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
SumandProductbodies 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 reportpartialwhen 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 sensitiveMod/Fractexpressions reportfloat32-unsafe-argumentwarnings.compileFunction()andcompileShader()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
doscopes 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,Heavisideand comparisons of an exact constant give the interpreter's value, also withconstantFold: false:Floor((25! − 1)/24!)compiled to25and now gives24. A rounding function of an integer variable that cannot be rewritten, such asFloor(k·π), declines compilation. - An assignment used as an operand compiles correctly:
(t ≔ 1) + 2compiled tot = 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,
NotGreaterevaluates,MapacceptsSubtract,Negate,MultiplyandDivideover 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
Sintakes 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 applyingFloorto(25! − 1)/24!returns24. - Lazy
MapandFilterevaluate impure list elements correctly. Numeric rounding functions also preserve exact surviving elements when a list drops an element that evaluates toNothing. Rangemembership and integer-gridLinspacesamples remain exact beyond the safe integer range.- Inverse trigonometric functions near complex branch points and
HurwitzZetawith 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.5prints as2^(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,DenominatorandNumeratorDenominatoruse it:Denominator(5, [])is an error, not[].
Behavior Changes
- Numeric element-wise operations on symbols typed
anyorvalueretain 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
lengthproperty. - Unit names such as
m,sandKare no longer declared in the system scope, and aQuantityno 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 withce.declare('s', 'number'). Sum(a)anda = [1, 2]stay unevaluated untilahas a value whenais declaredany. 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 · xisQuantity(3x, m), andsqrt(2 m²)is√2 m. Repeated unit factors combine into powers. - Corrected float division, powers and result types.
Sqrt(-13)/3gives(√13/3)iinstead ofNaN, andDivisorSigma(1, -12)is28(#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
Ifarity 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()orlistSequences(). - 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). Atover a collection of infinite count whose handler declines a position stays unevaluated instead of givingNaN(#449).- LaTeX derivatives with a symbolic order parse as
D(f, {x, n}):\partial_x^{n} fwas read as a first derivative.\partial_xtakes the expression that follows it:\partial_x f + gisD(f + g, x).
Performance
- Faster repeated evaluation of symbolic values and nested user functions.
0.154.0 2026-10-15
New Features
LatexSyntax.dictionaryreturns 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])is14/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.0is[[1,4],[9,16]]with float entries. - Float exponents retain their inexact meaning in simplification and polynomial
operations;
0 / 2.5is0.0. - Function results are checked against their declared result types.
AppendandJoinover a source whose type does not fix its kind are typedcollection<T>, notlist: withadeclaredany,Append(a, 1)is typedcollection<any>.- Comparisons with a
Measurementuse its nominal value:Less(Measurement(0.7468, 0.001), 1)isTrue.Floor,Ceil,Round,TruncateandModof a measurement return the bare result for its nominal:Floor(Measurement(0.7468, 0.001))is0. ce.declare()throwsThe symbol "Sq" is already declared in this scopewhenSqis an operator declared earlier without asignature(the second declaration replaced the first). To change an existing definition, usece.declare(name, patch, { extend: true }).- The
addandmulrational helpers exported by@cortex-js/compute-engine/numericsreturn[±Infinity, 1]for an infinite operand times a finite non-zero one (they returned[NaN, 1]).powof an infinite base follows IEEE 754 (it threw aRangeError). - In Epsil, a bare
name -> valuecall argument is a rule in every position:f(GridLines -> [10, 10])round-trips.map(x -> x^2, xs)reportsmapsto-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| dxis4(contributed by KingArth0r). - Symbolic integration respects angular units: in degree mode,
∫₀^{180} sin x dxis360/π. - Numeric integration handles near-divergent but convergent powers correctly;
∫₀¹ x^{-0.9999} dxis10000. Limits compare exponential and algebraic growth correctly:lim_{t→∞} t/e^{√t}is0. - Complex special functions select the correct branch, including
LerchPhi(0, 1/3, -8).Argumentsupports the requested numeric precision, and compiledGammahandles 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 completeEllipticE,Hypergeometric2F1andLambertWreturn complex values inautoandcomplexmode, where they returnedNaN: compiledLerchPhi(3, 2, 1)is0.7734 - 1.1505i. The GLSL and WGSL targets decline a provably complex value (#434, contributed by enumeratio). Transposeof an operand with no known shape is typedunknown, soMatrixMultiply(q, Transpose(q))with a freeqstays unevaluated instead of returning anincompatible-typeerror (#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
Atlowering on thejavascripttarget 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 withArray.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 orrules: null. Exact symbolic values such asLn(2)stay symbolic. Numeric relations simplify to truth values:(3 < 4).simplify()isTrue(#306).Gamma(n)evaluates exactly for positive integers:Gamma(4)is6(#429).- Numeric evaluation reaches single-statement user-function bodies directly.
codeof compiled JavaScript reads a constant list of three or more finite real numbers from a_lit_<n>local defined inpreamble. A host that evaluatescodewithoutrun()must includepreamble(orpreambleOnceonce andpreamblePerCallon every call). Acompilehook must treat an array operand as read-only.CompilationResult.entryPlanno 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, notRoot(-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: truewhen its handler evaluates every held operand and reads nothing else of them. UnderevaluateAsync, 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/nis+∞. 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 forReduce(…).N().
Issues Resolved
- Infinite telescoping sums and Wallis products evaluate and simplify correctly:
Σ_{k=1}^{∞} 1/(k(k+1))is1, and2∏_{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()is1366. Modof exact radicals remains exact:Mod(√2, 1)is√2 − 1.
Issues Resolved
GCDandLCMreturn exact results for large integers:GCD(2^100, 2^50)is2^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.
Ifresult 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 anevaluateAsynchandler does after anawait:ce.withEvaluationContext(options, () => x.canonical).evaluateAsync(options).
Behavior Changes
Solvereturns every complex polynomial root when no domain is specified, or when solving over the complex numbers. Supported closed forms remain exact:x³ − 2x + 1 = 0gives[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
evaluateAsynchandler does after its firstawait(ce.box(),ce.parse(),evaluate(), canonicalizing a held operand) sees the global scope, not the index of an enclosingSum. Run it insidece.withEvaluationContext(options, fn). DSolvewrites a complex characteristic root and its conjugate as real modes:y''' - y = 0givesc₂·e^(-x/2)·cos(√3x/2) + c₃·e^(-x/2)·sin(√3x/2).- In the lenient grammar,
√2 √3and√2 ∞reportambiguous-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,EvaluateandReleaseHoldyield 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
Rangeelements remain distinct beyond 2^53.Appendpermits 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.EllipticPisupports 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 ofstep.Round(226, 10)is230;Round(3.14159, 2)is now4. To round to two decimal places, useRound(3.14159, 1/100)for exact157/50, orRound(3.14159, 0.01)for float3.14. Translate decimal-place arguments from other tools accordingly (#417).- Negative
TakeandDropcounts work from the end.Take([1, 2, 3, 4, 5], -2)is[4, 5], andDrop(..., -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.countcan return abigint. Its type is nownumber | bigint | undefined; large finite counts usebigint, while infinite counts useInfinity. Check the type before arithmetic with a JavaScript number. For example,Count(Range(1, 10^20 + 1))is exactly100000000000000000001. Indexing from the end of collections with bigint counts is not yet supported (#416, reported by enumeratio).- Public complex types now use
complex.js2.4. Update imports of theComplexclass fromcomplex-esmtocomplex.js.
New Features
ce.roundingTiesselects the tie rule forRound:"away-from-zero"(default),"to-even","toward-zero","toward-positive-infinity", or"toward-negative-infinity". Compiled functions retain the rule selected at compilation.Remainderkeeps its existing tie rule (#417, requested by enumeratio).Floor,CeilandTruncateaccept a step:Floor(226, 10)is220,Ceil(226, 10)is230, andTruncate(-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) dxis1/2, and∫₀³ floor(x) dxis3(#413, contributed by KingArth0r). - A
countcollection handler can return abigintfor any count;expr.countconverts a safe integer to anumber(#416).
Behavior Changes
- Running an Epsil program with
executeEpsilor theepsilcommand 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, assignce.iterationLimitbefore running a program. - The branch
kofLambertW(z, k)must be an integer:LambertW(-1, -0.1)is anincompatible-typeerror. 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^bisx^(a+b).evaluate()is unchanged (#415, reported by enumeratio).- String operators accept a
characterwherever 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 inferredcharacter | string, notstring. - When the operand types do not select one signature of an overloaded operator,
the result type is the union of the possible results: with
sof unknown type,Slice(s, 1, 2)is typedlist | string, notlist. - Effect inference is more precise. A local that nested functions only read
needs no
scopeeffect, a function has the effects of itsletvalues (let x = Random()makes itrandom), and a dictionary has the effects of its values ({"a" -> Random()}israndom, 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. TakeandDropclamp 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])is5e-200at machine precision. - Float division avoids an extra rounding:
0.3/0.3is1. Compiled rounding of large integers andRemainderties 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
PolyLogandLerchPhiat 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-radicalfor√2 x(Tycho row 366).\mathbb{Z}^-and\mathbb{R}^-nameNegativeIntegersandNegativeNumbers.
0.148.0 2026-10-05
New Features
N(x, [p, a])accepts significant-digit and absolute-accuracy goals, stopping when either is met. UsePositiveInfinityto 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
createJavaScriptRuntimefrom@cortex-js/compute-engine/runtimeand useruntime.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)isResidueClass(2, 5), and1/ResidueClass(3, 7)isResidueClass(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\ointnotation. Reports include poles, residues and the integral. A pole on the contour givesIndeterminate; unsupported cases remain symbolic (#410). - More real integrals evaluate exactly using residues:
∫_{−∞}^{∞} sin(x)/x dxisπ, and∫₀^{2π} dx/(2 + cos x)is2π/√3. Principal values must be requested explicitly withRealLineContour(True). - Complex numeric evaluation now covers Bessel and Airy functions,
Sinc, Fresnel functions, and every integer branch ofLambertW. Error functions and trigonometric integrals also support imaginary-axis arguments. CompilationResulthas the new fieldsruntimeVersion,preambleOnce,preamblePerCall,callCode,entryPlanandreconstructionDigits.createJavaScriptRuntime()accepts the optionsrandom,frame,iterationLimitanddeadline. Withrandom: null, a draw throws aCapabilityDeniedError; teste.name, notinstanceof(#372).
Behavior Changes
- Absolute-value bars on a set or string give its size:
|{1, 2}|is2. Solvereturns a list only when it can establish completeness. Otherwise it stays unevaluated, andexpr.solve()returnsnull. Unsupported computed collections are type errors rather than empty solution lists.Nconsistently returns inexact numbers, preservesce.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/Argreturns angles in the engine's angular unit.- Lenient parsing reads
√12as√(12), multi-digit subscripts as whole subscripts, andsin^{-1}(x)asarcsin(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: forB_n = π·B_{n−1}/nwithB_0 = 1,B_10isπ^10/10!; it stayed unevaluated.ce.getSequenceTerms()returns such terms, andce.getSequenceCache()holds only exact terms.- When the unknown has no declared type and no domain is given,
Solvegives all roots of a polynomial, complex roots included (x^2 + 1 = 0is[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 roundsv, soN(Integrate(…), p)is aMeasurement.Derivative(f)is a function literal also when its closed form holds the derivative of another function: withh(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) := bodyas a function definition (#400, contributed by enumeratio). Solverespects 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)andLambertW(-1/e, -1)are exactly-1. - Limits at infinity respect complex values and unknown coefficient signs;
NLimitat−∞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
Arctan2and 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_2000of the Fibonacci recurrence gives its 418 digits. Arcosh(0)andArcoth(0)evaluate toiπ/2, andEllipticK(0)andEllipticE(0)toπ/2. A sum whose radical-coefficient terms cancel is0:(√2/4·x).sub(√2/4·x)was0·x(#409).Countof set operations is correct:Count(SymmetricDifference({1, 2, 3}, {2, 3, 4}))is2; it was1.- With a user value of
Pi,evaluate()no longer uses the library π: withPiequal to3,sin(π)issin(3); it was0. D(f(x), f)stays symbolic; it was0.- A product whose LaTeX ends with a symbol keeps a
\timesbefore a parenthesized factor:2v\times(x+1). The form2v(x+1)read back as a call ofv. - 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)gives5, as in the interpreter. It gave4.
0.147.0 2026-10-02
New Features
- Added Wolfram Language aliases
EulerPhi,PartitionsPandDet. - Lenient parsing reports more ambiguous notation, including inverse functions, primes and scripts.
Behavior Changes
TotientandNPartitionevaluate 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.
ArctanandArccotuse branch-cut values consistent with odd symmetry:Arctan(-2i)is approximately−π/2 − 0.549i.- Lenient parsing gives
**the same precedence as^:-x**2is-(x²). Chains containing only**still associate from the right. - An exact complex number divided by an exact number is one exact complex
literal:
i/3is["Complex", 0, ["Rational", 1, 3]], and2/iis-2i. - Numeric routines return floats also when the value is an integer:
NIntegrate(1, 0, 2)is2.0, not2. This also applies toNLimit,ND,NDSolve,FindFitandFindRoot. - The rational eigenvalues of an exact matrix larger than 3×3 are exact, and
Eigenvectorsthen 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,BarnesGand 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
StieltjesGammais 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)is2, andRationalReconstruction(6, 11)is1/2. Also addedPrimitiveRootListand a target-list form ofMultiplicativeOrder. - Added
DirichletEta,DirichletBeta,DirichletCharacterandDirichletL:DirichletEta(1)isln 2,DirichletBeta(1)isπ/4, andDirichletCharacter(5, 2, 2)isi. - Added generalized Stieltjes constants:
StieltjesGamma(0)isEulerGamma;StieltjesGamma(n, a)supports orders through 30. - Added
LogGamma,BarnesGandLogBarnesG.LogGammais the analytic continuation of log gamma, with branch values distinct fromGammaLn.BarnesG(5)is exactly12;PolyGamma(-1, z)isLogGamma(z). - Added
ClausenCl(n, θ):ClausenCl(2, π/2)isCatalanConstant. - Added
StirlingS2as an alias forStirling.
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
Indeterminatewhen divergence is established. Unresolved pole locations remain symbolic. NIntegratetries quadrature first, and constant numeric integrals can be folded during compilation.PrimitiveRootandMultiplicativeOrderignore 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
NIntegrateis(function, lower:number, upper:number) -> number. ATupleof bounds or a missing bound is an error. For a multiple integral, useIntegrate(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 dxisπ/2to 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.
GammaLnis more accurate near 0, 1 and 2. Compiled JavaScript preserves small complex results.FromDigitsandChineseRemainderreject non-integer elements; infinite input collections no longer exhaust memory. Stirling and Eulerian numbers outside their valid triangle return0.- 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) dxis(x + 1)·ln(x + 1) − x. It was(x + 1)·ln(x) − x + 1, and∫₀¹ ln(x + 1) dxwas+∞.- Compiled Python
Arccotof 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 withce.libraryOf(name)(#393). - Extend an operator with
ce.declare(name, patch, { extend: true }). Unspecified fields are retained;addSignatureadds an overload (#394). - Operator definitions can provide partial derivatives with the
derivativekey. Evaluation handlers receive aprecisionoption for numeric requests (#393). - Lenient parsing reports more
ambiguous-*diagnostics for notation such asx^2y,sin x y,1..10..2andlog(x, 2). Usece.parse(text, { strict: false, onAmbiguity: 'error' })to turn ambiguous readings into expression errors; the default is'report'. - The new
exponentialEserialization option sets howExponentialEandExpare written. TheimaginaryUnitoption now also sets howImaginaryUnitand complex numbers are written. The defaults are\exponentialEand\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, andletter-before-decimal→ambiguous-letter-decimal. - LaTeX output uses standard notation for
LCM,Log2,Log10, number sets andDegrees(#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 wasargument-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,ReandImbefore a parenthesis asMod,Power,Truncate,RealandImaginary:mod(7, 3)evaluates to1.
Issues Resolved
- Declared symbols mapped with
varsremain 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
\mpor∓has a reading:\mp 1isMinusPlus(0, 1). Calls whose arguments containFloor,Ceil,RoundorTruncateare 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. Aboutreports examples and keywords, and lists operator attributes, including laziness (#398).
Behavior Changes
Solvereturns a free parameter for identities and remains symbolic when it has no justified candidate root (#397).- Exact rational
Rangebounds and steps produce exact elements (#397). - Logarithm combination and splitting require provably non-negative arguments (#397).
- The
attributesentry ofAboutis 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
Solvehandles more linear and logarithmic equations and preserves exact exponential roots:Solve(eˣ = 3, x)givesln(3).- Derivatives respect custom library definitions and user replacements: after
Sinh(x) := 3x,D(Sinh(t), t)is3(#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. TheresolveApplicationhook 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,DeleteAtandInsertcompile to JavaScript and Python (#386).Max,Min,Length,Count,SumandProductof an operand typed as an abstract collection compile to JavaScript (#385).
Behavior Changes
- Nested list types describe their corresponding matrix shapes.
SumandProductpreserve 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
xdeclaredlist<list<integer^2>^2>,x^2is 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
Reduceover 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. HurwitzZetasupports 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 andTabulateare typedlist<T>(Tycho row 337).- A rounding function,
Fract,Mod,Sign,Heavisideor a comparison of an exact constant expression is resolved underevaluate()by raising the precision:Floor(π·10³⁰)is the exact integer3141592653589793238462643383279, andFract(π)isπ − 3. - To get the display preview from
evaluate(), passmaterialization: [5, 5].toString()and.latexstill print the preview. - To accept an absent argument at an annotated parameter, annotate it
T | missingand testisMissing(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. Withfdeclared(integer) -> unknownand assignedx ↦ x + 1,f(1.5)gave2.5and is now anincompatible-typeerror. - A parenthesized derivative operand ends at its closing parenthesis:
\frac{d}{dx}(x)+1isD(x, x) + 1, where it wasD(x + 1, x). A factor written after the parenthesis is outside the derivative:\frac{d}{dx}(x+1)(x-1)isD(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,BinomialandFactorialcontain their true values. Compiled binomial values agree outside0 ≤ 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
LerchPhiandPolyLogsupport 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
Selfto the selected implementation's type. Compiled ranges no longer capture user variables namedior_e(#362, contributed by enumeratio; #367, reported by enumeratio). Floor,Ceil,Round,TruncateandFractof an exact number are exact:Floor((25! − 1)/24!)was25and is now24, andFract((25! − 1)/3)was−1/3and is now2/3(#382, reported by enumeratio).- A
Pipestage that uses no topic slot and whose value is not a function is anincompatible-typeerror:5 |> Max(3)gave3. ce.number([n, 0])is~ooforn ≠ 0, asce.box(["Rational", n, 0])is; it wasNaN.ce.number([0, 0])isIndeterminate.- The
materializationoption no longer truncates the operands of an eager operator:Length(Range(1, 5000)).evaluate({ materialization: true })was11. - A fold's accumulator is typed from its seed and its combiner's result.
MapandFilterover 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
Rangecompiles to a counting loop on the JavaScript target, without building the range:Count,CountIf,Any,All,Length(Filter(…))andFilterover 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
IndeterminateandisIndeterminatefor exact indeterminate forms.0/0and other exact indeterminate calculations now use this value;.N()and compiled code use NaN. It is distinct from an absent value:IsMissing(Indeterminate)isFalse(#355). epsil --compilecompiles and runs JavaScript, using machine arithmetic. Unsupported constructs and unbound symbols report runtime errors.ce.withStepBudget({ steps, label }, fn)bounds cooperative engine work. Exhaustion throwsCancellationErrorwith cause'step-budget'(Tycho row 326).- Added
LerchPhi(z, s, a), the Lerch transcendent, and support for non-integer and complex orders ofPolyLog:PolyLog(2.5, 0.5).N()is about0.5549972787175124. Unsupported numeric cases remain symbolic (#340, contributed by enumeratio). PolyGamma,DigammaandTrigammasupport complex arguments (#340, contributed by enumeratio).- Added exact eigenvalues for supported 2×2 and 3×3 matrices, plus evaluation of
AdjugateMatrixandPseudoInverse. PolyLogandLerchPhicompile to JavaScript, GLSL and WGSL for real operands.CancellationCausegains'step-budget', andisTimeoutCancellationistruefor 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.
Indeterminateis a reserved name.- Code that tests a result with
x.isSame(ce.NaN)no longer matches an exact indeterminate form: usex.isNaN, which istruefor both values, orx.isIndeterminate.["Divide", 0, 0]canonicalizes toIndeterminate. - A list literal with a spread canonicalizes to the new operator
ListJoin:[...a, 0]isListJoin(a, [0]), where it wasJoin(a, [0]). - An integer-valued float serializes as
2.0in LaTeX and{num: "2.0"}in MathJSON, so it reads back as a float; it was2. WithfractionalDigits: 0(LaTeX) ordigits: {fractional: 0}(MathJSON) the integer spelling is kept. -(2x)canonicalizes toMultiply(-2, x); it wasNegate(Multiply(2, x)).-\frac{x}{2}isMultiply(-1/2, x), the same expression as-\frac{1}{2}x(#345, contributed by enumeratio).Beta(2, 3),Zeta(3)andLb(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,FilterandScanover a symbol declaredcollection<T>that holds a list give a list:Map(x ↦ 2x, P)forP = [3, 1, 1]is[6, 2, 2], where it wasSet(6, 2).ce.Zero.div(ce.PositiveInfinity)is0, as theDivideoperator gives; it wasNaN. A float numerator over an infinity is the float0.
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). varsmappings 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
Arcoshand 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 * 2nand library names in the MCP compile tool (#345, contributed by enumeratio; Tycho row 333). - A compiled
forloop 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 gaveNaN(Tycho row 332). - More exact special values:
Log(1/8, 2)andLb(1/8)are-3,Sinc(Pi)is exactly0,Arccot(1)isπ/4, andComplexRoots(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 orBigDecimalvalues for each component.isComplexis public on the number-literal interface.
Behavior Changes
- Decimal spellings consistently produce floats at machine precision, and a float operand makes numeric results inexact.
Integrateapplies coordinate by coordinate to tuples.- More logical connectives use three-valued logic for absent operands. Compiled
absent points, tuples and lists use
undefinedin arithmetic. - User bindings shadow capitalized library names. Binding
Nothing,MissingorUndefinedis an error. Lengthof an infinite collection is+oo.StringFromof text or a symbol returns its text.serializeEpsilwrites the lowercase spelling of a library name:["Sin", "x"]issin(x)and"Pi"ispi. The new optionlibraryNames: 'mathjson'restores the previous output, and the newisBoundoption reports names bound outside the expression.- A round trip through
parseEpsilalone gives the raw lowercase head (["sin", "x"]). RunresolveLibraryNamesto read it back toSin. isMachineNumericisfalsefor a number or list that holds an integer-valued float such as2.0.Timingreturns an unnamed tuple,tuple<number, value>: read the parts asTiming(expr)[1]andTiming(expr)[2].Timing(expr).resultwas anincompatible-typeerror.x^{0.5}staysPower(x, 0.5). Only an exact1/2becomes\sqrt{x}.
Issues Resolved
- Complex results retain working precision and imaginary components outside
double range.
Exp(Ln(−2)).N()is−2. Modretains remainders for floats near the double range. Compiled logical operations preserve three-valued results.GroupBysupports 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. ComprehensionandLoopclauses accept guards.SVDsupports complex matrices under.N().
Behavior Changes
- Decimal literals such as
1.0and0.0consistently remain floats.1.0/3is a float, andx^1.0retains its exponent. Integer-valuedBigDecimalinputs toce.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
0or±1produced by a computation is kept:0.5·2xevaluates to1x(it wasx),0.5x + 0.5xis1.0·x, and0.0 + xstaysx + 0.0. A float0still absorbs a product:0.0xis the float0. GCD,LCMandFactorial2of big integers are exact at the default precision. They were floats.- Compiled comparisons with a
MissingorUndefinedoperand give the undecided value (undefined, PythonNone). They answeredfalse. AnIforWhichover such a comparison takes no branch and givesNaN. - A lazy collection whose element type has a
nanmember from an element read is accepted at a list, collection or indexed-collection parameter when its element type withoutnanfits. An eager list that holdsNaNis 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.
Mapwith a named callback uses its signature. - List-valued integrands integrate element by element. Hyperbolic and angular special values evaluate consistently.
XorandEquivalentaccept 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}is4, and(8x+8)^{2/3}·yno longer gives a float. A root that is not exact stays a symbolic factor.(-8)^{2/3}is4, the real root (it was8^{2/3}·i).
0.138.0 2026-09-27
New Features
Zetasupports complex orders and a second argument; addedHurwitzZeta. For positive real base points,Zeta(s, a)agrees withHurwitzZeta(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)is0,Cosh(0)is1, andCoth(0)andCsch(0)areComplexInfinity(#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)^2retains 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
unknownparameters no longer cause slow type updates in other functions.
0.137.1 2026-09-26
Issues Resolved
- Functions declared with an
unknownresult 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
unknownresult 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,
MissingandUndefinedentries. - A predicate returning
Missingdoes 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: forf := (x, y) ↦ x + y,f([1, 2], [1, 2, 3])was[2, 4]. UseZipto 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^xno longer simplifies to2^{x+1}.- Compiled JavaScript: a
Sumwith a square root that is real by its type no longer givesNaN.
0.136.2 2026-09-25
Issues Resolved
- Function result types follow their bodies for
nan | realarguments. - 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
MissingandUndefinedas NaN rather than dropping them. Numeric out-of-range reads and traces of absent values also return NaN. SortandOrderingput absent elements last.SVDreturns 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)andDivide(Missing, Missing)areNaN(they were0and1).
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)is2i(it wasNaN).
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.
Undefinedelements and arithmetic operands are typed as absent.Dot([1, Missing], [1, 1])is NaN.CscandCotof a tiny nonzero angle give a signed infinity:Csc(5e-324)is+ooandCsc(-5e-324)is-oo(both were~oo), also in compiled JavaScript and Python.
Issues Resolved
- Exact complex roots and exponentials retain symbolic values:
√iis(√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
ComplexInfinityhas a consistent MathJSON form. - Perfect-power roots of large integers evaluate.
Artanhnear zero andZetanear its pole retain small differences. - Points, matrices and lists preserve absent coordinates and elements;
coordinate operations, norms and mapped functions return NaN or
Missingconsistently with their result types. Rangeretains endpoints on its step grid. Compiled ranges respect compound bounds and steps, and shader coordinate access handles absent points.Mapconsistently 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 ofTan,Cot,SecorCscissingular. It gave a very large finite interval, which plotted as a wrong segment. - A bare
functionover 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
threadsConditionalsto preserve restrictions around whole-value operations:f(When(v, c))becomesWhen(f(v), c). simplify()applies more loaded Fungrim identities, includingHypergeometric0F1(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
compiletool 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.
Mapover a range or indexed collection has typelist<T>, and rational products have rational types.- With
AandBlists,(A, B)canonicalizes toPointList(A, B)and serializes as\operatorname{PointList}(A, B). - Arithmetic or a function such as
Sinapplied to aTuplewith a list coordinate is anincompatible-typeerror:Sin(Tuple([1, 2], 3))was([sin(1), sin(2)], sin(3)). UsePointList(A, B)for a list of points. - In strict mode,
[1,2,3][1:2]is anincompatible-typeerror (it stayed unevaluated). Use[1,2,3][1...2]. Sin,Powerand a scalar factor of a restricted point whose condition is false giveMissing, notNaN. Test the result forMissinginstead of usingisNaN.- 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.codeis unchanged.
Issues Resolved
- Restricted points and point lists work with
Distance,Norm,Dot,Crossand coordinate accessors. False restrictions giveMissing; 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)is2e-12. Relational comparisons retain their tolerance.SortandOrderingstay symbolic when order is undecidable and put NaN last. Rgbpreserves extended sRGB channels. Color output uses gamut mapping:ColorToString(Oklch(0.7, 0.4, 30))is#ff5843. AddedGamutMap(color, gamut?)and Display P3 output.HsvandHslclamp 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. UseNorm(A)orNorm(A, "Frobenius")for the Frobenius norm.- Compiled JavaScript linear algebra checks whether real inputs contain complex
entries.
mode: 'complex'selects complex computation;entryChecks: falsedisables entry checks. - Symbolic integration supports a
stepBudgetoption inloadIntegrationRules. Caller deadlines propagate as cancellation errors through compilation, integration and differentiation. timeLimitMsdefaults 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,SecorCscwhen the argument is within about 100 ulps of a pole:Cot(1e-13)is1e13(it was~oo), andcot(1000π)is~oo. Compiled code givesInfinity(JavaScript) ornp.inf(Python) at a pole. - On GLSL and WGSL,
MandelbrotandJuliatakecomplexpoints, passed asvec2.
Improvements
- Compile results include
freeSymbolTypes. SetstrictTypes: trueto 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))²is4i. - 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])is3.Modebreaks 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.
Timingconsistently documents its result as time in microseconds, then value.- Compiled Python gave
2 * x + zfor2(x + z). determinant()of thenumericsentry point no longer throws aTypeErrorfor 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
StringFromdecodes non-negative integers as Unicode code points.StringFrom(65)is"A", andStringFrom([127467, 127479])is"🇫🇷". UseStringFrom(65, "default")for decimal text. The empty list gives"".- Collection equality propagates absent elements.
Equal([1, Missing], [1, Missing])isMissing, as isNotEqual. A length mismatch remainsFalse;.isEqual()andIdenticallyEqualstill recognize structurally identical collections.
Behavior Changes
Undefinedbehaves as an absent value likeMissing: numeric operations return NaN,IsMissing(Undefined)isTrue, andCoalesce(Undefined, 2)is2.- Scalar callbacks broadcast over collection elements in more collection
operations: with
f(x) := 2xdeclared(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[]. RandomChoiceon a string returns a string:RandomChoice("abc", 5)has five characters. UseCharacters(...)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
Listof numbers, not a lazyMap(f, L), soNorm(2·L)is a number. Some values can change in the last digit.
Issues Resolved
- Deep expressions parse and evaluate without stack overflow.
Rounduses ties away from zero at every precision:Round(-0.5)is-1.Expagrees across evaluation routes at machine precision.Linspace(-1e308, 1e308, 3)gives[-1e308, 0, 1e308], and large float products retain their inexactness.Dotbroadcasts over point lists and checks dimensions. Extrema and ordering preserve undecided and absent values correctly.- Compiled
MissingandUndefineduse 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.
FindFitandFindRootattribute 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
MaxandMinof scalars and collections compile oninterval-js, includingMax(0, Min(1, L)). Empty collections contribute no elements:Min(1, [])is1.- Faster point-coordinate access;
PointZover 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ˣ dxwith 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,ExpandSign. - Compiled complex arithmetic agrees with the interpreter at poles:
1/(0·i)is~oo, its absolute value is+∞, and its reciprocal is0. - Compiled coordinate reads detect incompatible point dimensions and preserve user variable mappings and operator overrides.
0.132.1 2026-09-19
Resolved Issues
resolveApplicationcallbacks preserve range precedence, including offsets in range bounds. Returningundefinedno longer changesf(x)+1...nintof(x)+(1...n).
0.132.0 2026-09-19
New Features
resolveApplication(context)lets a parser host choose whetherf(x)is a call or a product. Returnapply,multiplyorundefined; explicit declarations, parameters andresolveSymbolfacts take precedence.
Behavior Changes
- Explicit declarations, including
unknown, take precedence overresolveSymbol. 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,fooremainsf·o·o; Greek constants retain their splitting rules, as inpix(3).
0.131.3 2026-09-18
Resolved Issues
- The static type of
PointListincludes the width of a matrix component.PointList(M, V)withMamatrix<3x2>andVavector<3>now has the typelist<tuple<vector<2>, number>^3>. The width was missing before.
0.131.2 2026-09-18
Resolved Issues
- The
resolveSymbolhandler is also used outsidece.parse(), and takes precedence over a function declaration the engine inferred. A raw or structural result canonicalized later now also uses the engine-widelatexOptions.resolveSymbol. Before,b(\cos(NX)-1), with a handler that saysbis a value, could be read as an application ofb. A declaration made by the host, or one that holds a value, still takes precedence. - The static type of
PointListover list components includes the list width.PointList([0.1, 0.4, 0.8], [0.2, 0.7, 0.3])now has the typelist<tuple<real, real>^3>, soPointXof it has the typevector<3>. When the components have different widths, the smallest one is used. On the shader targets,2 · PointX(PointList(-6, v))over avector<3>now compiles to2.0 * vec3(-6.0). ce.appliedNonFunctions()reports a head that is a function only because of an earlier application. After the first parse ofg(x), the call returned[]forg. Only a host declaration or an assigned body now makesga function for this call.
0.131.1 2026-09-18
Breaking Changes
\overline{z}is the complex conjugate.\overline{…}now parses asConjugate(it wasOverBar), andConjugateserializes as\overline{z}. It used to serialize asz^\star, which parses asConjugateTranspose, so a conjugate did not round-trip through LaTeX.OverBarbuilt as MathJSON still serializes as\overline{…}. The repeating decimal0.\overline{3}and the extended sets\overline\C,\overline\R,\overline\Zand\overline\Qare unchanged.z^\starstill parses asConjugateTranspose.
Resolved Issues
- The
resolveSymbolhandler decides between a product and an application, as a declaration does. With a handler that answers{ type: "number" }fors,ce.parse("s(x+1)")was an application ofs, which then mades + 1an 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 declaredunknownas a normal parse does. - A head declared
unknownby the host is read as a product. In 0.131.0,k(1 - w)was an application wheneverkwas declaredunknown. An explicitunknowndeclaration sayskis a value whose type is not known yet, sok(1 - w)is nowk·(1 - w), as for a declared number. Only an undeclared head, or one with a type inferred from use, is applied. ConjugateTransposeover a vector of more than 100 elements evaluates.z^\starover a 200-element vector stayed unevaluated, whileConjugateof 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)withfundeclared, or declared with an unknown type, now parses as["f", "x"], not as the productf·x. The product reading gave silent wrong results:\int f(x) g(x) dxtookgout of the integral as a constant. A wrong application stays visible (f(2)evaluates to itself), and a later value forfoverrides the inferred declaration.2f(x)is2·f(x), andf(3)^2isf(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)thenx := 5,g(3)is40. Multiply(s, x+1)now serializes ass\times(x+1), so it round-trips.- With
strict: false,foo(x)is stillf·o·o(x).
New Features
-
Host capabilities:
ce.effectsandce.withEffects(). The application can replace or deny the handlers the engine uses to reach the host, one engine at a time. Theconsolehandler is used byPrintandInput(Epsilprintandinput):const lines: string[] = [];ce.effects = {console: { log: (line) => lines.push(line), readLine: () => "Ada" },};readLine(prompt)returns the line read,nullat end of input (Inputevaluates toNothing), orundefinedwhen there is no interactive input (Inputstays unevaluated). The defaults are the real console in Node and theprompt()dialog in a browser.ce.effects = {}restores the defaults.A handler set to
nulldenies the capability: the operator evaluates toError("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
entropyhandler,{ random(): number }, is the unseeded source of randomness (Math.randomby default).RandomExpressionand the random operators use it outside aWithRandomSeedframe. A constant handler makes them reproducible in tests;entropy: nullmakes them evaluate toError("capability-denied", "entropy"), and a compiled function throwsCapabilityDeniedError.A change made while an evaluation runs does not affect that evaluation. Operator handlers receive the handlers as
options.effects; a custom operator may useconsoleorentropyif its signature declares that effect.EvaluateHandlerOptionshas 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 asConjugate(z). It was the productconj·z. Over a list, it was an unknown function that did not broadcast.Conjugatestill serializes asz^\star.- The square of a base that ends with a superscript serializes with braces.
Power(Transpose(A), 2)serialized asA^T^2, which is invalid TeX. It is now{A^T}^2, andPower(Conjugate(z), 2)is{z^\star}^2. Atwith an index that is not an integer returns the missing-element value.L[2.5],L[3/2]andL[5 + √17]stayed unevaluated. They now returnNaNfor a numeric collection andMissingotherwise, 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-jstarget:- Missing values: a restriction over a list or a relation, a
Whichwhose branches are lists, andIsMissingandCoalesce, which gave wrong results. A type that is not numeric, such as a string, still does not compile. PointX,PointYandPointZover 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,MultiplyandDividewith point operands, for exampleV.x·(cos a, sin a) + V.y·(−sin a, cos a). Operations the interpreter rejects, such as point + number, still do not compile. - A
Whichwhose branches return a list in one case and a number in another.
- Missing values: a restriction over a list or a relation, a
sqrt,abs,lnandgammaon theinterval-jstarget keep a missing value missing. They returned an interval such as[0, ∞)for it.
Epsil
- The MCP server captures
printandinputwith aconsolehandler. It no longer replaces global functions such asconsole.logduring an evaluation. The output of theevaluatetool is unchanged. - A denied
printorinputis acapability-deniedruntime error (seeepsil 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 asg(a: 2). A rename is refused when not every use can be resolved. epsil checkchecks calls to a function literal bound withlet,constor:=.let k = (n: integer) => n + 1followed byk(1.5)now reports the same diagnostic as a declared signature. A named call such ask(n: 2)no longer reports a falseargument-names-unavailable.- A constant bound to a function literal can no longer be redefined.
const k = (n) => n + 1followed byk = (s) => s, or byk(x) = x * 2, replacedkwithout an error. It is now refused, asconst c = 5thenc = 6is. From the host,ce.assign('Pi', (x) => 2x)is also refused; it replacedPion that engine.
0.130.0 2026-09-17
New Features
- New
expr.digestproperty: a 128-bit digest of the expression's structure, usable as an in-memory cache key. Unlikeexpr.hash(a 32-bit bucket that needs anisSame()check on a hit), two expressions have the same digest exactly when their MathJSON is the same tree. It is much faster thanJSON.stringify(expr.json)on expressions that share sub-expressions. It is not anisSamekey:1/2and0.5digest differently. The value is 32 lowercase hexadecimal characters, stable only within one release, and not cryptographic. Do not persist it.
Resolved Issues
expr.hashnow agrees withisSameon number literals. The exact rational1/2and the float0.5hashed differently, soUnique,Tallyand 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^kin 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-jstarget, 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 asce.expr(expr.json, …)for an expression that already contains anError. 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.rebindcopies 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
compilehandler 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 listlrecomputed 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
MaporRangevalue compiles on theinterval-jstarget.R[k]andlength(R)withR = 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-jstarget. 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])withf(x) := x²). This call used to return an incorrect result withsuccess: true; - supports a
Rangewith literal or symbolic bounds; - supports
Sum,Product,MaxandMinover a list computed at run time; - returns a list of numbers, a range or a
Mapat the root as an array.
Also fixed:
Σ_{k=1}^{x} kwithx ∈ [2.5, 3.5]returned[6, 6], which is not an enclosure. A bound that is not constant over the interval now returnsentire. - applies scalar functions element by element to an operand whose type is a
list of numbers (
-
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]forv = 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
ndeclared in the root scope andn = 4in a child scope,[k/n for k in 1..n]and\sum_{k=1}^{n} kstayed unevaluated, while90nreturned 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))withg(P) = P.yreturned 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 typedinteger | naninstead ofcomplex | 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)typedcollection<number>, so arithmetic on it did not compile to JavaScript. It is nownumber | tuple<number, number> | list<number>. - Juxtaposition with a union-typed operand is multiplication.
2\mathrm{PointX}(P)with anumber | list<number>coordinate parsed as the tuple(2, PointX(P)). A union with a non-numeric member (string | number) still parses as aTuple.
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 obeysce.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], soCross(A, B) + Pfailed. It now returns a point typedtuple<number, number, number>. A list operand still gives a list. Mapover a tuple returns a list. The result is typedlist<R>. It was typed as the tuple or ascollection, and could become aSetthat 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 aCancellationErrorwith causeiteration-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 ofF_0(t) = f'(t)/|f'(t)|for a space curvefreturned an incomplete symbolic result; compiled JavaScript returnedNaN. Both now differentiate each component and return a point.NDalso 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))andHypot(([], 3), 4)now evaluate to[]. They returned√10and√(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 asJoin(L, M), and[1].join(2, 3)asJoin([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.
PointXoverlist<tuple<…>> | tuple<…>is typednumber | list<number>instead ofcollection<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)withRbuilt from aRange); - 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
NaNfor every element withsuccess: true. When the declared type cannot tell the two apart, the compilation now fails with a message; Norm,AbsandHypotof 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 ofNaN.
- a point multiplied by a symbol bound to a list of numbers (for example
- A literal index into a list expression computes only the selected element.
This applies to element-wise
Which/If, relations,Mod, list arithmetic, a literalRangeand aPointListbuilt from columns, on every target. For example(0.1·[a, b, c] + 0.4)[2]compiles to0.1·b + 0.4, and[a, b][1]compiles toa. LikewisePointXof aPointListbuilt from columns compiles to the first column. - More list expressions compile on the
interval-jstarget. Lists of any width and helpers that return lists are written out, soF(x, y)[1]andTotal(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,IforWhenwhose branch contains a loop-formSumor a block. The loop runs only when its branch is selected; - a block with local variables used as a
Sumterm or an operand.
- Fixed a shader miscompile of a reduction assigned to a block local. On
GLSL and WGSL,
a := Σ…in a function orLoopbody 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
Sumwith the same argument computes the parts that depend only on that argument once, on all compile targets. Theinterval-jstarget 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):
| Expression | CE (current) | CE 0.128.9 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 11 | 13 | 179 | 143 | 3.9 |
\sin 1 | 23 | 23 | 221 | 449 | 5.1 |
\ln 2 | 18 | 18 | 357 | 4,460 | 3.7 |
\zeta(3) | 1,584 | 1,588 | 281 | — | 50 |
\Gamma(\tfrac13) | 841 | 848 | 368 | — | 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
PointListover 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.
Dotbroadcasts 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
.reducecalls.Dotof 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))andProduct(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-jstarget compiles applications of small closed-form derivatives, such asApply(Derivative(f, n), x).
Resolved Issues
- Compiled derivatives of odd roots stay real at negative inputs. For
f(x) := ∛x, compiledf''(-1.2)now returns approximately0.16399052instead of a complex value, as the interpreter does. This applies to all compile targets. - Compiled closed-form derivatives handle complex arguments.
f''(z)forf(x) := x^3now uses complex arithmetic whenzis complex, instead of returningNaN. - Compiled
Dotbroadcasts list-valued point coordinates. For points declared astuple<broadcastable<number>, broadcastable<number>>,Dot(a, b)witha = [1, [1, 2]]andb = [3, 4]now returns[7, 11]instead ofNaN. Complex point coordinates are not compiled.
Performance
- Repeated
SumorProductover the same range sliceP[a...b]computes the result once in compiled code.
0.128.3 2026-09-10
Improvements
Dotof points whose coordinates may be lists returns the broadcast inner product. WithL := [1, 2],Dot((1, L), (3, 4))is[7, 11]; it stayed symbolic before.Dot(c, d)for twotuple<real, real>symbols is now typedrealinstead ofnumber.
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)withsassigned2.41emitted[…, ...2.41], and each call raised "2.41 is not iterable". - A point plus a scalar is no longer compiled.
(x, y) + 3returned[3.3, 3.7]atx = 0.3, y = 0.7, while the interpreter reportsincompatible-type. The expression is now left to the interpreter. - A compiled element-wise comparison marks an absent element with
NaNinstead offalse.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. ANaNoperand still equals nothing, and the scalar comparison still returns a boolean.
Types
Dotno longer reads the operands of any tuple-typed expression as coordinates.Dot(P + (0, 0), Q)was typedtuple<real, real>andDot(2P, Q)a union with a tuple, although both values are numbers.
Performance
- Point arithmetic and
Dotcompile to per-component code on thejavascripttarget 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
Sumcan 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, notNaN. Withg(t) := \begin{cases} t & t > 5\end{cases},g(3) + 1is nowMissing. Declare the function, for exampleg: (number) -> number, to getNaN.
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)]withaandbdeclarednumber,PointY(Q)is typedlist<number>. Before,PointY(Q) + 1had 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) + 2was accepted, and compiled code returned the string"21,2". It is now rejected as for a plaintuple<number, number>. A scalar multiple of such a point is still accepted. - An absent point stays absent instead of becoming
NaN. WithPdeclaredlist<tuple<number, number, number>>,2 \cdot P[0]returnedNaN, and2 \cdot P[0] + (1, 1, 1)then failed withincompatible-type. It is nowMissing.Add(Missing, (1, 1, 1))andMultiply(Missing, (1, 1, 1))are alsoMissing. An operation typed as a number still returnsNaN:2 \cdot Missingand\sin(Missing)are unchanged.
0.128.1 2026-09-10
Breaking Changes
- Compiled
EqualandNotEqualare exact. A compiled comparison of two numbers uses the IEEE 754 test (===injavascript,==inpython), with no tolerance. The interpreter still compares withinengine.tolerance, so0.1 + 0.2 = 0.3evaluates toTruebut compiles tofalse. This also applies toKroneckerDeltaand to comparisons of collections.NotEqualis stilltrueforNaN. For tolerant equality, evaluate with the interpreter.
Resolved Issues
LaTeX Parsing and Serialization
- Nested absolute values round-trip. An
Abswhose 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 asReal,Imaginary,ErfandErfc.
Compilation
- An integer exponent over a possibly negative base is correct in GLSL and
WGSL.
(-1)^nwith a runtimenreturnedNaNon most drivers, becausepow(x, y)is undefined for a negativex. 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.typestaystuple<number, number>. Witha := -1,Pevaluates toMissing.missing <: numberis still false, so the type of an undeclared symbol or of an expression still includesmissing.
Collections
-
A point accessor over an empty list of points returns the empty list.
PointX([]), andPointY,PointZover any empty collection, returnedMissing, and2 · PointX([])returnedNaN.PointX([])is now[], typedlist<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 returnsMissing(NaNin compiled JavaScript); - any other element type (
list<string>) returnsMissing.
Compiled JavaScript returns the same values.
PointX(s)forsdeclaredstringnow compiles to its first character instead ofNaN. - a point element type (
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 toNaNwhen the body has a complex value. - Faster compiled complex arithmetic in JavaScript. Complex operations no
longer allocate a closure each.
i · bcompiles to{ re: 0, im: b }. - More constant folding in compiled code.
\sum_{k=1}^{3}(-1)^{k}xcompiles to(-x + x + -x)instead of threepowcalls. In GLSL and WGSL this also fixed a wrong value: a literalpow(-1.0, 1.0)is undefined in both languages. - Better common-subexpression elimination in compiled code.
(sin(u)+1)^2 + 1/(sin(u)+1)calledMath.sintwice 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,MinandLengthoverP[a...b]no longer build the slice. A source that is not an array, or a bound that is not an integer, now returnsNaNinstead of throwing aTypeError. interval-jskernels no longer rebuild constant intervals on each call. A plottedcasesexpression is 25–40% faster per sample.
0.128.0 2026-09-09
Breaking Changes
-
A false restriction evaluates to
Missing, notUndefined. Whencis false,When(e, c)(thee\{c\}restriction) now evaluates toMissing, the same value as aWhichwith no selected clause or anIfwith 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 useIsMissing, which now returnsTruefor a masked value).The type of a restriction now includes the absent case:
When(5, x > 0)isinteger | missing, and a list with a masked element islist<integer | missing>. Only a restriction whose condition is the literalTruekeeps the bare type. Code that checkstype.matches("number")must remove themissingmember 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 }, wherespaceis'oklch','rgb','hsv','hsl'or'oklab', the channels are in that space, andalphaisundefinedwhen there is none. Before, a color was a flat[L, C, H](or[L, C, H, alpha]) OKLCh array, and a conversion such asAsRgbreturned a flat array in its own space, with no way to tell them apart. Readraw.spaceandraw.c0…raw.c2instead ofraw[0]…raw[2]. The color helpers throw aTypeErrorfor a numeric array or an unknownspace. A list of colors is an array of these objects; a color string is still a string.ColorToColorspaceis not affected: it returns components, and its compiled value is still a plain array.Nested conversions are now correct:
AsRgb(AsRgb(c))equalsAsRgb(c)andColorDelta(AsRgb(a), b)equalsColorDelta(a, b). The interpreter fallback returns the same object and accepts one as avarsvalue. The GLSL and WGSL targets are unchanged: a color is still avec3in OKLCh. -
ColorFromColorspacereturns a color. Its signature is now(color | tuple, string) -> color.ColorFromColorspace((0.5, 0.1, 20), "oklch")isOklch(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, useColorToColorspace(color, "rgb"). Passing the result to another color operator needs no change. Indexing the result (At(result, 1)) is now anincompatible-typeerror, 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 isColorFromColorspaceand that expects OKLCh must wrap it inAsOklch.
New Features
-
Argcompiles on theinterval-jstarget 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 asSin(z), still fall back to the interpreter. -
More fixed-width list expressions compile to straight-line code.
Map(h, [e1, …, eN])with a user functionhbecomes[h(e1), …, h(eN)], soMin(Map(h, list))now compiles oninterval-js.Product([a, …])and aReduceoverAdd,Multiply,MinorMaxcompile to an n-ary operation: the JavaScript target emits_.a * _.b * …instead of areduceover an array. -
On the JavaScript target, the loop-invariant part of a
Reduce/Scancombiner is computed once. InReduce(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-jswhen it is the whole expression.Tuple(a, b)and an all-scalarPointList(a, b)compile, andrun()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
EqualandNotEqualare correct forNaNand for two infinities of the same sign (JavaScript and Python).NotEqual(NaN, 3)compiled tofalse, for exampler \ne 3whenrwas not given torun(); it is nowtrue, as in the interpreter.Equal(oo, oo)compiled tofalse; it is nowtrue. An impure operand (theRandomfamily) is evaluated once. -
An else-less
Ifcompiles.\mathrm{If}(x > 0, x + 1)was refused by every target. It now compiles on all targets and returnsNaN(for a number) when the condition is false. AnIfwhose branch is a statement is unchanged. -
A selection with no selected value compiles to the same absent value on a target. A
Whichwith no matching clause, anIfwith no else branch and a false restriction returnNaNwhen the value is a number or of unknown type, andundefined(JavaScript) orNone(Python) only when the value is provably not a number, such as a string. Before,WhichandIfcould returnundefinedfor a number, and Python always returnedfloat('nan'), so a compiledIsMissingover 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, compiledr((a, b))returnedNaNon 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 andfallback: trueuses 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 bindsp. -
A
SumorProductover a collection (Sum(2n, Element(n, [3, 5, 7]))) fails to compile with a clear message instead of an internal error aboutNothing. It still falls back to the interpreter. -
An unrolled
SumorProductno 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 scompiled to six complex square roots. -
A
Rangewith a non-integer upper bound has integer elements.Range(1, 2.5)andRange(1, n)withndeclaredrealwere typedindexed_collection<real>; they are nowindexed_collection<integer>. An equality against such an index now compiles tok === 9543instead of a tolerance test. -
An equality with an operand of type
integer | nan(such as an element readP[i]) compiles to===/!==, which gives the interpreter's result forNaN. -
Shader float literals use the shortest single-precision spelling.
2πwas emitted as6.2831854820251465; it is now6.2831855. -
Constant factors inside parentheses are folded.
\frac{2\pi s}{P}withPassigned 100 emitted(2 * Math.PI * s) / 100; it now emits(6.283185307179586 * s) / 100.
Interval Arithmetic
-
Interval
signandheavisideare tighter.sign([0, 0.5])returned[-1, 1]; it now returns[0, 1].heaviside(withH(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
atan2reports a jump along itsxoperand.atan2([0, 0], [-1, 1])returned the bounded interval[0, π]; it is nowsingularwith the same enclosure. Asingularresult has a new optionalatOperandfield: the index of the operand that theatlocation refers to (absent for the first operand).-0is read as zero:atan2([0, 0], [-1, -1])is+π.
Colors
-
A tuple variable is no longer read as a color by compiled code. With
vdeclaredtuple<number, number, number>,ColorMix(v, Rgb(0, 0, 1), 0.5)compiled but threwNot a colorat 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 suggestsAsRgb((r, g, b)). TheAs*conversions andColorToColorspaceread it as 0-1 sRGB components, as the interpreter does. -
Color conversions of a possible list of colors compile again on JavaScript.
AsRgb(w)withwdeclaredbroadcastable<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 ofRgb(0.5, 0.2513, 0.25). This applies to all color operators. On GLSL and WGSL, aWhichorIfwhose branches return colors in different spaces does not compile. -
Compiled
ContrastingColorreturns an OKLCh color on JavaScript. Before,AsOklch(ContrastingColor(bg, AsRgb(a), AsRgb(b)))read sRGB channels as OKLCh. -
Compiled
AsHsvandAsHslof a non-finite color returnNaNfor all channels. They returned a hue of0(red).
Absent Values and Types
-
AddandMultiplyreturnNaNfor an operand that evaluates toMissing. Withg(3)evaluating toMissing,g(3) + 1stayedAdd(Missing, 1); it is nowNaN. A list operand givesNaNper 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 now2 \cdot g(0). The same applies to2x\{x>0\},2\,\mathrm{If}(c, x)andt P\{0 \le t \le 1\}. A non-numeric operand, such as a string, still gives aTuple. -
A maybe-absent
Sum/Productbound is accepted.\sum_{x=g(0)}^{3} xwas anincompatible-typeerror; an absent value is now handled at evaluation. A non-numeric bound ("lo") is still an error. -
A bound written as the literal
NaNis an error, like one written asMissing:Sum(x, (x, NaN, 3))now reportsincompatible-type. -
Partitionaccepts a maybe-absent chunk size, likeTake,DropandChunk.Partition([1,2,3,4], k(0))withk(0)typedinteger | missingwas anincompatible-typeerror; it now returns[[1,2],[3,4]]. -
A function with a
missing | Tresult keeps its result type when it is assigned to a symbol declaredfunction. Afterce.declare('l', 'function'), assigning a piecewise function without a default madel(t)of typeunknown, so\sin(l(t))was typedbroadcastable<number>instead ofnumber.
Performance
-
Faster compiled JavaScript. A repeated
Min/Maxcall is computed once;Mod(a, 1)uses a shorter formula; a factor of1is removed from a product;PointX(PointList(a, b))emitsa; and√a³anda^(3/2)emita * Math.sqrt(a)instead ofMath.pow(also more accurate). Loop indexes and unrolled sums that cannot beNaNskip their run-timeNaNtests. A product with one complex factor uses real multiplication for the real factors. -
Faster compiled
interval-jscode. Multiplying or dividing by a constant uses a cheaper operation; a constant such asπ/50in2πs/100is 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 onglsl,wgslandinterval-js. The compiler now unrollsMap(f, List(…)),PointX/PointY/PointZover a list of points,Min/Max/Sum/Productover a literal list, and scalar-list arithmetic. On JavaScript, a loop-invariant part of aMap,Filter,CountIforFindcallback 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 onglsl,wgslandinterval-js. - Texture-backed
Aton the WGSL target. With thestoragecompile 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.restis aTupleof 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)givesConjugate(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)andfunction h(a, ...rest) { Length(rest) }. Primes, the set of all prime numbers. A lazy, infinite set likeIntegers:Element(7, Primes)isTrue, iteration yields 2, 3, 5, …, and\sum_{p \in \mathbb{P}} p^{-2}stays symbolic with an integer index.\mathbb{P}parses toPrimesand 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,Tabulateand similar operators:- if its definition could not be compiled, the output compiled with
success: trueand then threwTypeError: _f is not a function. It is now refused at compile time with a message that names the function, and the defaultfallback: trueuses the interpreter. - now broadcasts over an element that is itself a collection, as the
interpreter does. For
f(x) := 2x, the compiledMap(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.
- if its definition could not be compiled, the output compiled with
- 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]]andMap((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²andpsh(gsh, t) := gsh(t) + 1,psh(u, u)compiled to a call of the globalgshand ignored its first argument. It is now refused. angularUnit = 'deg'applies inside inlined user functions. ASinin 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, soAsRgb((1, 0, 0))did not compile on JavaScript. - Infix operands of identity operations keep their parentheses.
Absof a non-negative value,Floor/Roundof an integer,Reof a real,Identityand similar operations inserted their operand without parentheses:3·Re(x + 1)compiled to3 * _.x + 1. Fixed on the JavaScript, GLSL, WGSL and Python targets. - WGSL emitted a two-argument
atan, which WGSL does not have.Arctan2and the argument of a complex value now emitatan2. - A compiled
Modwith a non-literal divisor evaluated the divisor three times (2 mod sin(0.2θ), orRandom()). It is now evaluated once. - Division by an exact rational compiles as a division.
x/49compiled to0.02040816326530612 * x, which is not correctly rounded:floor(x/49)gave 0 atx = 49. The JavaScript, GLSL, WGSL, Python and interval-js targets now emitx / 49. A power-of-two denominator (x/2) still compiles to0.5 * x.
Interval Arithmetic
- The
interval-jstarget 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 as1/3and radicals such as\sqrt{2}compile to two-ulp enclosures instead of a single point.atan2across its branch cut now contains π. A 40-term sum went from 32 to 72 µs per call. - Rational powers and roots on
interval-jsare correct enclosures. A rational power did not contain the true value in about 5% of random cases, andRootof 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 andRoot(1000, 3)is 10. Round(x, n)oninterval-jswith a negativenscales 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, sof(1)andf(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)andh(1, 2, 3)were errors. f(L) := Sum(L)treatsLas a collection, sof([1, 2, 3])is 6. It gave[1, 2, 3], becauseLhad typeunknownand the call was mapped over the list. ASum(L)orProduct(L)with no index over a symbol with no other type information now types the symbol as acollection.- The index of a
Sum/Productover anElementclause has the element type of the collection. It was alwaysinteger, soSum(2x, Element(x, [0.5, 1.5]))threw, and a sum over a set of functions gave anexpected-functionerror. - Generic signatures narrow their operands. For
f: (T) -> T where T: number | function,f(u) + 1now typesuand the sum asnumber(the sum wasfunction | number). An operator declared(T) -> T where T: numberwith acanonicalhandler now types an undeclared operand asnumber. LinearRegression(xs, ys, x)andPolynomialFit(xs, ys, n, x)are typednumber.LinearRegression([1,2,3],[2,4,6],x)was typedtuple<number, number>while it evaluates to2x.- The LaTeX pipe shorthand has a type.
[1,2,3] |> \_^2was typedunknownand is nowlist<integer<0..>^3>;[1,2,3] |> \mathrm{Length}(\_) + 1isinteger. Atypehandler'scontext.derivenow also maps a broadcastable operator over a collection operand:derive('Power', [d, 2])withdtypedlist<integer^3>gavenumber. - 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 > 1was "proven" andassume(v < 1)was rejected as a contradiction. Bounds are now compared exactly, andce.ask(['Greater', 'x', '_k'])gives the exact bound (1/3, not0.333…). CovarianceandPopulationCovarianceno longer overflow on large data.Covariance([1e200, -1e200, 0], [1e200, 1e200, -2e200])gaveNaN; it is 0. A covariance larger than the largest double is+oo.Conjugateaccepts 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#ff0000in the interpreter and#ffffffwhen compiled. A list of three numbers is not a color.AsRgb,AsHsv,AsHsl,AsOklabandAsOklchnow accept a color string and a tuple (AsRgb("#ff0000")wasincompatible-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 asrgb(255, 0, 0and#gg0000are refused.#00000000andrgba(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, soContrastingColorchose black for a 0.6 grey.ColorFromColorspace(Rgb(1,0,0), "rgb")was not red when compiled."hsv"is accepted byColorToColorspace/ColorFromColorspaceeverywhere.Colormapsamples the same color asColorMix.ColorToString(c, "oklch")keeps out-of-gamut chroma when compiled. ContrastingColorreturns 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 givesOklch(0.2, 0.1, 29)instead of anRgbvalue.
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-jscompiles a subtraction as onesubcall 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/Productbody 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 usesfract,exp2,dotand 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|toMath.atan2andMath.hypot.
0.126.2 2026-09-08
New Features
- New
expr.isMachineNumericproperty. It istruewhence.list(expr.array)reproduces the list exactly: every element is a float or an integer that a double holds. It isfalsewhen an element is an exact non-integer, such as1/2(expr.arrayreads it as0.5, but0.5 / 3is a float where1/2 ÷ 3is1/6). On a number, the same rule applies to the number. It isfalsefor 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) + zon a complexzcomputed"1[object Object]". It now gives4 + 2i. Also, a call to a function whose body already returns a complex value is no longer wrapped in an unnecessary conversion. - The
pythontarget 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-jstarget: a\sumwith 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-jstarget:GCD,LCMandBinomialof an integer at or above2^53did not return. They now return a wide enclosure.
Collections
expr.arraythrew aRangeErrorfor an exact rational outside the double range. It now returnsundefined, andisMachineNumericisfalse.- 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 anincompatible-typeerror. It now evaluates toh(xs), and to[2, 3]oncexs = [1, 2]. With an untyped parameter,pair(n) = (n, n); pair(xs)no longer becomes(xs, xs).
Other
- Chained set relations.
A ⊂ B ⊂ Cand a one-operandSubset(D)boxed with an error.Subset,SubsetEqual,SupersetandSupersetEqualnow accept one or more operands. A chain evaluates all adjacent pairs (ℤ ⊂ ℝ ⊂ ℚisFalse; before, only the first pair was checked). The LaTeX\mathbb{Z} \subset \mathbb{Q} \subset \mathbb{R}parses toSubset(Integers, RationalNumbers, RealNumbers), asa < b < cparses toLess(a, b, c). - An operator with only an
evaluateAsynchandler is awaited insideSum,ProductandBlock.Σ_{i=1}^{3} AsyncOnly(i)and{x = 1; AsyncOnly(x)}stayed unevaluated withevaluateAsync().
0.126.1 2026-09-07
Resolved Issues
expr.arrayno longer approximates exact elements atprecision: "machine".["List", 1, ["Rational", 1, 3]]returned[1, 0.3333333333333333]. It now returnsundefined, as at other precisions. An exact value is accepted only when a double holds it exactly (for example2^70or3/4).javascripttarget: faster selection over a list declared asindexed_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 2parsed as a function of one parameter namedNothing, and() \mapsto \_ + 1applied to3returned_ + 1. Both now parse as["Function", body]. Also,x_1 \mapsto x_1 + 1serialized as()\mapsto x_1+1, which lost the parameter.
0.126.0 2026-09-07
New Features
ce.list(values)builds aListof numbers quickly. The input can be anumber[], aFloat64Arrayor 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 asce.box(['List', ...]). A non-number element throws aTypeError.expr.arrayreads aListas plain machine numbers. It isundefinedwhen an element is not a machine number (an exact rational such as1/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.
javascripttarget: numeric selections over arrays run in one loop, with no intermediate arrays.javascripttarget: a list-declared input accepts a typed array. AFloat64Arraypassed for alist<number>orvector<3>input was read as a scalar and gaveNaN. It is now copied into a plain array.
Behavior Changes
javascripttarget: a symbol declared with a scalar type no longer broadcasts over a list. Withudeclarednumberandf(x) = 2x + 1,run({ u: [1, 2, 3] })forf(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.
javascripttarget: 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.javascripttarget: 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
emptyinstead ofentire. simplify()treats\log_eas\ln.\log_e(x)simplifies to\ln(x),\ln(x) + \log_e(y)gives\ln(xy), and\log_e(x^3) - 3\ln(x)gives0. Contributed by @yelliver (#332).
0.125.0 2026-09-06
New Features
-
GLSL target: read a fixed-length list from a texture. The new
storagecompile 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 withAtand 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
typehandlers now returnBoxedType | undefined. Returncontext.engine.type('real')for a fixed result, orcontext.engine.type(computedType)for a computed result.undefinedcontinues to use the declared signature fallback. BoxedType.typeis now readonly. Useengine.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.
| Expression | CE 0.124.3 | CE 0.100.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 9.1 | 7.9 | 179 | 106 | 3.9 |
\sin 1 | 22 | 21 | 227 | 496 | 5.1 |
\cos 1 | 21 | 21 | 221 | 536 | 7.1 |
\ln 2 | 16 | 14 | 347 | 4,428 | 3.7 |
e^{\pi} | 14 | 13 | 212 | 4,824 | 4.6 |
\zeta(3) | 1,585 | 1,602 | 268 | — | 49 |
\Gamma(\tfrac13) | 863 | 829 | 362 | — | 213 |
\psi(\tfrac13) | 726 | 712 | 2,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).
| Operation | CE 0.124.3 | CE + R/F | CE 0.100.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 2.9× | 1.8× | 3.0× | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 6.4× | 1.2× | 6.0× | 0.09× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 3.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\,x | 64× | 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)^x | 3.8× | 3.1× | 3.6× | 2.1× | — | 1× |
\int_1^2\tfrac1x\,dx | 1693× | 2285× | 3777× | 83× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 186× | 86× | 230× | 2.5× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.2× | 0.2× | 0.2× | 0.06× | — | 1× |
x^3-x-1=0 | 1.5× | 1.6× | 1.4× | 0.04× | — | 1× |
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=0went from 3.17 ms to 2.86 ms, and∫1/(x³+1)dxfrom 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
Pdeclaredlist<tuple<number, number>> | tuple<number, number>,PointX(P[2])reported anincompatible-typeerror. 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
Zundeclared,(Z[1..p-1])[W] = Z[p]was typedlist<boolean | missing>, so aFilterwith that comparison as predicate was refused. It is now typedbroadcastable<boolean>and the predicate is accepted. glsltarget:Dotover a point list whose component is itself aDotcompiles again. It reported thatdot"has no array overload". Lists of complex values, booleans, strings or lists still do not compile.interval-jstarget: 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]contains0.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 + √2xwent from 1.53 ms to 0.96 ms, and∫1/(x³+1)dxfrom 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,piand the other spellings resolve to the library when nothing in the program or the engine binds them:mean + 1is now a type error, where it was a sum with an unknownmean. 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 aliasesprintandinputare removed: usePrintandInputin MathJSON. - Epsil: a
let/constcannot re-declare a name of its own scope. This includes an earlierletof 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 avariable-redeclarationerror, reported byepsil 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 shadowingletnow 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-levelletin 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 (Addis+,Ifisif) have no lowercase spelling. A user binding shadows the spelling. Hosts that callparseEpsiland box the tree themselves must call the newresolveLibraryNames(tree, source, ce)first. - Common mathematical Unicode notations. Double-struck letters are types
(
c: ℝisc: real; alsoℤ,ℚ,ℂ,ℕ, and ince.declare('x', 'ℝ')).√3,∛8and∜16are radicals (√2xisSqrt(2)·x). Superscripts are exponents (x²,x⁻¹,xⁿ⁺¹). A subscript of letters and digits joins the name (xₙis the symbolx_n); other subscripts give aSubscript(xₖ₊₁).∫,∑and∏areIntegrate,SumandProduct. The highlighter and the VS Code grammar support these notations. - Unicode output. The serializer option
fancySymbols(command line:epsil --epsil --fancy-symbols; MCPserializeandevaluate:fancySymbols: true) writes√x,∛x,∜xandx², with parentheses where needed (√(x + 1),(−x)²). The default output stays ASCII. The--epsiloutput now writes a square asx ^ 2instead ofSquare(x). - Dot call for protocol functions.
c.area()isarea(c)andc.scale(2)isscale(c, 2), so calls chain (c.scale(2).area()). Named arguments follow the receiver (c.scale(k: 2)), andc.(Shape.area)()names the protocol when two protocols declare the same member. Other functions are the new errordot-call-not-a-protocol-function(xs.Sort()).c.areawithout parentheses is still a field read, and a declared field still wins over a member of the same name. epsil check --effectsreports the inferred effects of each top-level function, for examplef (line 1): consoleorg (line 2): pure (declared). With--jsonthe report is theeffectsarray; the MCPchecktool accepts"effects": true. The VS Code hover shows the same line.
LaTeX Parsing and Serialization
- Typed lambda parameters.
(i: integer) \mapsto 2iand(f: (real) -> real) \mapsto f(1)parse toFunctionliterals 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 to6and5; they parsed as products. A power on the argument list applies to the application:(x \mapsto 2x)(3)^2is 36, and(f)(3)^2reads asf(3)^2.
Types
- A use of an element refines the collection's element type.
xs[1] + 1makes an undeclaredxsanindexed_collection<number>,xs["a"] + 1adictionary<number>, andm[1][2] + 1makesmanindexed_collection<indexed_collection<number>>. A lambda parameter shows the refinement:(v) => v[1] + 1is typed(v: indexed_collection<number>) -> broadcastable<number>. A later assignment replaces the inferred type, and a declared type is never changed. This applies toAt,First,Second,ThirdandLast; an operator definition can declare its own rule with the newinferOperandTypeshandler.
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
Ca 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, usetoMathJson({ 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 |> Sumfailed withincompatible-type; it now returns136. - A number literal keeps all its digits.
12345678901234567890.5parsed as12345678901234570000,1e400asInfinity, 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))andFactorial(-2)now serialize as(-2) ^ 2,(x ^ 2) ^ 3,a - (b - c)and(-2)!. - A
matchorif letpattern 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]withpairs: list<tuple<number, number>>,pandqarenumber. 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 typeunknown; it now has typenothing. - 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 + 1withq: boolean | missingis now refused, asSin(q)already was.unknown | missingandlist<number> | missingare still accepted.- A collection of non-numbers at a numeric element-wise operator is refused
when boxed.
Ln(["a", "b"])andAdd(["a"], 1)are nowincompatible-typeerrors, asSin(["a", "b"])already was. A collection that could hold numbers (such aslist<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 compiledhon[[1, 2], [3, 4]]returned"1,21". It now throws an error that suggests declaring the parameter aslist<list<number>>ormatrix. javascripttarget: aSumwith indexigives correct results.\sum_{i=1}^{3}\cos(i(|x|-\sqrt{9.81/k[i]}\,t))returned"[object Object]…"becauseiwas read as the imaginary unit. Withmode: "complex"it also returned a wrong value.javascripttarget:Absof a parameter that may be a point or a point list is decided at run time. Withg(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-jstarget:Arctan2over a box that crosses its branch cut reports a jump. It returned a bounded interval; it now returnssingularwith the enclosure[-π, π].interval-jstarget: a domain clip is kept by the operations above it.x + \sqrt{x^2 - 0.01}overx ∈ [-0.3, 0.3]returned a bounded interval, although the function is undefined on|x| < 0.1; it now returnspartial.pythontarget: aforloop over aRangewith a non-integer bound matches the interpreter. A symbolic bound of2.5raisedTypeError, and a literal bound of5.5walked5, 4, …instead of5.5, 4.5, ….
Arithmetic and Numerics
- A subscript with a computed index evaluates. With
k := 3,x_{k+1}evaluates to the symbolx_4, and to its value whenx_4is assigned. - Recursive functions in a comprehension are no longer recomputed for each
element.
[P(i) for i in 0..n], withP(i)defined fromP(i-1), called the step function n(n+1)/2 times; it now calls it n times. - An asynchronous-only operator inside a comparison,
IforWhichis awaited.Less(15, AsyncOnly(2)).evaluateAsync()returned15 < 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-integera.GammaRegularized(-1/2, 2)is now−0.00849…andGammaRegularized(-1/2, 0)is−∞. A negativezstill stays symbolic.Variance,CovarianceandCorrelationare accurate for data far from zero.Variance([10⁸+1, 10⁸+2, 10⁸+3])returned0instead of1, andCorrelation([1.5e200, 2.5e200, 3.5e200], [1, 2, 3])returnedNaN; it is now1.Correlationis typedreal<-1..1>.- Operations whose work grows with an argument value are bounded.
Eulerian,Stirling,NPartition,StirlingS1,BernoulliB,MatrixPower,Chunkand others could run without limit or exhaust memory (Stirling(4000, 2000)). Past a limit they now stay symbolic or reportiteration-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.
| Expression | CE 0.124.0 | CE 0.123.2 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 10 | 12 | 175 | 103 | 3.9 |
\sin 1 | 22 | 23 | 219 | 438 | 5.1 |
\cos 1 | 21 | 23 | 216 | 522 | 7.1 |
\ln 2 | 15 | 17 | 342 | 4,364 | 3.7 |
e^{\pi} | 14 | 16 | 211 | 4,659 | 4.6 |
\zeta(3) | 1,538 | 1,587 | 270 | — | 49 |
\Gamma(\tfrac13) | 855 | 839 | 339 | — | 213 |
\psi(\tfrac13) | 729 | 723 | 2,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).
| Operation | CE 0.124.0 | CE + R/F | CE 0.123.2 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 2.2× | 1.4× | 1.7× | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 5.0× | 0.9× | 3.6× | 0.09× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 2.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\,x | 23× | 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)^x | 3.1× | 3.0× | 3.0× | 2.1× | — | 1× |
\int_1^2\tfrac1x\,dx | 1441× | 2430× | 1187× | 85× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 140× | 57× | 118× | 2.5× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.08× | 0.08× | 0.08× | 0.06× | — | 1× |
x^3-x-1=0 | 1.0× | 1.1× | 1.0× | 0.04× | — | 1× |
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)andxs = [...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. AJoinof list literals, and anAppendto 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)typesvector<integer^4>(waslist<integer>).
Resolved Issues
- Real-only functions over a list that may hold complex values now compile in
the
autoandcomplexmodes.Min,Max,Floor,Mod, the statistics functions and the ordering comparisons over such a list (√LforL: list<real>, alist<complex>symbol) were refused on the JavaScript and Python targets. Each real element now gets the real result and every other element isNaN:⌊√L⌋atL = [4, -1]is[2, NaN]. This also fixes two wrong results:√L < 1atL = [0.5, 2]returned[false, false](now[true, false]), andmin(Z)for alist<complex>symbol returnedNaN.strictmode is unchanged. - List and set literals now await asynchronous elements.
[1, AsyncOnly()].evaluateAsync()returned[1, AsyncOnly()]for an operator with only anevaluateAsynchandler. Elements are now awaited in order. evaluateAsync()now honorsnumericApproximationon 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}}withx := 5returnedSet(x, 2).- A tuple, set or list of inexact literals no longer carries a numeric range
in its type.
(√2, 1)typedtuple<real<1.4..1.5>, integer>and now typestuple<real, integer>. A list of 5,000 such points typedlist<tuple<any, any>>and now typeslist<tuple<real, real>^5000>. A record holding~oonow typesrecord{x: infinity}. - A point with a list component now compiles under
Norm,AbsandHypot.Norm(([1, 2], 3))is[√10, √13]andHypot(([1, 2], 3), 4)is[√26, √29]. The JavaScript target used to fall back to the interpreter. Hypotwith 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).Absfollows 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)andAt(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 typedtuple<integer, integer>; it now typeslist<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)andMin(2, N, M)returned anincompatible-typeerror onN, whileN + 1andRange(N)readNas a variable. For example,[1, \ldots, N]with an undeclaredNfailed in 0.123.0. JoinandAppendno longer drop an operand that has no value yet. Withvdeclaredlist<number>and unassigned,Join([1], v)evaluated to[1]andAppend(v, 2)to[2]. They now stay unevaluated untilvhas a value.First(Join([1], v))still returns1.- A symbol declared with the bare
tupletype is treated as a tuple in arithmetic. Withce.declare('w', 'tuple'),Sin(w),Sqrt(w)andw^2typednumber; they now typetuple.Abs(w)is the norm, andw · wis theno-product-between-pointserror. - A list scaled by a symbolic tuple kept a unit factor.
[1, 2, 3] · pevaluated to[1p, 2p, 3p]; it is now[p, 2p, 3p].
0.123.0 2026-09-04
New Features
- Epsil:
matchexhaustiveness warning. Amatchwhose subject has a closed type (a sum such astype light = red | green | yellow, orboolean) now reportsmatch-not-exhaustivewhen a value reaches no case, and names the missing pattern (yellow(),false). A guarded case does not count as covering. Seeepsil doc match-not-exhaustive. - Typed
matchbindings andistests compile. On the JavaScript target,MatchesType(v, T)(used byn: integer => …,while let v: T = …andx 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 aComprehension, now compile on the JavaScript and Python targets, when the element type is known to be tuples of the right arity (aZip, a literal list of tuples, alist<tuple<…>>annotation). - The Python target compiles more loop forms. A
Loopover severalElementclauses, aRangewith integer literal bounds (Range(10, 1, -3)→range(10, 0, -3)), and aComprehension(as a list comprehension). Ziptypes its elements.Zip(xs, ys)now typeslist<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 recommendsMap/Fold, orListFrom, instead of growing a list withJoinin a loop.
Resolved Issues
Types and Validation
- Operators with custom canonical forms now validate their arguments against
their signature.
PoissonDistribution(s)withs := -3was accepted, while a literal-3was 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:
Applytakes any function (Apply(3, 5)), a relation takes a single operand (Less(3)isTrue),Attakes a base of any type, and the set operators take any operand.
- Function signatures print unions correctly.
(complex | infinity, (complex | infinity)+) -> numberprinted without the inner parentheses, and(real<0..>) | nanprinted 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\|_Ffor 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)withx := 0.5returned0.216, whilePDF(PoissonDistribution(2), 0.5)returns0. The symbolic forms now include the support condition, for exampleWhich(⌊x⌋ = x ∧ x ≥ 0, …, True, 0). A point declaredinteger<0..>keeps the plain closed form.
Compilation
- A value that may be a scalar or a list compiles correctly. On the
javascripttarget, an operand typedinteger | vector<integer^2>madeSum(x)fail, and2·x,x + 1andx < 1returned wrong values (NaN, or the string"1,21"). They now handle either shape at run time. Such a union now types2·uasnumber | vector<2>(wasnumber). - A compiled condition with a random operand handles
NaN.If(Random() > 0.5 ∧ x > 0, a, b)took the else branch atx = NaN; it now returnsNaN, andRandom()is called at most once. - The Python target emitted JavaScript for a
Comprehension. - A
Rangewhose bound is not a number compiled to an empty range.Sum(1..10..2)compiled to0. It now fails to compile. - Compiled
Sum,ProductandComprehensionloops 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: falseturns this off.
Arithmetic and Numerics
- A
NaNin a branch condition selects no branch.If(NaN > 0, 1, -1).evaluate()returned-1, while compiled code returnedNaN. It now evaluates toMissing.Greater(NaN, 0)is stillFalse, andIf(NaN > 0 ∧ False, 1, -1)is still-1. ANaNelement in an element-wise condition still takes the else value. - Exact numbers are compared exactly.
Less(10^400, 10^500)andGreater(10^500, 10^400)were bothFalse, and1 + 10^(-20)compared equal to1. Factorialof a very large integer no longer hangs.Factorial(10^400)now stays symbolic, and its.N()is+oo. The same applies toFactorial2.- Dividing a very small or very precise exact rational no longer rounds it.
Divide(1/10^400, 3)was0andDivide(99999999999999999999/10^20, 1)was1.CDF(UniformDistribution(0, 1), 10^-400)is now10^-400.
0.122.0 2026-09-03
Breaking Changes
-
A
typehandler 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 asfacts.finite,facts.sgnandfacts.elementType, and a structural view fromstructureOf(). Usecontext.derive(operator, operands)to type a sub-expression. A handler can no longer declare, canonicalize or evaluate. ThetypeHandlerKindflag, theOperatorTypeHandlerOnExpressionstype and theoperandTypesoption are removed. To migrate:ops[i].type.type→operands[i].type;.ops,.operatorand.string→structureOf().Some built-in types changed:
{n² : n ∈ ℤ}now has elementsinteger<0..>(wasunknown); anIntervalwith a symbolic endpoint hasrealelements (wasnever);√2 ∈ [√2, 1]now typesboolean.
New Features
RuntimeError(code)creates an error value when evaluated. A function whose body containedError("neg")was never defined. Withfunction g(x) { if x > 0 { x } else { RuntimeError("neg") } },g(-1)is an error value thatif let v: !error = …andmatchcan inspect. Thejavascripttarget does not compile it.- Epsil
if let.if let pattern = subject { … } else { … }binds the pattern when the subject matches, and runselseotherwise:if let v: !error = f(x) { v } else { 0 }. Withoutelse, a failed match evaluates toMissing. 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 tosin(Error(…))and1 + Length(5)to1 + 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)thenlen(5)returnedlen(5). An Epsil function can now return an error value, such asmatch-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.
LengthorElementoverList(t, t)18 or more levels deep overflowed. toLatex({ materialization: false })no longer evaluates anything. PrintingJoin(p)withpbound to a long unevaluated chain could take minutes.
Pattern Matching
matchsees lazy lists.match Rest([1, 2, 3]) { [h, ...] => h; _ => "no" }took the wildcard case, and awhile let [h, ...] = xsloop ended after one turn. A lazy list now matches a list pattern.- A
matchcase canbreakorcontinuein compiled code. It caused anUnexpected token 'break'syntax error. - A compiled
...restcapture now splices into a list.[h, ...t] => [t]compiled to a list that held the tail as one element, so awhile 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 typeslist<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)andPower(P, 2)typedlist<number>; they now typelist<tuple<number, number>>. - A comprehension variable's type comes from the collection. In
[q.x + 1 for q in L]withL: list<number>, the body changed the type ofqand the expression failed only when evaluated. It is now a type error when the expression is created. Reshapewith a non-literal size no longer throws.Reshape([1,2,3,4], (n, 2))threwFailed to parse type; it now typeslist<number>.point + (list, list)types as a tuple of lists.G + (L, L₂)now typestuple<list<number>, list<number>>, not a union.
Compilation
- The
javascripttarget refuses invalid point arithmetic.P·Pfor a point list,P + 2,P − L, and a scalar divided by a point list compiled to plausible values whereevaluate()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·√LandL + ln Lfailed to compile on thejavascripttarget. - List arithmetic over a mix of real and complex elements compiles.
Add,Subtract,MultiplyandNegateover2·[1+i, 2]or alist<complex>parameter now compile.
LaTeX Parsing and Serialization
- The serializer overflowed the stack on a product with a
never-typed factor, such asf(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 aRange; it now stays a set, like\{x_1, x_2, \dots, x_n\}. - A two-element
Tuplein 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
Joinaccepts scalar operands. A scalar is appended as one element:Join([1, 2], 3)andJoin(1, 2, 3)are[1, 2, 3]. This was anincompatible-typeerror. Thejavascriptandpythontargets now also append a tuple operand as one element instead of spreading its components.Signaccepts complex numbers.Sign(z)isz/|z|:Sign(i)isiandSign(3 + 4i)is3/5 + 4i/5. This was anincompatible-typeerror. For real values the result is still −1, 0 or 1;Sign(~oo)is still an error andSign(NaN)isNaN. Thejavascripttarget compiles the complex case.
Resolved Issues
- Fixed compiled
PointX,PointYandPointZreading a list of points as a single point when the operand shape was not known at compile time. Forf(v) := PointX(v) + 1,f(P)over a list of points compiled to[2, 3, 4]instead of[2, 5], or threw inside aPointList. - Fixed compiled base-10 and base-2 logarithms losing precision:
log(x) / log(2)atx = 4gave1.9999999999999996instead of2. - Fixed
["Function", ["Add", ["Negate", "q"], ["Tuple", 1, 2]], "q"]being anincompatible-typeerror whileq + (1, 2)was accepted. - Fixed the
glslandwgsltargets missing parentheses before a swizzle:PointX((x, y) + (1, 2))compiled tovec2(x, y) + vec2(1.0, 2.0).x. The same applied toFirst,SecondandThird. - Fixed compiled color constructors (
Rgb,Hsv,Hsl,Oklab,Oklch) with a non-finite channel: they now return aNaNcolor.Hsv(90, 1, ~oo)compiled to the same color asHsv(90, 1, 1). - Fixed
compile()taking exponential time and memory, and eventually throwingInvalid 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, andcomplexcontain only finite values;numberis nowcomplex | infinity | nan. The newinfinitytype contains+oo,-oo, and~oo;signed_infinitycontains only+oo | -oo. The old namenon_finite_numberis a temporary alias forsigned_infinity, and thefinite_*names are temporary aliases for the bare types (finite_numbermaps tocomplex).Use
BoxedType.complex,BoxedType.integer,BoxedType.real, andBoxedType.setIntegerinstead of theirfinite_*counterparts. UseisExtendedRealinstead of the removedisRealproperty. -
Infinity and
NaNhave precise types and matching runtime behavior. Arithmetic, functions, assignments, collection accessors, and compiled code report or propagateinfinity,signed_infinity, andnan. 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, andce.context.assumptionscontains immutable fact arrays instead of booleans.assumptionBindings,ContextAssumptions.bindings,TypeProvenanceEntry.previousType, and theassumedprovenance kind are removed. -
Numeric predicates are three-valued.
isNumber,isInteger,isRational, andisExtendedRealreturntruewhen membership is proven,falsewhen it is impossible, andundefinedwhen 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~ooare rejected.NaNpropagates 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 forArctan2at infinite corners, andNaNfor indeterminate forms. -
Complex accessors propagate
NaN. This applies toReal,Imaginary,Argument,AbsArg, andConjugate. The real part, imaginary part, and argument of~ooareNaN. -
PowerandRootuse the same rules for infinities.~oois not a valid exponent.Root(x, n)followsPower(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.IsEvenis now kept as its own operator. -
Linear-algebra operations validate numeric inputs.
Norm,Trace,MatrixPower, andDeterminantreject incompatible operands and have more precise result types. Frobenius norms work for vectors and higher-rank tensors, the norm of an empty collection is0, 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, andBetaRegularizedhandle 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/CDFare piecewise, discrete distributions return correct values outside their support, andN()computes binomial and Poisson quantiles. -
Statistics functions reject invalid samples. Non-numeric or nested values are an
incompatible-typeerror.NaNandMissingpropagate, quartiles of a single value are supported, and quartile tuple fields are orderedlower,mid,upper. -
Selections are lazy, and undecided conditions stay unevaluated.
If,Which,And, andOrevaluate 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 anincompatible-typeerror. A selection with no matching branch and no default returnsMissing. Custom lazy operators can get the same behavior withselectsOperands. -
Compiled conditions use three-valued logic. Compiled JavaScript and Python no longer select a branch for a
NaN, absent, or undecided condition.And,Or, andNotstill short-circuit. -
Compiled float-only targets represent
~ooasInfinity. These targets cannot distinguish signed and complex infinity. -
Result types include possible exceptional results. Partial operations can have
nanorinfinityin their type, and domain failures of non-numeric operations returnMissing. Division and negative powers include non-finite results when the divisor can be zero. An expression with an empty-range operand has the typenever.
New Features
- Operator definitions can declare their error behavior. The new
nanBehavior,partiality,definedWhen, andrequiresfields describe howNaNis handled, whether the operation is partial, its domain, and its preconditions. Result types includenanormissingonly when needed. Hypotaccepts infinite arguments. An infinite argument gives+ooeven when another isNaN; otherwiseNaNpropagates. Euclidean norms and distances follow the same rule.Factorial,Factorial2,Gamma, andGammaLnaccept signed infinities.+oogives+oo;-oogivesNaN.Ddifferentiates piecewise expressions. Each branch ofIfandWhichis 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 asAbs, are unchanged. - The
javascripttarget 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
pythontarget compiles statement-form conditionals.Ifwith anelsebranch compiles inside blocks and loops. - The
interval-jstarget supports more expressions:Choose, direct application of function literals, bounded random draws, and sums over collections. A finite jump discontinuity gives asingularresult with a correctvalueenclosure, instead of being treated as a pole. - Predicates can have the type
trueorfalsewhen the operand types decide them; otherwise the type isboolean. Compiled code can remove unreachable branches. - Numeric ranges support open endpoints.
real<0<..>meansx > 0,real<..<3>meansx < 3, andreal<0<..<3>means0 < x < 3. An empty range isnever. - Numeric result types include computed bounds for
Add,Multiply,Abs,Divide, and integer powers. Inexact literals get a small enclosing range type. - New
infinityandnanprimitive types, usable in type strings, declarations, signatures,matches(), andisSubtype().~ooand~∞are the type of complex infinity. BigDecimal.toPrecisionToward(n, direction)rounds tonsignificant digits toward negative or positive infinity.
Resolved Issues
Types
- Fixed the TypeScript signature of
ce.declare()rejecting an existing operator definition, for examplece.declare('Sqrt', { ...ce.expr('Sqrt').operatorDefinition!, evaluate }). The accepted type is exported asSymbolDefinitionInput. - 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
iorenot shadowing the library constant. - Fixed assigning a function signature through
.typecorrupting 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-errorcode instead ofevaluation-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 aNaNrow 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
LengthandCountstaying unevaluated for values known not to be collections.
Compilation
- Compilation output is now deterministic.
- Fixed compiled
Blocklocals losing their declared or assigned types. - Fixed compiled
Heaviside(NaN)andSign(NaN)not returningNaN. - 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,LinearRegressionandPolynomialFitpreviously projected every data point through its real part, soCovariance([1, 1+2i], [2, 3])returned0— the answer for the data[1, 1]. A non-real data point now returns a structuredincompatible-typeerror 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~oois not complex data in this sense: it is the single point at infinity rather than a sample point off the real line, so it propagatesNaN. Additionally,Correlationnow propagatesNaNfor any data it has no real value for —NaN,±∞and~oo— matchingCovariance, instead of misreporting it as"zero variance". That error is now raised only when a column is genuinely constant, so atce.precision = 'machine'it also no longer appears for finite data whose sums of squares overflow the double range (values around1e200), which now yieldsNaN. (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 fromMeandirectly.) -
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])answered2.333…, the mean of[1, 1, 5]. Now:Meanreturns the COMPLEX mean —Mean([1, 1+2i])is1 + i— with no convention involved, since the mean is linear. Exact data still gives an exact answer:Mean([1, i])is(1 + i)/2, not0.5 + 0.5i.Variance,PopulationVariance,StandardDeviationandPopulationStandardDeviationcomputeE[|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])is2andPopulationVariance([1+i, 1−i])is1.Median,Mode,Quartiles,InterquartileRange,Skewness,Kurtosis,Histogram,BinCountsand the empirical data form ofQuantilereturn the sameincompatible-typeerror 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/BinCountsreject a complex bin EDGE for the same reason. (Quantileof a DISTRIBUTION is unaffected.)
Real data is unaffected, including the exact results (
Mean([1, 1, 5])is still7/3), andNaNand a real±∞keep the readings they have always had in these heads (Mean([1, +∞, 5])is+∞,Median([1, +∞, 5])is5). -
The complex infinity
~oonow reads asNaNin the one-sample statistics, under both of its spellings. The real part~ooreports is an artifact of how it was written —ComplexInfinityreportsInfinity,Complex(1, Infinity)reports1— so the same value produced different statistics depending on the spelling:Mean([1, ~oo, 5])answered+∞one way and2.333…the other, andMediananswered5one way and1the other. Every one-sample head now projects it toNaN, which is what the bivariate heads already did, so both spellings agree:Mean,Median,Mode, the variance family,Skewness,KurtosisandInterquartileRangereturnNaN,Quartilesreturns(NaN, NaN, NaN), and the empiricalQuantilereturnsNaN. A real±∞is unaffected. -
Meanand the variance family returnNaNfor 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, +∞])isNaN). 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 — soNaNis what these now answer. The real-only paths are unchanged. -
HistogramandBinCountsstay inert on data (or bin edges) that is not a number literal, instead of silently dropping it. ASqrt(-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 empiricalQuantiledoes; under.N()the datum numericizes to a complex literal and is rejected with theincompatible-typeerror. -
A
PolynomialFitdegree 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, soPolynomialFit(xs, ys, Complex(1, 2))silently fitted a degree-1 polynomial andPolynomialFit(xs, ys, 2.5)a degree-3 one, while aNaNor±∞degree was blamed on the argument list asinvalid arguments. Every number in the degree position is now answered by thedegree must be an integer in [0, 12]error unless it is exactly an integer. -
HistogramandBinCountsreject 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~oounder either spelling — used to be filtered out of the sample, soBinCounts([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 structuredincompatible-typeerror the complex rejection uses, naming thefinite_realconstraint, the operator, and the offending value. These two heads cannot absorb a non-finite value the wayMean([1, NaN, 5])returnsNaNdoes: 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 anout-of-rangeerror 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. -
LinearRegressionandPolynomialFitpropagateNaNfor non-finite data instead of misdiagnosing it. ANaN,±∞or~oovalue in the X column made the least-squares pivot search fail, and the heads reportedunexpected-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-NaNtuple(NaN, 0)whose0slope is not a fit of anything. Both now answer with every coefficientNaN, in the shape each head declares:(NaN, NaN)forLinearRegression, aNaN-filled list ofdegree + 1coefficients forPolynomialFit, and the fitted expression withNaNcoefficients 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 matchesCovariance/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 correspondingPolynomialFitcall fell through to the least-squares rank guard and were misdiagnosed as"degenerate data"; they now reportincompatible-dimensions 2 vs 3, the errorCovarianceand every other pairwise head already used for a length disagreement. -
LinearRegressionreports a sample with fewer than two points as such. One point determines no line whatever its value is, but the head answered(NaN, NaN)forLinearRegression([NaN], [2])and"degenerate data"forLinearRegression([1], [2]), whilePolynomialFit([NaN], [2], 1)said there were not enough data points.LinearRegressionnow reportsnot enough data pointsfor any sample shorter than two, ahead of anything the values could say. -
Re,ImandArgreject the argument lists their targets reject. These aliases rewrote toReal/Imaginary/Argumentthrough a construction path that skips signature validation, soArg(1, 2)silently dropped the second operand and answered0whereArgument(1, 2)reported the unexpected argument. They also declared a narrower type than their targets, soRe(NaN)claimed the typereal, 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
-
ReandImare now defined, as aliases ofRealandImaginary.["Re", z]and["Im", z]used to stay inert as unknown operators; they now canonicalize toReal/Imaginary(the preferred names) and evaluate, exactness included:Re(1/3 + 2/5i)is1/3. The\Reand\ImLaTeX commands already parsed toReal/Imaginaryand are what the serializer emits, and\operatorname{Re}(z)now resolves too. -
A
typehandler can now be declared as a function of operand DESCRIPTORS instead of operand expressions. An operator definition that setstypeHandlerKind: 'types'receives, in place of each operand, anOperandDescriptor: the operand's handler-visible type, a deliberately minimal set of three-valued facts carrying only what the type cannot (finiteness — for theNaNliteral, whose type isnumber; 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 withCE_TYPE_PURITY_GUARDelsewhere) a runtime guard turns any engine-state write from a'types'handler into an immediate error. The built-inCoalesce,HoldandReleaseHolddefinitions were the first to use the new shape, with byte-identical derived types;DigitCount,BlockandWhenhave 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 constanttypehandler was retired outright and its result moved into the declared signature —NthPrimenow declares(integer) -> finite_integerinstead of pairing a wide signature with a narrowing handler, with byte-identical derived types. -
GammaRegularizedandBetaRegularizedno 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 claimfinite_realonly when the domain is proven —a > 0andz ≥ 0for the gamma;x ∈ [0, 1],a > 0,b > 0for the beta — and answernumberotherwise, per the non-finite typing convention.
Resolved Issues
-
Square roots of large exact perfect squares are exact at every precision.
Sqrt(10^402)returned+ooat machine precision (the radicand was narrowed to the numeric format before rooting) andSqrt(10^12/9)stayed unevaluated where10^6/3exists. 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 to1000/7. -
Conjugate,RealandImaginarypreserve exactness.Conjugate(1/3 + 2/5i)returned machine floats (soz·Conjugate(z)answered0.2711…where61/225is available);Real(1/3 + 2/5i)returned a 21-digit approximation. All three now read the exact components:Real(1/3 + 2/5i)is1/3,Imaginary(√2 i)is√2, andz·Conjugate(z)is61/225. -
A fence-less
Delimiteraround 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 callf([x,y])carries — serialized as\lbrackx,y\rbrack: the[fence maps to the\lbrackcommand, 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 withjoinLatex, which separates a command from a following letter (this also fixes the same glue for\lbraceand custom bracket delimiters), and a fence-lessDelimiterkeeps 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, andf([x,y])round-trips to the call. The same rule covers the tuple family —Delimiter(Tuple(x, y))used to emitf(x,y)under a call, which re-parsed as TWO scalar arguments where the source had one tuple argument; it now emitsf((x,y)). ASequenceoperand 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.) -
Negatedistributes 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 — aTupleof coordinate lists,([1,2], [3,4])— negated component-wise only when every component TYPE was a scalar number, so-Pstayed an inertNegate, andP − Por the interpolation(1−t)·P − t·P(withtsubstituted later) never reduced, while2·PandP + Pfolded fine. The negate arm now uses the same structural tuple test as theAdd/Multiplydispatch. Thetuple / scalararm had the sibling gap plus one more: it now admits the same tuples as theDividetype handler (transparent type aliases included), and each component quotient is evaluated the way the multiply and add arms already evaluate theirs — soP / sfolds onceshas a value, andP / 0answers(~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 ofcos/sin(π·rational), ordinary in Desmos documents — types thefinite_complexhedge (the engine does not prove5−√5 ≥ 0at type time) though its value is the plain real1.6625…. The broadcast lowering's per-element shape analysis read that element complex, the element verdicts of(√(5−√5), 0)disagreed, and everyAdd/Multiplyover 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.reoff a plain number). The fold-first verdict stands down where the emission cannot fold: underconstantFold: false, in asymbolDepscapture, 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
.typebecame its literal value (ce.box(21).typeis21), 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.typeof 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
Powerover a vector base, so the min-distance-field family compiles on the GPU lane.minover squared distances to a literal point list —(x−P.x)² + (y−P.y)²after vectorization — stopped atPower: the sign-preserving integer-power helper_gpu_powiis declared over scalar floats, andpow(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 ofpowexplicitly, and answervecN(1.0)for a vector base raised to the zero power — which previously emitted a shape-wrong scalar1.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 assuccess: true. Component reduction forMin/Maxover 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
.typeis now its literal type.ce.box(42).typeis42— a value type, a subtype offinite_integer— andce.box(0.5).typeis0.5. An exact rational carries its tier through a singleton range (ce.parse('\frac12').typeisfinite_rational<0.5..0.5>), and an exact value no machine number represents carries its sign on its tier (ce.parse('\sqrt2').typeis(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 stilltrue— but code that compares.type.toString()against a tier name must switch to.matches(). Stored types still widen to tiers:k := 42infersk: integer, a generic callidentity(5)typesfinite_integer(the solver never binds a type variable to a literal type),[[1, 2]]typesmatrix<finite_integer^(1x2)>, and{x -> 1}typesrecord{x: finite_integer}. Type errors now name the offending literal: "expectedinteger, got2.5" instead of "gotfinite_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 (stringat anintegerslot). A symbolic argument whose type merely OVERLAPS the parameter is admitted provisionally:FactorInteger(n)withn: numbernow boxes, works whennturns 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 sameincompatible-typeerror 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 likek(x)withxmerely 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 customcanonicalhandler, keep their existing handler-owned admission unchanged. On a non-strict engine (strict: false) nothing changes. (R1/R8 — §4.4 ofdocs/plans/2026-08-22-type-handlers-on-types.md; pinned byruntime-conformance-fuzz.test.tsandruntime-conformance.test.ts.) -
Sqrtno longer evaluates a closed radicand to decide its type.√(1 − 0.2²)— a machine-float radicand, which canonicalization deliberately does not fold — now typesfinite_complex, the same hedge as any other real radicand of statically unknown sign, instead offinite_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 intype-handler-audit.test.ts). A literal radicand still types precisely —√0.96staysfinite_real, its sign being statically known. Measured blast radius: 3 pinned type assertions, all intype-handler-audit.test.ts; zero snapshot changes in the full suite. (§5.4Sqrtrow and §5.8 A5 ofdocs/plans/2026-08-22-type-handlers-on-types.md.)
New Features
-
Ranged result types:
Abs, even powers andExpcarry their sign in the type.|x|typesreal<0..>(each real tier keeps its non-negative range),x²typesfinite_real<0..>, ande^x— everyPowerwith a provably positive real base — types(finite_real<0..>) & !0, so a consumer that reads the TYPE sees the sign thesgnhandlers always knew:√(x²),√|x|andln(2^x)now typefinite_realinstead of hedging complex.Negatereflects a range instead of echoing it (−|x|isreal<..0>). The scope stops at these heads:Add/Multiplydeliberately still join bare tiers (see the Bug Fixes note below); general interval arithmetic is tracked separately inROADMAP.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..>) & !0for√2) — so the sign and value questions handlers used to answer from the value channel (Power's integer-vs-rational claim for10^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.typeof a literal is unchanged (ce.box(21).typeis stillfinite_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 constantseandπ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-jstarget sizes — or declines — dynamically nested integrals at compile time. An inner integral runs once per enclosing piece, soIntegratenodes 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 keepingruns·count^depthwithin 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 asentireat 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'sentirenow answers a real (coarse) enclosure at ~28 pieces per level in milliseconds. Composition through by-reference function calls shows noIntegratenode in the tree and is still bounded at run time byINTERVAL_NESTED_QUADRATURE_BUDGET. ASum/Productwith 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 incompile-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 toDivide, which erased the declared integer contract, soRational(3, 2.5)— andRational(3, x)withx := 2.5— evaluated to1.2. The signature is now the overload((real) -> rational) | ((integer, integer) -> rational)and a non-integer literal, or a symbol holding one, errors withincompatible-typeon every construction route. A valueless symbol is still admitted and rewrites toDivideas before, and theDividespelling itself is unchanged (Divide(3, 2.5)is still1.2). -
Dividesno longer answers a rounded question.Divides(2.5, 3)returnedTrue— the handler converted operands with the roundingtoBigint, so it actually tested3 | 3— andNotDivides(2.5, 3)returnedFalse(the truth isTrue). Non-integer operands now stay symbolic, as the operator's documentation always stated. The same integrality gate protectsFromContinuedFraction, which used to reconstruct a value from a rounded non-integer term, andNotDivides' declared signature now matchesDivides' ((number, number)). -
The unary
Multiply/Subtractfold no longer lets a held non-numeric value through unexamined.Multiply(s)withs := "str"folded to the lone operand — the operator vanished before any check could run — and evaluated to"str", while the literalMultiply("str")refused. A lone operand holding a concrete string, boolean, or character now errors withincompatible-type; a lone collection still folds (the broadcast identity) and a valueless symbol still folds. -
Intersectionof a single collection is that collection as a set, not the empty set.Intersection([1, 2, 2])evaluates toSet(1, 2)(symmetric withUnion), and a set-shaped operand returns itself without enumeration —Intersection(Integers)isIntegers. NullaryIntersection()is stillEmptySet. -
Vectordeclares the content leniency it always had. Its signature is now(any+) -> vector, matching the tensor family (Matrixaccepts non-numeric entries on every route), and its type handler claims the numericvector<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)withg: () -> numberandk: (integer) -> integerflags astatic-type-erroragain: 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 inROADMAP.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;
tryFoldKnownSymbolnow 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 defaultfallback: trueroute 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.integrateMCjoins the nested-evaluation budget (a runaway by-reference composition under Monte-Carlo, previously unbounded, now answersNaNin seconds; a nested Monte-Carlo integral that previously never returned now answersNaNtoo), and theIntegrateemitter 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
NaNinstead of hanging. The scalarjavascripttarget's_SYS.integratecarries 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. ChoosingNaNas the exhausted result follows the ROADMAP entry's own analysis (the scalar result is an estimate, not an enclosure). -
structuralof 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 nowdepth + 1rebuilds). -
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)fork: (integer) -> integer(the widenednumberoverlapsinteger); the pre-pass now records the initializer's exact literal type as its assignment evidence, which provably refutes the parameter, restoring thestatic-type-error— whilelet x = 2stays clean and a symbolicx = g()stays admitted (that half is an open product decision recorded inROADMAP.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 —√φandln πtypefinite_realoff 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-buildconsole.assertplus the suite pinconstant-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
compileRootand the publiccompile()entry — but registered targets invoked directly (ce._getCompilationTarget('javascript').compile(...)) enter throughcompileCseRoot, 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 dxcompiled to a quadrature call instead oft²). The reset now lives at the one choke point every route crosses — the depth-0 boundary ofBaseCompiler.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 incompile-antiderivative-budget.test.ts. (Surfaced by the dual review of the constant-fold-before-type-read change.) -
Histogram/BinCountsno longer round a non-integer scalar bin spec. The scalar bin spec is a bin COUNT, but it was read with a rounding conversion, soBinCounts(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 anincompatible-typeerror instead of being iterated character-by-character intoNaNbin edges. -
Integer-domain operators no longer silently round a non-integer operand delivered through a permissively-typed symbol. With
u: anyandu := 2.5,FactorInteger(u)answered[(3, 1)],NextPrime(u)5,IsTriangular(u)True, andFibonacci(1 + 2i)1— the ~45 operators of the number-theory and combinatorics families trusted the boxing gate to never hand them a non-integer, andtoBigintrounds by contract. All of them now produce anincompatible-typeerror 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 rawTypeErrorout ofevaluate()(an unguardedCONDITIONS[name]lookup);checkConditionsnow fails closed on an unknown condition name. More generally, a native-fault crash (TypeError/RangeError/ReferenceError) escaping a built-in non-lazyevaluatehandler is converted into anevaluation-errorvalue 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, theTypedascription 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 oftryConstantFoldbehind every gate except theconstantFoldemission opt-out). A closed constant arm beside a complex arm therefore keeps the complex convention under every mode, includingmode: 'strict'withconstantFold: false, where only the fold-informed analysis can prove the structurally-emitted arm real. Pinned incompile-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 spelledexpr._effectsOf?.() ?? applicationEffects(expr), andundefinedis 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.structuralrebuilt through every operand'sstructuralwith 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, liketype), and the rebuilt form preserves the sharing. Both pinned on the depth-30 shared tower indag-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 inROADMAP.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 indag-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 inROADMAP.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
Integratenode (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 distinctIntegratenode 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 iterationw − k·E(w)/d_E(w)whose macro-expansion substituted each level intoE's integrand — emitted 728_IA.integratecalls 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 answersentire— 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 incompile-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 perIntegratenode, so a many-hundred-node emission degrades to its numeric emitters at bounded compile-time cost. The pool resets at every compilation entry — the publiccompile(),compileRoot(raw custom targets), andcompileCseRoot(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 throughAddandNegate. Afterassume(x > -1); assume(y > -1),x + ytypedreal<-1..>— but the sum can be as small as −2 — and-xechoedreal<-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), andNegatereflects a range about zero (-xunder that assumption isreal<..1>).
0.118.2 2026-08-22
New Features
- The
interval-jstarget now lowersIntegrate,At,LengthandPointX/PointY/PointZ. Each compiled on thejavascripttarget and declined oninterval-jswith "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 — aList/Tupleliteral 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:Lengthanswers the count as a point interval;Atapplies 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,partialwhen some of them fall outside the collection, and the numeric absence marker ({ lo: NaN, hi: NaN }) when none selects anything;PointX/PointY/PointZread one coordinate of a single point. A literal collection — or a symbol assigned one — folds at compile time. The gates mirror thejavascripttarget'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).Integrateis a different algorithm rather than a table entry: the antiderivative-first step is now shared with thejavascripttarget (BaseCompiler.closedFormIntegral), so∫₀ˣ sin t dtcompiles to the closed form1 − cos xon 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 answerssingular. 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} dtat 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 answerssingular, an undefined integrandempty, and an infinite boundentire(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 thejavascripttarget. Found and fixed alongside, on BOTH targets: aFunctionintegrand'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 populatedvarsKeys, so a symbol the caller pinned throughvarswas not protected from the closed-form fold (∫₀ᵏ t dtwithkmapped bakedk²/2instead of readingkat 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 toentire; and every scalar kernel now fails closed on a provably collection-valued operand, whereAdd(L, 1)used to answer{ lo: NaN, hi: NaN }andLess(xs, 3)'maybe'behindsuccess: true. The runner's variable type isIntervalInput— 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 — theAddordering 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 andSum/Comprehensioncanonicalization (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 itsAddbroadcast over the operand; the item-221 fix keeps thatAddinert, 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 structuralorderrather 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 indag-shared-walks.test.tson 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(), aSumbinder 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 inROADMAP.md("Assigning a function literal whose body shares operands"). -
A compound factor of a vector
Multiplykeeps its parentheses on theglslandwgsltargets.Multiply(Add(t, 1), Tuple(x, 0))compiled tot + 1.0 * vec2(x, 0.0), which the shader reads ast + (1.0 * vec2(x, 0.0))— the float broadcast into the vector rather than scaling it, so a Desmos-style lerpt·P₁ + (1−t)·P₀rendered ast·P₁ − t + P₀. AMultiplywith a collection operand is lowered by the shader target's function handler, and thecompilecallback 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 sameop[1] < precrule the infix path applies to its own operands (s·(P + Q)over two points emitteds * vec2(a, b) + vec2(c, d)), and a single-statementBlockused as a sub-expression is compiled at the enclosing precedence on every target (Multiply(Block(Add(t, 1)), x)emittedx * t + 1onjavascript,python,glslandwgsl). The scalar multiply,Divide, and thejavascript/pythonlowerings of the vector shape were already correct and are unchanged. (Tycho item 224) -
A bare
Nothingfails closed on every compile target.Nothingis the erasure marker, not a value; it can still reach a compiler as a bare symbol (a malformedWhichwith a dangling clause canonicalizes to it). Only thejavascripttarget refused it —glslandwgslemitted the undefined identifierNothingbehindsuccess: true,pythonthe undefined name, andinterval-jsa_.Nothingread that isundefinedat run time. All five targets now decline with the same diagnostic ("the erasure marker is not a value … Fail closed (D6)"). ANothingarithmetic operand is still erased at canonicalization (Add(Nothing, x)compiles tox), as before. -
An element-wise broadcast no longer captures a collection-typed but still valueless operand as a per-element scalar. With
Aan unknown-length view (Range(0, n)/n,nunassigned) andsdeclaredlist<number>but not yet assigned,Multiply(A, s)evaluated toMap(_ ↦ _·s, A); onces := [10,20,30]andn := 2bound, that stored form materialized as the outer product[[0,0,0],[5,10,15],[10,20,30]]while a freshMultiply(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], typedlist<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 declaredbroadcastable<…>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,PointListcomponents, tuple scaling ((1,2)·sno longer stores(s, 2s)), and operands typednumber | list<number>orbroadcastable<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 baretuplekeeps scaling a list ([1,2,3]·r→[r, 2r, 3r]). (Tycho item 221) -
PointListover unknown-length lazy views is a point VIEW, with a type that carries the point arity.PointList(−√(1−A²), A)withA = Range(0,n)/nandnunassigned evaluated to an inertPointListhead —isCollectionfalse, nocount, typedlist<tuple>with no component arity — while the siblingA·(1,0)already produced a lazy point view. An unknown-length indexed component (a symbolic-lengthRange, a lazyMapover one, aFilter) 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 (aSet) still fails closed; a string component is an atomic coordinate rather than a fail-closed trigger, soPointList("ab", 3)now evaluates to the point("ab", 3)its type already promised. The static type islist<tuple<T₁, …, Tₖ>>(each list component contributes its element type, any other component its own type) instead of the arity-lesslist<tuple>. Consumer-visible consequence: because a two-component point list now statically has no third coordinate,PointZ(PointList(-6, n))is a typedincompatible-dimensionserror at box time on every route, where thejavascripttarget used to emit a kernel ofNaNabsence 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-leveldiv()had no tuple arm, and the broadcast zip builds each element withce._fn('Divide', …), which bypasses thecanonicalDividefold the single-tuple formT / √(…)takes. An inexact divisor left an inertnumber × Tuple; an exact integer divisor ([T, U] / [2, 5]) left a tuple of unevaluatedDividecomponents. A numeric tuple divided by a numeric scalar now scales component-wise on the value route too, andtuple.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 })thence.declare('p', 'pt'):2·p,[1,2,3]·p,Divide(p, 2)andNegate(p)all erroredincompatible-type "number" vs "pt", as did an alias oflist<number>orvector<2>, while the direct spelling was accepted — the shape gates read the type's kind directly and saw an opaque reference, whereisSubtypealready 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. Withudeclarednumber | list<number>and not yet assigned,2u,u + 2,−u,sin(u)and a user lambdaf(u)all typed a definitelist<…>—type.matches('collection')answeredtrue— yetu := 5evaluated2uto the scalar10. 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 giveslist<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 to2u(andSum(u)tou) whereu := [1,2]makes the sum6; andSum(2 + h(x))withhnot yet defined evaluated toh(x) + 2, which re-evaluated to[5, 8]onceh := x ↦ [x, 2x], where the sum is13.Sum/Productwith no index now stay inert over a body typed as a scalar-or-collection union (any branch a list, vector, set, tuple, string orbroadcastable<…>), abroadcastable<…>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/tevaluated to0and∫₋₁¹ 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. TheIntegrateevaluate 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 —±∞, orNaNfor 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 quadratureMeasurement(∫₀² 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 oftan/cot/sec/cscorcsch/cothof a linear argument, or a zero of asin/cos/tan/cot/sinh/tanhdivisor (a reciprocal such as1/tan tis not canonicalized tocot 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 as1/|t − r|. A root is told apart from a bound by the denominator's residual there, not by a distance, so∫₀¹ (t − 10⁻¹⁰)⁻² dtis 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.
sgnofcos 1,sec 1,tan 1(first quadrant) andsin 2(second quadrant) answerednegative: 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 asln(−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) dxhad the wrong sign. The pattern rule answered−ln|sin(ax + b)|/a; the antiderivative ofcotis+ln|sin x|(its derivative iscos 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
Integrateevaluate handler uses when no integration provider is loaded; the Rubi provider was unaffected).∫ sinhand∫ coshansweredln|cosh|andln|sinh|(the antiderivatives oftanhandcoth);∫ tanhansweredln|sech|, the negative of the correctln(cosh);∫ sechansweredln|tanh|where it isarctan(sinh);∫ cschkept 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 dtevaluated toarsinh 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
javascriptandpythontargets now compile the multi-collection (zipWith) form ofMapover symbolic sources.Map((_1,_2) ↦ _1+_2, 1..N, 2..N)withNa free input declined with "Map: multi-collection form is not compiled" — which a plotting document met through Desmos' element-wise list differencec = (1..N) − Join([0], 1..(N−1)), whose bound value is exactly that zipMap, so every compiled expression readingcdeclined 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'scountfor the form, and whatZipdoes. A parameter annotation on the callback is checked against ITS source, position by position, with the same fail-closed rule the unary form has (anintegerannotation over anumber-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 barelist) — the callback's parameters are compiled untyped and its body treats them as real numbers; a source with observable effects (one that drawsRandom) — the compiled code materializes every source in full where the interpreter stops at the shortest; and a bareAdd/Subtract/Multiply/Dividesymbol as the mapping over anything but exactly two sources — it compiles to a two-argument function. The effects decline now guardsZipon 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)/nwithna free input, thenY_0 := Y + 0·(2L−1)— nests oneMapper arithmetic step, and asking such an expression for its type cost 2^depth type-handler invocations.Sqrt(1 − Y_0²)took 3.6 s, andPointList(−√(1 − Y_0²), Y_0)did not return at all. A bound list was affected too, superlinearly in its length: parsing anrgb(…)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 aMap. And the element-type derivation added in 0.118.0 declares stand-in symbols in a scratch scope, which advanced the engine'sanycache generation — the generationBoxedFunction.typekeys 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. OnePointListevaluation 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/Productover 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, soSum(Random()·[1,1], n=1..31)stopped drawing at the first NaN while its scalar twinSum(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, likeevaluate(). Since 0.117.02(x+1)evaluated to2(x + 1)but.N()of the same expression still came back2x + 2: the numeric route of theMultiplyhandler kept distributing..N()isevaluate()with floats, so the two now agree on shape and differ only in the numbers —√2(x+1).N()is1.414… * (x + 1),((a+b)/c)·dkeeps its quotient shape(d(a + b))/con both routes, and a closed constant such as2.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 underevaluate(). 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 to1.57…, as0.5 · πdoes, instead of leaving0.5π.Expandreproduces the previous output;simplify()and the internal normalization paths still expand. The values are unchanged. -
A compiled
Sum/Productnow 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, becauseRandomis not caller-supplied. Skipping terms draws from the generator fewer times, and a later draw observes that, soSum(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
compilehandler 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'spure/effectsnow governs: a definition declaring no effects keeps the exit,pure: falserefuses 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) + 1summed 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 throughfunctionshas — 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
functionsentry through its declared or inferred purity, an operator with a callercompilehandler through its definition, everything else through the effects model. A spelling with no oracle is refused, which is where anoperatorsentry ([op, prec], no body and no declaration slot) and a string-valuedvarssymbol land.Note the two oracles have deliberately opposite defaults. A
functionsentry must EARN purity — it is inferred from the source, and anything the analysis cannot model is refused — while an operator definition is GRANTED it, sincepuredefaults 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. - An impure operator of the engine's own no longer keeps the exit. A sum
containing
-
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+) -> stringaccepted a(integer, string) -> stringfunction, and the mismatch surfaced later, at a call the declaration explicitly permits:declare join: (integer, string+) -> stringjoin := narrow // narrow: (integer, string) -> string — was ACCEPTEDjoin(1, "a", "b") // permitted by the declaration → unexpected-argumentThe 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 typednumber | ComplexResult, but a compiled predicate returns aboolean(Greater(x, 0)runs totrue, never1), a string-valued expression returns astring, and a collection-valued one returns a (possibly nested) array. TypeScript therefore acceptedresult.run({ x }) * 2on an expression that could never be a number. The default is now the new exportedCompiledValueunion — which also covers a function-valued expression, sinceDerivative(Sin)runs to the callable(x) => Math.cos(x)— so such a call is a build-time error. Theinterval-jstarget keeps its own result type (anIntervalResult, or a bareIntervalfor 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 anumberback. This is the type-level replacement for therealOnly: trueoption removed in the previous release, which was the only remaining way to obtain a narrow numeric result; unlikerealOnlyit 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 takesnumber | ComplexResulton a JavaScript target, so a complex-mode call such asrun({ z: { re: 2, im: 3 } })type-checks instead of needing a cast. It was previously typednumberalone, which refused the one shape complex mode exists to accept — while the_getCompilationTarget('javascript')route had always typed it the wider way.interval-jswidened by the same step, fromnumberalone tonumber | 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 passesentryChecks: false, which disables that guard along with the others and lets the value reach real arithmetic, yieldingNaN. -
~oo(ComplexInfinity) now typesnumber, notcomplex. 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~oovalue could type differently depending on whether the constant survived canonicalization (Divide(~oo, 5)answeredcomplex,Add(1, ~oo)answerednumber).expr.type.matches('complex')is nowfalsefor~oo, exactly as it is forNaN. A value carrying an infinite IMAGINARY part is~oofor 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
NaNon a real-valued lane.(-1)!,1 + (-1)!and1 + \tilde\inftycompiled to the object{re: Infinity, im: Infinity}; they now run toNaN. A pole has no real value, andNaNis how the real lane spells that — the same projection_SYS.factorialalready 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'snumbertype, 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 answerNaNon both paths, and on the shader targets too, where thevec2this 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 toInfinity(the fold evaluates symbolically, and the interpreter answers+oo) but lowers structurally toNaN, because on the real lane the pole is alreadyNaNby 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
glslandinterval-jstargetsf((x,y))withf(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 noPointX/PointYlowering 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), sof((x,y)),d((x,y))withd(P) := √(P.x²+P.y²), a two-pointQ((x,y),(1,2))and a chainede(P) := d(P) + 1all compile on both targets; thejavascripttarget 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 depthi— 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
javascriptcompile 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" whilex+[1,2,3]and2(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 withevaluate()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).AddandDivideof a point against a list, which the interpreter answers with a per-elementincompatible-typeerror 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] = PwithPdeclaredtuple<number, number>compiles. It declined as "a tuple participant" while[1,0]=[x,y]and(1,0)=Pcompiled. 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 toFalsebefore compilation. The list-vs-point-symbol pair now compiles to that constant (trueforNotEqual) instead of declining. (Tycho item 215.) -
A zip of two unknown-length point views keeps its element tuple-ness in the TYPE. With
nunassigned,A = (Range(0,n)/n)·(1,0)is a lazy view of points, butA - Bevaluated toMap((_1, _2) ↦ _1 + _2, A, B)typedindexed_collection<number>— the mapping's parameters are bare, so its body was typed with bothunknown— andPointYover the view folded to a scalar absence marker on the strength of the type alone, while the pulled values were correct tuples.Mapnow 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 readsindexed_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)withx: numberistuple<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.PointYover a point view whose length is not yet decidable answers the lazy projection instead of an absence marker. (Tycho item 212.) -
PointX/PointY/PointZover a list of SYMBOLIC points stay symbolic. WithPdeclaredtuple<number, number>and unassigned,PointY([P])evaluated to[NaN]andPointY([(1,2), P])to[2, NaN]whilePointY(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, withnassigned, as([0, 1/3, …], [0, 0, …])— because the numeric routes ofMultiplyandAddran their tuple branch on the raw operandRange(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 shapeN([0, 1/3]·(1,0))used to crash (mulNhad no single-operand short-circuit, somulTensors' 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 variablePrime(alpha_1), but\alpha_{i+1}',A_{i,j}'andx_{n+1}''parsed asDerivative(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))forfdeclared 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 asDerivative(f, n), but withnunassigned it canonicalized to anincompatible-typeerror (the order was checked againstnumberwithout 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 inferrednumberand the expression stays inert until it is assigned (n := 2then givesf''). Also fixed on the way:Derivative(g, 0)withga function symbol evaluated to(x) ↦ g, the constant function returning the symbol, instead ofg(likewise the all-zero multi-indexDerivative(g, 0, 0)), and a multi-index derivative of a symbol bound to a lambda (Derivative(g, 1, 0)withg(x, y) := x^2 y) stayed inert because that arm only recognized an inline function literal. -
A compiled
Sum/Productkeeps 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 thefunctionscompile option projects unknown effects onto every application of it, so the skippability gate — which lets aSumstop 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)withwrap(t) := sq(t) + 1: thefunctionsentry'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. -
Multiplygave~ooa sign.2·~ooevaluated to+ooand-2·~ooto-oo, which contradictedNegate(~oo)— that correctly stays~oo, so-2·~ooand-(2·~oo)disagreed. An undirected infinity takes no sign from its factors: all three are now~oo. One neighbouring case went with it:i·~ooansweredNaN, because the general complex product computed∞·0 − ∞·1. The indeterminate form0·~oois stillNaN, and the signed infinities are untouched (-2·∞ = -oo). -
A real infinity turned in a non-real direction is
~oo, notNaN.∞·ievaluated toNaN, 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∞·0for 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, soi·ln(0)is~oorather than-oo.0·∞remains the genuine indeterminate form atNaN, and a real factor still keeps the signed rule (-2·∞ = -oo,2·ln(0) = -oo). -
A sum holding
~ooalongside a real infinity kept the wrong one.∞ + ~ooevaluated to+ooand-∞ + ~ooto-oo, discarding the~ooterm; both are~oo, since the undirected point at infinity absorbs the sum.Addselected~ooby asking whether a term typedcomplex, which stopped selecting anything once~oomoved tonumber, leaving the signed-infinity counters — which track only real ±∞ — to decide. It now tests the value. Two real infinities are unaffected (∞ + -∞is stillNaN). -
Gammaat a non-positive integer returned a large finite number when compiled.Gamma(-2)compiled without constant folding ran to6413262697001887: the reflection formulaπ / (sin(πz)·Γ(1−z))cannot see the pole, becausesin(-2π)computes as ≈2.4e-16 rather than 0. The kernel now answersNaNat every pole. The interpreter was unaffected — it returns~oobefore 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 anincompatible-typeerror naming the offending entry, the way the siblingDictionaryFrom/RecordFromhandlers 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.prototypemembers work. A__proto__entry could not be stored at all (the dictionary rendered as{->}and listed no keys, whileAtstill returned the value by reading the prototype), and reading a MISSINGtoStringorvalueOfkey returned the inherited JavaScript function as if it were a math value, where any other missing key yieldsNaN. The same flaw dropped a__proto__field when arecord{…}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")wheref: (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, soT: numberrefuses astringoutright. 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.prototypemember no longer reads that member. A MathJSON symbol name is an arbitrary string, but several lookups keyed plain JavaScript objects by it, sotoString,constructor,valueOfor__proto__found an inherited value instead of missing. Three consequences, all fixed:ce.parse('\\mathrm{toString} + 1')threw aTypeError. 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 + 1returned the string"function toString() { [native code] }1", where every other missing symbol yieldsNaN. A caller-suppliedvarsmap was also consulted withinrather than an own-property test, on all four targets. \\mathrm{__proto__}resolved as a known unit, because the unit tables answeredObject.prototypefor that key and the lookup tests its result for truthiness.\\sum_{\\mathrm{__proto__}=1}^{3} \\mathrm{__proto__}silently lost its index and evaluated to3·__proto__instead of6.
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
realOnlycompile 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 torewhen the imaginary part was at roundoff scale and toNaNotherwise, a top-level boolean becameNaN, 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 returnedComplexResultalways hasim !== 0. Testtypeof 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 asNaN. To forbid promotion and hold the real lane — whichrealOnlynever did — passmode: 'strict'.realOnlyis 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
functionsentry can now be declared pure, and a compiledSumover it stops at the first NaN again. A compiledSum/Productexits 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 — afunctions/operatorsentry, a string-valuedvarssymbol, an operator with a callercompilehandler — 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
functionsoption accepts a{ source, pure? }descriptor beside the bare source-or-function spellings it always took. A declaredpureis 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: falsepins 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
Mathmembers is taken as pure. It calls nothing else — not a parameter ((f) => f(x)is rejected, sincefis whatever the caller passed at run time), and not aMathmember outside the allowlist (Mathis an ordinary mutable object, soMath.auditmay be anything). Also rejected:Math.random, whose two calls disagree; a closure over an outer binding ((t) => t * scalementions 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 callmyOp((a, b) |-> a + b)reported nothing and failed at application; it now carries the samecallback-aritydiagnostic the opposite mismatch gets — "myOp calls its callback with 1 argument (per the declared parameter list);(a, b) => a + bdeclares 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) -> numberis 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. -
SortandOrderingdeclare 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 (Truemeans 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 meantSort(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 toac+bc+ad+bd, and2(x+1)stays2(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.Expandreproduces the previous output verbatim, so code that needs the expanded form should ask for it. This is what made aProductof 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 theMultiplyandProductevaluate handlers (mulFactored()andproductAccumulate()respectively; the∏(kn-1)example above is theProducthalf). Several results are now reported factored where they were not:∫x²/(2(1+x²))dxis½(x - arctan x),d/dx LambertW(x)isW(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, asubscriptEvaluatebase (such as\gammafor theEulerGammafamily) keeps its subscripts, and dictionary-claimed constants such as unbraced\mu_0are untouched. If your code readsform: '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 quotientlist<number>, dropping the element tuple-ness, while the equivalent productp·(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/PointYover alist<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, abroadcastable<number>, a sibling collection of scalars that divides elementwise), and a divisor that can present a tuple — a point, a point list, or abroadcastable<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 tolist<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.5is[(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
functionand only then assigned a tuple-valued lambda typedf'(t)asnumbereven though evaluating it returned a 3-tuple — so the declared type contradicted the value, and type-strict consumers such asCrossandDotrejected the call withincompatible-type.Derivativenow reads the codomain of an assigned function literal when the declaration itself is uninformative, andDof a tuple- or list-valued body reports the shape it actually evaluates to (D((cos t, sin 2t, t), t)is a tuple, not anumber). A head that is declaredfunctionand never assigned still reports the long-standing scalar compromise. -
A literal
0operand 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 literal0is the number0— 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 barereturn, 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), andDelimiter(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}asm^{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 a0in the output:Sum(0, i=1..10)now serializes\sum_{i=1}^{10}0, but the upper limit contains a0of 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
Whenrestriction keeps its precedence.When(-1, cond)serialized as-1\left\{cond\right\}, which reads back asNegate(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 toWhen(-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,constructorandvalueOfcollide 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, andtoStringused as a variable was refused bycompile()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/Productno 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 atx := 1answered 60 where the same expression built directly answers 120, andΣ_{i=1}^{2} Σ_{j=1}^{2} x·iunderx → 2answered8i— the imaginary unit — instead of 12. Nothing looked wrong: the rewritten expression reportedisCanonicaland 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 indexedp/qwere correct all along and sums indexedi/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 asSum(2·Complex(0,1), Limits(Complex(0,1), 1, 3))and then failed to evaluate withincompatible-type; theNumberform 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/Productnow stops at the first NaN, and a constant collection inside one is built once instead of once per term. When both bounds of aSumorProductare 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 31Array.fromconstructions, 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 flata + b + cchain; 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 — afunctions/operatorsentry, a string-valuedvarssymbol, an operator with a callercompilehandler — 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: avarsentry whose value is a STRING is spliced as source, which is exactly the carve-out above, so a sum compiled withvars: { x: 'number' }emits no early exit at all and looks like the old behavior. Measure with a numericvarsvalue, or with novarsmap — the free symbol is read from the arguments object either way. -
A protocol function member read as a field now says so.
b.spanfor afunction span(self: Self) -> numberrequirement reportedunknown-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 reportsprotocol-function-not-a-field, naming the protocol and the call spelling: afunctionmember is called (span(b)), only areadonly/readwriteproperty is read with a dot (b.area). This is the mirror of theprotocol-property-not-callablewarning 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 beprotocol-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
functionmember is invoked in call position (span(b)), areadonly/readwriteproperty 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 inprint(c, area(c))printedarea(Circle(…))with nothing to say why. It now raises aprotocol-property-not-callablewarning 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-pendingwarning 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 thetype X is Pstatement 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: listfollowed byxs = f(0)raisedincompatible-type list unknown, and so did thelet xs: list = f(0)spelling, for every declared type (number,string,list<number>, …). A type ofunknownstates 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 alistparameter was accepted. Values the engine CAN type are still held to the contract (xs = 42againstlistis still an error), and a bare-constructor declaration still refines its element slot from real evidence: an admittedunknownvalue leavesxsreportinglist, and a laterxs = [1, 2, 3]refines it tolist<finite_integer>.anyis 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 anunknownvalue 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 defaultautomode a lane mismatch — a complex-shaped value, typically a promoted unknown-sign radical, reaching a binding the compilation shaped real — is answered by recompiling undermode: 'complex'. That retry existed only in the standalonecompile()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 thrownLaneMismatchErrorinstead — or, withfallback: true, asuccess: falseresult with thelane-mismatchdiagnostic and an interpreter-backedrun— where the standalone route returned working compiled code. The escalation now lives in each target's owncompile(), so both routes behave identically: such a call returnssuccess: truewithmode: 'complex',promoted: trueand the failed strict attempt's diagnostic inescalation. Callers of the target route under the default mode are affected;fallback: truecallers will seesuccess: truewhere they previously sawsuccess: falsewithlane-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 declaresautosupport no longer inherits the retry from the standalone wrapper — apply the exportedcompileWithAutoEscalationhelper inside itscompile()to keep the behavior. -
The Python target's interpreter fallback no longer drops the
realOnlyprojection. Withfallback: trueon the Python target route, a declined compilation handed back an interpreter-backedrunthat ignoredrealOnly: 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
Crossaccepts numeric tuples, likeDotalways did.Cross((1,2,3), (4,5,6))computed nothing — the signature demandedvector, so a point spelled as a tuple was rejected withincompatible-typewhile the same point passedDot— and the two classic vector products disagreed about what a vector is. A provable numeric 3-tuple (a point in ℝ³, including aPointListrow) is now lowered to its component vector exactly asDotlowers it; the result is aList, consistent with collection operators not preserving thetuplekind. A tuple that is not provably numeric stays a symbolicCross, and a wrong-length tuple gets theincompatible-dimensionsreport, not a type error. Affects code that buildsCrossoverTuple/point operands — previously an error, now a value.
Resolved Issues
-
compile()no longer reports the boolean literals as required inputs.CompilationResult.freeSymbolslists the identifiers a caller must supply, andTrue/Falsewere 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 fromfreeSymbols, andTrueis the idiomatic fallback condition of aWhich, an ordinary piecewise such asWhich(x > 0, 1, True, 2)acquired a phantom variable namedTrue. -
A boolean literal compiled to invalid code on the shader and interval targets. The
glslandwgsltargets emitted the undeclared identifierTrue— a shader that fails to compile, reported behind a successful result — and the interval target emitted a lookup for a caller-supplied variable namedTruethat 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,
DegreesandDMSvalidated 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 assin("a")andDegrees(At(["a", 2], 1))answered a bareNaN. All of them now answerincompatible-type, matching the arithmetic operators. This completes the numeric-operand checking introduced in 0.116.0, which had covered arithmetic but not these operators. -
DMSno 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, soDMS(1, At([30, 2], 1))answeredNaNeven though the minutes component resolves to 30, andDMS(1, i)answered exactly whatDMS(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, sorunwas typed optional even for executable targets such asjavascript, andrealOnly: trueno longer narrowedrun's values to plainnumber— 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).runis non-optional,compile(expr, { realOnly: true }).run(...)is anumber, and callingrununguarded 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
Typenow returns a type value instead of text. UseStringFrom(Type(x))if you need the type name as a string, or use type-aware checks such asx is integerandSubtype(Type(x), TypeFrom("number")). Code such asType(3) == "integer"now evaluates toFalse.- Callback type declarations now use regular arrow types. The old
callback<...>spelling no longer parses. Declare the callback slot directly, for example(T) any -> booleanfor a predicate. Calls that could only fail, such as filtering alist<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.
Aboutnow returns a dictionary. Read fields such asAbout(Pi)["type"]orAbout(f)["signature"]instead of parsing a display string.- Bare collection types now mean values-only collections.
list,setand similar bare types are synonyms for their<unknown>form, not<any>. Usecollection<any>when absence markers such asMissingshould 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 primitivetype. Example:Subtype("integer", "number")evaluates toTrue. istests andmatchtype patterns now accept full type expressions, including unions, negations and parameterized collections. Example:x is list<integer>andx 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],xsis 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(...)andinput(prompt?).printwrites to the host console and returnsNothing;inputreads one line of text where the host supports it. - The CLI and REPL no longer echo a final
Nothingresult, so a program ending withprint("done")only displaysdone. - Very deeply nested Epsil expressions now produce an
expression-nesting-limitdiagnostic 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
regexptype andRegExp(pattern, flags?). New operations includeIsMatch,StringMatch,StringMatchAll, and regular-expression forms ofStringSplitandStringReplace.IsMatch("abc123", RegExp("[0-9]+"))StringReplace("a1b22c", RegExp("[0-9]+"), "#") -
Regular expressions use the host JavaScript regexp dialect. The
gandyflags are rejected because their mutable position would make evaluation order visible. -
StringMatchreturns match text, ranges, numbered groups and named groups. Ranges are character-based, matching the rest of the string library.
Symbolic
AndandOronce 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-functioninstead of staying inert. Example:Pi(2)is now an error.
Engine State
- Added engine checkpoint APIs:
ce.checkpoint(),ce.restore(cp)andce.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 andPrint(...)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 + 1is allowed fora: value, buta = "hello"later produces anincompatible-typeerror. - Fixed several compiled complex-number cases that could return
NaNor corrupt values while reporting success, including color functions, list broadcasts, complex-declared symbols with real values, and promoted radicals inside broadcasts. CompilationResult.modenow reports the actual arithmetic mode used by the emitted code. A default compile that promotes to complex reportsmode: "complex".- Deprecation warnings for
realOnlyandcomplexPromotionnow appear through target-level compile entry points as well as the standalonecompile()export. - Consumers that discover the engine through the global registration object can
now access the supported
compile()helper there too.
Collections
Sequencenow splices intoList,SetandTupleliterals. 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 duplicate2. Mapover a set now returns the distinct image of the set. Example:Map(x => x^2, Set(-1, 1, 2))isSet(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
ExtractandExcludeoperators. UseAt,Slice,ReverseorDeleteAtinstead.
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.
| Expression | CE 0.116.0 | CE 0.100.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 8.4 | 7.5 | 177 | 1,316 | 3.9 |
\sin 1 | 21 | 21 | 220 | 440 | 5.1 |
\cos 1 | 21 | 21 | 220 | 618 | 7.1 |
\ln 2 | 15 | 14 | 350 | 4,344 | 3.7 |
e^{\pi} | 14 | 13 | 213 | 5,308 | 4.6 |
\zeta(3) | 1,533 | 1,551 | 267 | — | 49 |
\Gamma(\tfrac13) | 829 | 821 | 342 | — | 213 |
\psi(\tfrac13) | 715 | 716 | 2,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.
| Operation | CE 0.116.0 | CE + R/F | CE 0.100.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 3.7× | 2.0× | 2.8× | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 7.7× | 1.4× | 5.8× | 0.08× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 4.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\,x | 63× | 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)^x | 3.8× | 3.5× | 4.4× | 2.1× | — | 1× |
\int_1^2\tfrac1x\,dx | 3672× | 4204× | 4152× | 80× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 262× | 100× | 237× | 2.6× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.2× | 0.2× | 0.3× | 0.06× | — | 1× |
x^3-x-1=0 | 1.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 Engine0.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 asSqrt(x),Ln(x)and non-integer powers may promote to complex arithmetic when the sign is unknown. For example, compiledSqrt(x)now returns{re: 0, im: 1}atx = -1and2atx = 4. Usemode: "strict"for the previous real-only compile behavior. complexPromotionandrealOnlyare deprecated. Usemode: "complex"ormode: "strict"instead.realOnlystill 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 whenim !== 0. Usetypeof value === "number"to test whether a sample is real. StringJoinnow 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". UseJoin("ab", "cd")or interpolation for string concatenation.- Several string collection operators now preserve string results.
RandomShuffle("abc")andDeleteAt("abcdef", 2)return strings, not lists of characters. Chunking and combinatorics over strings now return lists of strings, for exampleSlidingWindow("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. CompilationResultnow includesmode,promoted,escalationand structureddiagnosticdata 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-mismatchdiagnostic. - 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
ReduceorScanwhose accumulator becomes complex mid-fold through an unknown-sign radical — for exampleReduce([4, -4], (a, x) -> a + sqrt(x), 0)— returnedsuccess: truewithvalue: nullin 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 commit7cfa95b4.
Epsil
-
Added definition attributes for user-defined functions:
bindparameters, algebraic properties such ascommutativeandassociative, 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 putxsin 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. -
WhichandIfnow hold or broadcast correctly when a comparison's broadcast shape cannot be decided statically. -
SumandProductnow substitute loop indexes correctly inside heldIfandWhichterms. -
A symbol's inferred type is updated when a later value proves the earlier guess was too narrow.
Strings
Joinnow concatenates strings. Example:Join("ab", "cd", "ef")returns"abcdef".- Added contiguous sequence search:
RangeOf,ContainsSequence,StartsWithandEndsWith. Example:ContainsSequence("abc", "ab")isTrue, whileContains("abc", "ab")isFalse. SliceacceptsNothing, soSlice(xs, RangeOf(xs, needle))returns the match orNothingwithout an extra branch.- Added
StringReplace,Trim,TrimStart,TrimEnd,StringRepeat,PadStartandPadEnd. - Added case operations:
ToUpperCase,ToLowerCaseandCaseFold. UseCaseFoldfor 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, notNaN.
0.114.0 2026-08-16
Breaking Changes
- Strings are now indexed collections of characters.
Length("shop")is4,"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 ascollection & !string. stringis no longer ascalar. Declarestring, orstring | number, when strings are intended.- Added a distinct
charactertype. Characters are single user-perceived characters and are not one-character strings, although they compare equal by value where appropriate.Characters("ab")now returnslist<character>. - String-preserving collection operators now return strings for string
input. Examples:
Reverse("abc")returns"cba"andTake("abc", 2)returns"ab". Transforming operators such asMapstill return lists. - Materializers and set operators read strings as characters.
ListFrom("abc")is["a", "b", "c"], and[..."ab"]is["a", "b"]. Max,Min,GCDandLCMno 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+FFFDso 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"isTrue. String(collection)now joins a finite collection into one string. Example:String([1, 2])returns"12"; useMap(String, [1, 2])to convert each element separately.
Epsil And Types
- Added
holdfunctions, whose arguments are bound as written and evaluated only when used in the function body. Example:hold f(e) = Head(e)letsf(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
stateeffect. HeadandTailnow resolve a symbol's assigned expression during evaluation while staying structural for unbound symbols.
Numeric And Symbolic Fixes
ReduceandScannow handle complex accumulators correctly in both interpreted and compiled numeric paths.Absover 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\etabecomes a collection. - A compiled user function with a
complexparameter 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
readwriteproperty or a member declared withstateeffects can only be implemented by anobject{...}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 bypand returns the assigned value. It no longer calls a setter that rebuilds and rebindsp. - Collection operators now report result types by collection kind. Operators
such as
Reverse,RotateLeft,RotateRight,Rest,MostandFilterno 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 Nameablecan satisfy a readonlynameproperty 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
Slicenow accepts an index span. Example:Slice(xs, 2..4)is equivalent toSlice(xs, 2, 4).- Bare collection element types now consistently read as
unknownrather thanany. - Assigning a value to a symbol declared
unknownnow refines the symbol from the assigned value. Declareanywhen the type should remain fully permissive. - Speculative parsing no longer narrows symbols declared
unknownin 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, _) -
recordandobjectfield types now use braces. Writerecord{x: integer, y: integer}andobject{name: string}instead of the old angle-bracket form. -
And,Orand 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
forloop 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 thatMapcalls its callback with one argument. - A pipe stage that cannot accept exactly one piped value now reports
pipe-stage-arityinstead of returning a partially applied function. - Duplicate lambda parameter names are now parse diagnostics.
forandwhileloops now serialize back to Epsil loop syntax instead of genericLoop(...)calls when possible.
Types And Collections
- Added the
rangetype 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,Minand statistics overLinspacenow 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, allowingWhichandSumto type their results more accurately. - Scaling or shifting collections now widens element types correctly. Example:
(1..4)/2reports 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
SumandProductbounds. - Statistics functions such as
Mean(x)now compile correctly for a single value. - Differentiating point-valued functions installed through
assignis componentwise again. - Primes before subscripts now attach to the subscripted function name. Example:
F'_{0}(t)parses as the derivative ofF_0applied tot. - 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,MaxandModnow decline complex operands instead of compiling toNaN.
0.111.0 2026-08-15
Breaking Changes
RecordFromhas been removed. UseDictionaryFrom. 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 + 1stores into the object in place, and all aliases see the update. - Assigning to fields of immutable values now reports
immutable-value-assignmentwith 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
withTimeLimitdeadlines 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 unassignedL: 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
countwithout requiring a value. Example: avector<2>symbol has count2.
0.110.0 2026-08-15
Breaking Changes
- Functions that write to outer variables must declare the
scopeeffect. Unannotated functions now guarantee they do not mutate outer bindings. Usefunction bump(n) scope { total = total + n }when mutation is intended.
Compilation
- Added
complexPromotionas an opt-in compile option for complex results fromSqrt,LnandLogwhen 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
Rangebounds 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
unknownnow refine from the function body. Useanyfor 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] / 4has rational components. EqualandNotEqualbetween 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
TakeandDropoperations in compiled code now honor the requested count instead of throwing at the iteration limit. FilterandUniquenow apply the iteration cap to unproductive pulls, so productive infinite filters can be consumed safely withTake.
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 beforedhas 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 returns1. - 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. Dropwith a negative count now reports the same element count it actually yields.- Compiled counted
TakeandDropover infinite streams now work when the requested count is larger thance.iterationLimit.
0.108.0 2026-08-14
Breaking Changes
Mapnow takes the function first:Map(f, xs). The legacyMap(xs, f)order now reports anincompatible-typeerror. Pipeline calls should usexs |> Map(f, _)or simplyxs |> 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 asx + 55. UseconstantFold: falseto inspect structural output. - Difficult symbolic derivatives now fall back to numeric differentiation for
N()and compiled evaluation when a closed form is too large. Exactevaluate()still stays symbolic. At(xs, indexes)now preserves known selection counts, improvingZipandMapover gathered elements.expr.unknownsno 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 toInfinityin machine-precision paths.- Python-compiled counts for
Take,Drop,TabulateandFillnow round like the interpreter. - Numeric quadrature now honors active time limits.
Strings
IntegerStringnow 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 -
otherwiseis now accepted as the wildcard case inmatch. -
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)returns8.
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
TakeandDropcounts 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
stateeffect 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==— arithmeticEqual, 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:\equivstill parses asIdenticallyEqual. -
.toString()no longer prints arithmetic equality inSame's spelling:Equalnow serializes as==(was===) andNotEqualas!=(was!==). The old JS-flavored spellings predate Epsil, where===parses as the structuralSametier — so the string view printed anEqualin the exact spelling that reads back as the other operator.Samenow also serializes in operator form (===) instead of function-call form;IdenticallyEqualstays in call form (the same no-equivalence-glyphs ruling as the Epsil entry above). Compiled JavaScript is unchanged (Equalstill 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
8e19from their seed corpus, minimal witness1e16). 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, soce.box(1e16)and the parse of its own serialization wereisSamewith byte-equal MathJSON yet hashed differently — the sameisSame ⇒ equal hashbreak, past the boundary (2^53itself 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 of2^53has 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
unknownnow 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 typeunknownwhen its uses record no type evidence (a bareListsibling, a binder-body occurrence); assigning5to such a symbol then adopted the value's raw type (finite_integer), while the same assignment on a fresh symbol declares the promotedinteger. Theunknown-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-mismatchnames 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 annotatedpurewhose 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.mdAppendix 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
ErrorJSON shape gains an operand: argument-validation errors now carry the FAULTED OPERAND as a site operand, before the trace. Anincompatible-typeerror 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", …]]ErrorTracestays LAST and is head-identified, as always — code that finds the trace by scanning for theErrorTracehead 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 positionalErroroperand reads before adopting. Rendered messages now name the site ("… atp"). Two contract repairs ride with the change:match'sError(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 containingErrornodes (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 generalnclause whilef(0)runs the base clause) and protocol members — bare (tag(prefix: "→", self: s)) and qualified (Tagged.tag(prefix: "→", self: s)) alike, dispatching onselfwherever 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, andargument-optional-skipped, all covered byepsil 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.mdAppendix 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, sinceDefineFunctionhas no lowering in any target) reports the compiler's explanation in the pane instead.
New Features
-
A definition-level
compilehandler onWhichis now honored (Tycho item 180). The per-operator compilation extension point excluded every control-flow head;Whichis 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 aSumindex or aFunctionparameter list, which remain non-overridable). The handler has the same contract as every other operatorcompilehandler: it takes precedence over the built-in lowering, and returningundefinedfalls back to the stockWhichcompilation, 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 stockevaluate/canonicalhandlers, 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 inferredu: numbertointeger) — and the second one is not confined by parsing inside a child scope, since the write lands on the resolved definition wherever it lives. Withspeculative: truethe 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 thescopeoption, 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 expressionField(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 keptApply(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)reportedincompatible-type number/unknownon the dispatcher call, while the identical shape through a plain declared function worked. Cause: at an undecided receiver, the requirement'sSelf-typed result substituted as a type reference named "unknown" — printing exactly like the primitive but defeating every gate that admits a primitiveunknownoperand 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 (
vused inx·vthen assigned5stayednumber, while assign-first gaveinteger) — 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.5thenv := 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'sFunctionliteral 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)\,dntherefore carried a bodyxbound to a discarded parameter while the bounds'xbound the engine's definition, and comparedisSamefalse againstce.box()of its own.json(and against the box route generally) with byte-equal MathJSON. The handler now passes the integration variables tocanonicalFunctionLiteralas the intended parameter list, so the body canonicalizes with exactly those parameters declared — the same binding structure the explicit-Functionroute produces. A knock-on repair: a parenthesized explicitFunctionintegrand (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
isSameexpressions — 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 enginece.box(J).isSame(ce.box(J))wasfalse(and a box and a parse of the same integral disagreed), breaking the documented contract thatisSameis 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. -
.jsonno longer reports a different operand order than the expression it serializes (Tycho item 178(d))..jsongoes 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(),isSameandhashall read, so re-sorting could only disagree with it. It did, becauseorder()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 ofm(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. Fourarithmeticsnapshots record the corrected order — in each,.jsonnow matches.ops(1.1 + 2 + 5 + 5/7 + 7/9 + √2 + πserialized√2before1.1while 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)).
ExactNumericValueaccepted an integer as either a machinenumberor abigintand kept whichever it was handed, butnumberToString()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) — andBoxedNumber.hashhashes that string. Soce.box(100000000000)and its own reparsed serialization wereisSamewith byte-equal MathJSON yet hashed differently, breaking the documentedisSame ⇒ equal hashcontract; any integer with more than five trailing zeros was affected,1000000included. The constructor now applies the same safe-integer normalizationreducedRational()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
forallquantifier was replaced by a trailingwhereclause. A generic signature states its type variables after the signature instead of before it:forall T. (T) -> Tis written(T) -> T where T,forall T: indexed_collection. (T) -> Tis(T) -> T where T: indexed_collection, andforall 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 meansany, sowhere Tis shorthand forwhere T: any. Semantics, solving and bounds are unchanged. The prefix spelling is removed, not deprecated.Consequences:
whereis now a reserved type name andforallis not:ce.declareType("where", …)is areserved-type-nameerror, andce.declareType("forall", …)now succeeds. A nominal type namedwheremust be renamed.- A type string beginning with
forallreports a targeted migration diagnostic rather than a cascading parse error. .toString()emits the trailing shorthand ((T) -> T where T, neverwhere 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
wherebinds 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 formIncludes
readonly/readwriteproperties with getter/setter implementations (person.name, andperson.name = vas rebinding sugar over the immutable value model); protocol constraints inwhereclauses (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 whenstringdoes); qualified property disambiguationperson.(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 aprotocol-implementation-duplicateerror; 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 | yellowtype 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 aliasunion. The sum's own name is usable in variant payloads without atypeforward-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 | Bover known types stays the opaque nominal,type alias X = A | Bstays 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 throwsprotocol-implementation-missingat 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,
matchconstructor 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 usestypeof/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 — sofunction foo(x: string, n: integer) { x }followed byfoo("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 suppliednote: `foo` is defined here--> example.epsil:1:10|1 | function foo(x: string, n: integer) { x }| ^^^= note: `epsil doc missing` explains this errorThe same notes reach
epsil check, the REPL, and — machine-readable, with the definition's location —--diagnostics jsonandcheck --json, where a diagnostic may now carry anotesarray. 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, alet/const's statement. A name inside a string or a comment is not a hover target.
Collections
-
isEnumerableCollectionnow answers for arithmetic broadcasts. A broadcast node (x + [1,2],Sin(1..99)) used to answerundefinedfor the whole class; it now answers from its participants:truewhen they agree on a length, evaluation is draw-free, and every collection-typed participant is itself enumerable;falsewhen a participant is definitively unwalkable — including an application of an unbound head (x + Total([1,2])withTotalundeclared);undefinedwhen participant lengths disagree or an impure participant (RandomShuffle(xs) + 1) makes per-index coherence unpromisable.trueis a delivery promise:each()andat()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)answeredundefined; since such a broadcast distributes structurally without consuming randomness,at()now serves the same unevaluated elementeach()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
elementCounthandler (companion tocanEnumerate) to report their length without evaluating.
Resolved Issues
Epsil
-
A static diagnostic no longer underlines a whole function definition.
epsil checkand 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 the23, and the message quotes the failing call. An ambiguous match (twoIndexOfcalls 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
Errorresult now propagates likeReturn: 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 * iwas 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..3was bound asnumber, so10 * itypedfinite_numberand 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)anda(t: real),a(0.3)stayed inert underevaluate()while compiled code correctly selected therealclause — 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 withRangeErrorwhen a concrete value was tested against it, from multi-clause dispatch and fromtypeAcceptsValue. 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
adeclared(number) -> numberbut 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))anda(u) + 1returned strings,2·a(u)andsin(a(u))returnedNaN, comparisons returned a wrong boolean,Iftook the wrong branch — all behindsuccess: 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 barea(u)on JavaScript still compiles (it returns the correct array today), open declarations (-> unknown) keep compiling element-wise, andboolean/broadcastable<T>big-op bodies are untouched. -
A
Sum/Productwhose body applies a list-valued user function now compiles correctly. Witha(t) ≔ [cos t, sin t]under an open(unknown) -> unknownsignature,Σ_i a(h(i))compiledsuccess: trueand 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 behindsuccess: truebut ran toNaN(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 readundefined:(s ≔ 0; s + 1)compiled toNaNwhere the interpreter answers 1, and a function body accumulating intosthrough a loop returnedundefinedbehindsuccess: 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 (
NaNbehindsuccess: true; the interpreter answered3√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) andcompileShader()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: PythoncompileFunctionno longer emitsdef f(x): return s = x, andcompileLambdacatches single-line statement bodies. Expression bodies are byte-identical on every route. -
A
Returnin a GPU function body now emits valid source or declines. A final-statementReturnemittedreturn return …and aReturninside a conditional emittedreturnin expression position — neither is GLSL or WGSL — both behindsuccess: true. Two gates now hold: a shape gate (aReturnwhose value shape disagrees with the function's signature declines, naming both shapes) and a placement scan on the finished body. EarlyReturns 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_ = 5behindsuccess: 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>andfunction size(b: bag) -> number, compiledsize(bag([1,2,3]))answered[42, 42, 42]where the interpreter answers42. Nominal values are atomic, like tuples: the whole value binds. Abroadcastable<bag>slot now also accepts abagargument 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
-> booleandeclarations over collection-constructing bodies now decline everywhere a condition or logical operand consumes them — anIfno longer takes a branch off array truthiness.
LaTeX serialization and round-tripping
-
Blockexpressions now round-trip. Four shapes changed value on a round trip and are fixed:- A single-statement block (
Block(s ≔ 2)) serialized as bares\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 vestigialNothingstatement a trailing;used to add. - A multi-statement block with no assignment (
Block(s+1, s+2)) serialized ass+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 aBlock. 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 aList; it now serializes as explicit\operatorname{Block}(x=1, y=2). - A
Blockused as an argument — of any function call, aLoopbody, or a comprehension body — emitted a bare;-statement list, which binds looser than the argument comma and theforclause, so the block swallowed what followed and the value changed. Block operands are now fenced.
- A single-statement block (
-
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 asi\in\lbrack1, 2\rbrack, which reparses asElement(i, Interval(1,2))("i is any real between 1 and 2"). The same conversion fired on both operands ofUnion,Intersection,SetMinusand theSubset/Supersetfamilies, 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 namedListhead is never overridden. Authored bracket spellings keep their interval meaning, including\mathopen\lbrack a,b\mathclose\rbrackand\mleft…\mright. Other list lengths are unchanged. -
Miscellaneous parsing fixes. An authored trailing
\mathrm{Nothing}statement survives parsing, a nestedBlock(Block(…))operand no longer defeats thecases-environment fence, andSymmetricDifferencereads and writes its operands with the same interval/list conventions as the other set operators.
Evaluation
-
countno longer invents a length for reshaping operators. The broadcast count read participants' lengths without checking that the operator actually broadcasts, soChunk([1,2,3], 2).countanswered 3 (true count: 2), and a dozen other reshaping operators (BinCounts,Histogram,Tally,Shape, …) reported their operand's length.Sort,OrderingandRandomShufflenow report their source's length (with zero draws),Chunk(xs, k)reportskfor a literalk, and operators whose length is not cheaply knowable answerundefinedinstead of a wrong number. -
A parenthesized juxtaposition on a collection-valued symbol is a product regardless of the argument's type. With
Aa list andBa list,A(B)andA(t-B)canonicalized to function applications of a non-function — evaluating toError("incompatible-type", …)— whileA(m)with scalarmcorrectly 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 toMultiplyand 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)cbuilt a node whose.jsonserialized flat (orderedabc) whileisSameandhashsaw the nesting, violating the documentedisSame ⇒ equal hashcontract; a negated rational coefficient with aPowerfactor hit the same problem..json,isSameandhashagree again. -
A
PointListbody 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 inertApply(Derivative("PointList", …), …)terms, because the differentiator applied the generic chain rule to thePointListOPERATOR. It now differentiates each component and keeps thePointListhead, sop'(0.25)is the point(0.9689…, -0.2474…, 1), matching what the same curve already gave when defined asg(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/Productfolds 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 behindsuccess: true: inF(i) = \sum_{m=1}^{i} mthe boundiresolved to the imaginary unit, whose real part is0, solower 1 > upper 0read as an empty range and the entire body folded to the identity —const _fn_F = (i) => 0, against an interpreted6. A parameter namedepicked up Euler's number and summed two terms (3). The same shape emittedfloat _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
Tupleis 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 theTupleoperator: withg(t) = (\cos 2\pi t, \sin 2\pi t, t), bothg'(t)andD(g(t), t)left inertApply(Derivative("Tuple", 0, 1, 0), …)nodes.Tuplenow takes the same elementwise branchListalready had, preserving the container head and nesting —d/dt (fₓ, f_y, f_z) = (fₓ', f_y', f_z'), sog'(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 becausef'stayed inert. Unregistered heads are unaffected:Pointtypesunknown, so it stays an opaque application rather than acquiring invented componentwise semantics. -
Equal/NotEqualagainst 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 — threwRangeError: Maximum call stack size exceeded, as didq(2) = [1, 2]withqdeclared(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 answeringFalse: an opaque operand may still be that collection, so a structural mismatch is not something the engine can claim. -
.countno longer outrunseach()on a broadcast over an unbound head. An undeclared call binds vacuously and its result type lifts over a collection argument, soTotal([1, 2])typeslist<unknown^2>and looked countable — but nothing produces those elements and the walk yields none.countanswered 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 reportsundefined, the same honestyAdd([1,2], [1,2,3])already gets. Broadcasts over declared operators are unchanged. -
QuartilesandInterquartileRangeno longer throw on thin or symbolic data.Quartiles(2.5)— a single inexact datum — threw aTypeError(median of an empty quartile half), as did any symbolic datum reaching the numeric path (Quartiles(z)for an unassignedz). 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)oncez := 3. A NaN literal datum still takes the documented absence path. -
ListFrom/SetFrom/TupleFromno longer wrap an unresolved collection operand as a scalar. For a valuelesslist-typed symbolxs,ListFrom(xs)answered["xs"]— a one-element list containing the symbol — andListFrom(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 reportisEnumerableCollection: falsewithout evaluating). Genuine scalars still contribute themselves:ListFrom(5, [1, 2])is[5, 1, 2]. -
Dot'sisEnumerableCollectionno longer misreports a matrix product. The result type ofDotis the widevalue, which the facet read as "not a collection" even whenDot(m1, m2)evaluates to a matrix.Dotnow answers per shape: a provably scalar inner product isfalse, a matrix-ish product is decided by its operands (Quartilesalso gained a decline-onlycanEnumeratenow that its symbolic case is inert). -
Stacked restrictions no longer depend on the order they were written.
When(When(e, c1), c2)merges toWhen(e, And(c1, c2)), but the merged conjunction was built without going through canonicalAnd, so it kept the authored operand order. Since.jsonserializes 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\}andx\{0<y\}\{|z|<5\}produced byte-identical MathJSON whileisSame()answeredfalseand their hashes differed,.jsonand.toString()of the SAME expression listed the conjuncts in opposite orders, andce.box(expr.json)was notisSametoexpr. Reported by Tycho as the 11differs-json-equalrows 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 itsevaluate()result) could be walked througheach()but not indexed throughat(), 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)answeredFalse(nowTrue), andSum(Take(Divisors(12), 3))stayed inert (now6).at()now has the same materialize-on-demand fallbackeach()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
canEnumeratehandler — the decline test theirevaluatehandler already starts with — soisEnumerableCollectionanswers without evaluating:Divisors(n)for a freenreportsfalse(wrappers over it stay inert instead of answering[]/False/0),Divisors(12)reportstrue, and an operator whose success is not cheaply decidable (Solve) staysundefinedand 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 theundefinedtier 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 | plusevery declaration was accepted and then every use failed:
lit <: expranswered false, so passing alitwhere anexprwas expected was anincompatible-typeerror. Recursive shapes failed at the first nested construction —total(node(1, [node(2, [])]))reportedexpected list<tree<finite_integer>>, got list<node<finite_integer>^1>even thoughnode<integer> <: tree<integer>held when asked directly.Three defects in the unfolding of a structural alias standing on the RIGHT of a subtype check:
isSubtypeshort-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 <: litheld whilelit <: exprdid not); an applied reference SNAPSHOTTED itsaliasflag while delegatingdefto the declaration record, so a forward reference captured inside a variant's payload kept the placeholder'salias: falseafter atype aliasfulfilled it; and the unfold compared against the alias's open body without substituting the application's arguments, sonode<integer> <: tree<integer>askednode<integer> <: leaf | node<T>and failed on the type variable.Nominal opacity is unchanged:
type X = A | Bstill declares a new opaque type that neitherAnorBinhabits — onlytype alias X = A | Bis a sum. -
A type whose body is
nothingnow has a nullary constructor, so a payload-free variant can be built.type leaf = nothingminted(nothing) -> leaf, which no call could satisfy:nothingis the unit type and its sole inhabitantNothingelides as an operand, soleaf()was a missing argument andleaf(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
nevereven 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 contributesneverand 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 theunknowndefault:plus(lit(5), lit(2))typedplus<unknown>and was then rejected by anexpr<number>parameter, whereplus<never>is accepted.
0.104.0 2026-08-11
Breaking Changes
- Types are now engine-global;
typestatements 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 atypestatement inside adoblock, function body,ifbranch or loop body is now a hard error —type-declaration-not-top-levelat parse time in Epsil, aninvalid-type-declarationerror value on the MathJSON["DeclareType"]route — instead of declaring a block-local type; there is no hoisting, and the formertype-shadowwarning 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 asunknown), 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. Seedocs/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 newparameter-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. AKeyValuePairarrow 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 newmapsto-arrow-expecteddiagnostic with a fixit on the arrow and recovers as the intended lambda, so the program still runs. It also no longer emits a spuriousunexpected-symbol ":"for typed parameters before a->. Legitimate dictionary spellings ({one -> 1},"a" -> 1,{->}) are untouched.
Issues Resolved
-
Dno 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 anyDwith 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}+1and re-parsed asD(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)+1still re-read asD((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.
-
.countanswers for an un-evaluated arithmetic broadcast (Tycho item 167).[1,2,3]+1reported no count even though its type saysvector<finite_integer^3>, and2(1..99)/99-1reported none even though theRangeinside it reports 99 — because the broadcasting arithmetic operators carry no collection handlers (broadcasting is a property of how they evaluate, not a collection operator), socounthad 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.countnow 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 areincompatible-dimensionsrather than a shorter result — so mismatched or unknown-length participants reportundefinedrather than a guess. A declaredcounthandler still owns its own answer, andisCollection/isFiniteCollectionare deliberately unchanged: this answers the length question only. -
toLatex()and.latexare 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 raisedCondition 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
CancellationErrorstill propagates: deadlines are installed only by an enclosingce.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 whatTadmits (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 astringand aboolean.Neither the admission path nor
isSubtypewas at fault; both were already correct (isSubtype(function, broadcastable<value>)isfalse). The gap was inprovablyDisjoint: abroadcastable<T>spans two category buckets (Tandindexed_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.provablyDisjointnow distributes overbroadcastable<T>exactly as over a union, using the sameT | indexed_collection<T>expansionisSubtypeuses.What
broadcastableis for is unaffected: scalar and collection arguments are admitted as before, andbroadcastable<value>still acceptsstringandbooleanbecausevaluedoes. -
Divideno longer drops the tuple type of aPointListnumerator (Tycho item 165).canonicalDividescales atuple / scalarcomponent-wise only when the numerator's components are ACCESSIBLE — aTuple/Pair/Triple/Singlehead. Any other tuple-TYPED numerator, notably thePointListhead importers emit, stays an inertDivide, and the type handler had no tuple branch, so that inert form collapsed tonumber.PointList(x, y) / ntypednumberwhilePointList(x, y) · nand(x, y) / nboth kept the tuple —Dividewas the sole outlier among the arithmetic operators (Multiply,Add,SubtractandNegatewere already correct).The collapse cascaded: a list of such quotients typed
vector<n>— a list of NUMBERS — soPointX/PointYover it took the element-INDEX reading instead of the elementwise one, and a mixedp + p/nfailed outright withincompatible-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
Multiplyhandler, which also stays inert on this input yet reports the tuple type. It is type-only: no canonical form changes, so thePointListhead — which carries a consumer compile contract — is preserved, and the accessor narrowing that surfaced the bug is untouched (PointXover a genuinely flat numeric list remains the element-index read).
Improvements
-
Declared-type mismatches on
let/constare 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 inDeclare's evaluate path — solet s: string = 42produced 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-missconst 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 + 1used to fail with a bareincompatible-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 hostce.declare/ce.assignthrow and theAssign/Declareerror-value routes) now spells out the near-miss: a signature's parameters bind only when they are named, so nothing in(number) -> numberbinds andx^2 + 1is an expression in the unknownx, not a function ofx— 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) areundefinedin 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), orundefinedfor the one undecidable case — an eager collection operator such asCharacters(s), which has no collection handlers until it is evaluated. Custom collection operators declare it with the optionalisEnumerablecollection 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, andLength(Filter(Take(xs, 2), p))0— answers a laterxs := [1, 5]contradicts. All of them now stay inert, asLength,Total,SortandMapalways 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-sourceMapnow 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 valuelesss) 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 inROADMAP.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,953parseType()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 thetype Xforward-reference spelling, which the parser now tracks viasawForwardRef, 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/PointZto(collection | tuple) -> anyrejectedAt's optional result — an in-range unprovable access typesmissing | tuple<…>, so everyS[n].xchain erroredincompatible-typeat canonicalization. The accessors (andFirst/Second/Third/LastandDistance, which had the same composition failure) now declare amissingBehavior, admitting themissingarm 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 isNaN(numeric-slot marker), an absent element access propagatesMissing(mirroringAt), andDistanceof an absent point isNaN.
Improvements
-
Wildcard dimensions no longer display as negative lengths: a list type with open (sentinel
-1) dimensions and a non-numeric element printed aslist<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 canonicalizingconsole line printed a bareTimeout exceeded, indistinguishable from an engine-imposed deadline (no such deadline exists — deadlines are only ever installed by an enclosingce.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
.typeread and is keyed onBoxedTypeidentity — and type inference, which rewrote an already-inferred symbol's type with a freshly allocatedBoxedTypeon 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
_writeVersionbump on value definitions and the engine-wide_semanticVersion/_worldVersionbumps on operator definitions; ce.type()now interns primitive type names per engine, soce.type('number')is identity-stable.
The box-microloop canary in
benchmarks/effects-registration.tsreturns to its 0.102.0 baseline (0.0091 vs 0.0105–0.0108 ms/iter regressed). - primitive types bypass the display-projection cache entirely (a primitive
carries no
0.103.2 2026-08-10
Breaking Changes
-
The prose ellipsis no longer binds inside an inline
/.1/2...5isRange(1/2, 5), where it wasDivide(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 on1+8/d, while[1+8/d...5]anchored ondalone and produced1 + 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]withd := 500is a 500-elementRange, where it was a 2-elementList— 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 byx, 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 thans1 - s0, so it readsxinstead ofm - 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-nis 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>withL(a) := a + 1stored 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, soL([3, 4])ran[3,4] + 1and returned the string"3,41"behindsuccess: true— including underrealOnly: true, which promises a number.L(a) := |a|degraded toNaNthe same way, making the declared spelling worse than the undeclared one.Two knock-on gains:
Length(a),Map(a, …)andSum(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 thevector<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/.zspelling) now infers as non-scalar, so a list argument is applied instead of broadcast.g(a) := PointX(a)answered[g(3), g(4)]forg([3,4])— per-element nonsense that compiled to[null, null]behindsuccess: true— where it now answers3. 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.Distancewas narrowed the same way, and for the same reason, in 0.100.1.Normis deliberately unchanged:Norm(-5)is5, 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" whilePointX((3, 4))compiled tovec2(3.0, 4.0).x— the GPU lowering read every indexed collection as a list of points, so the flatlist<number>spelling a data import produces could not reach a shader. A numeric list of width 2–4 IS avecNthere, 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 avecNlowers 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 coordinate3but typedvector<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 (aSetof points) had the mirror-image defect, typing an element access while returning a list. -
An indexed
Sum/Productno longer discards its indexing set when the body is a collection.\sum_{k=0}^{2}[k, 2]canonicalized toReduce([k, 2], Add, 0)and answeredk + 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 still6); 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/Productbody 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"behindsuccess: true— including underrealOnly: true, whose overload is typed to return anumber.Add/Multiplyreject 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 andunknownbodies are unaffected;booleanbodies keep compiling (the counting idiom\sum_k (x_k > 0), where+over booleans is numerically faithful), as dobroadcastable<T>bodies, which are routinely scalar at run time. -
A
Rangestep built over an assigned symbol no longer freezes its value.[2a, 3a...9a]witha := 2canonicalized toRange(2a, 9a, 2)— the step folded to a literal while the bounds stayed symbolic. Re-assigninga := 3then produced an 11-element range of spacing 2 instead of the 8-element one of spacing 3: the start moved withaand 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.008must become0.008, not0.008000000000000007); it now folds only when every symbol in the step is a CONSTANT.Pi/4folds as before, and a step over a document variable stays unevaluated and re-reads its binding per use (Rangehas supported symbolic bounds and steps since 0.100.0). -
A nested
Mapthat closes over the enclosing lambda's parameter now substitutes it.Min(Map([1,2], k ↦ Max(Map([1,3], j ↦ j·k))))evaluated toMin(Max(k, 3k), Max(k, 3k))— the inner map reduced, butkstayed free — where it is3; with a+xin the outer body the result went non-finite instead of3+x.The drain-time
Mapfusion bypassesmakeLambdaand 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:makeLambdapushes 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))reportsfinite_integer— exactly what1·3 + 2·4reports — where it previously reportednumberfor every provably numeric pair. The component types carry through: real components givefinite_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.
numberincludes complex, so a point inner product was rejected by everyreal-declared slot in the library:Hypot(Dot(p, p), Dot(q, q))— real by construction — reportedincompatible-type('real', 'number'), as didDegrees,HaversineandPrimePiover the same operand. Those compose now, and a genuinely complex inner product is still refused there, loudly.Nothing else about
Dotmoves: an operand whose components cannot be read (a symbol declaredtuple<number, number>orvector<3>) keeps the widenumber; a tuple that is not provably a fixed numeric point keepsvalue(the rule against claiming a numeric result on retractable evidence); unequal fixed lengths still reportincompatible-dimensions; and the matrix product is stillvalue. Update any pin asserting the literal stringnumberon a fixed numericDot. -
A norm and a distance are now typed as the reals they are.
Norm((3, 4)),Abs((3, 4)),Norm([3, 4])andDistance((0, 0), (3, 4))reportfinite_realwhere they reportednumber, andrealwhen a component's finiteness is not provable. A norm is√(Σ|xᵢ|²), which is real whatever the components are —‖(3+4i, 0)‖is5— so thenumberclaim (which admits complex) was refused by the samereal-declared slots as theDotclaim above:Hypot(‖p‖, ‖q‖)did not typecheck.The demotions follow the convention
Absalready uses for the same question one operand down: a provably NaN component givesnumber(only a literal proves NaN, so a merely-unknown component does not demote), and so does a provably non-finite one. No narrower tier thanfinite_realis 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 tolist<number>,Distanceover an undecidable operand still reportsnumber | list<number>, and an operand with no readable components (a symbol declaredtuple<number, number>, a matrix, whose elements are rows) keeps the widenumber.
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 aggregatevec3value 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 avec3standing in a slot from one consumed by an enclosingdot(). 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 —Hypotover two point operands, emittinglength(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.
FilterandTakeWhileanswered an empty collection,FindansweredNothing,IndexWhere0,AnyFalseandAllTruewhen 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: withxsdeclaredlist<integer>and unassigned,Any(xs, x ↦ x > 2)answeredFalse, and assigningxs := [1, 5]afterwards makes the same expressionTrue. 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 wayLength,Total,Sort,Map,CountIfandPositionalways 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 inROADMAP.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]andAny(xs, 5)carried a constant thunk, while the identicalSort(xs, 5)andCountIf(xs, 5)reportedincompatible-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)withk := 5). What afunctionslot 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(wascortex), 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 areparseEpsil(),serializeEpsil()andexecuteEpsil()(with the correspondingExecuteEpsilOptions/ExecuteEpsilResulttypes). The npm package name and@cortex-jsscope are unchanged. There are no compatibility aliases: update imports and scripts to the new names. A./clipackage export exposes the CLI entry point so the standaloneepsillauncher package (and other tools) can forward to it. -
The
structural: trueboolean is no longer part ofce.function()'s typed signature — use{ form: 'structural' }. Theformoption has been the documented spelling for the creation modes since the structural tier was introduced, andce.expr(),ce.box()andce.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 againstComputeEngine/IComputeEngineis 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=meansAssignwhen 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), andEqualin 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 doif 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, soif x = 5 && ygroups 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 = 4on its own line is the equation, not an assignment to a power. Anda = b = 5would assignathe booleanb == 5, which is never what a chained assignment means, so it reportschained-assignment; writea := b := 5to chain, ora = (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 reportsassign-in-condition, a warning (the spelling is deliberate). It fires only where a value is consumed as a boolean, not forf(a := 1).The
assign-in-argumentdiagnostic 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
-
Dotaccepts numericTuple/PointListoperands.Dot((1, 2), (3, 4))— and the equivalentPointListspelling — is now valid, typednumber, and evaluates to the inner product, matching the already-supportedListvectors; mixedtuple · listproducts work too.Dotis the sanctioned representation of a point inner product (tuple · tuplehas no implicit product and remains an error), so the explicit operator now accepts the operands the implicit one rejects. Point operands lower to the nativedot()builtin on the GLSL/WGSL compile targets (with the usual operand-shape fail-closed guards: matching widths,vec2–vec4only) and tonp.doton Python. Unequal fixed lengths reportincompatible-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 thenofapply2(n |-> n + 1, 3)asnumber), and when the callback ofMaporFilteris 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))withpoints: 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 spelledcallback<(T) -> …>rather than as a plain generic arrow(T) -> …. Thecallback<…>spelling is what asks for the inference, and it is also the permissive one: the slot still admits any function, exactly as a barefunctionparameter 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))infersx: 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/Filteryou 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 inferredlist<integer>becomes floats), re-evaluating the retained expression reports a per-elementincompatible-typeerror — 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/NotEqualwith 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.neqdispatch, whose scalar leaf gained a string branch, soEqual(["a","b"], ["a","b"])isTrueandEqual(["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 ioverCharacters(text)) compile end to end. -
JavaScript target:
Characters,GraphemeClustersandStringJoinnow compile.Characterssegments UAX #29 grapheme clusters through the sameIntl.Segmenterthe interpreter uses, so a combining sequence, a ZWJ emoji or a flag is one element — matching interpretation element for element (neither[...s]norsplit('')is faithful; both are pinned as counter-examples).StringJoincompiles 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 andCharacters/StringJoinresults 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 declaredbroadcastable<T>(e.g.ce.declare('f', '(broadcastable<number>) -> unknown'), or an Epsilfunction f(x: broadcastable<number>) { … }) maps an indexed-collection argument element-wise — even whenTwould 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.Tis 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 giveslist<R>, a possibly-collection argumentbroadcastable<R>, a scalarR— 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
sourceOffsetsmetadata 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.canonicalgetters (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 viametadata: ['sourceOffsets']), and interned singletons are never stamped. - Debug statement hooks.
src/common/debug-hook.tsexposes a synchronous, module-global pre-statement hook and post-statement result hook, fired by the statement sequencer (Blockbodies, lambda bodies,ifbranches) 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), soce.traceand debuggers can delimit activation frames.
- Source positions survive canonicalization. The
-
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 destructuringlet's — at least two elements, each a bare symbol, a_skipping that position, or a nested tuple pattern — and a shape mismatch is the sameincompatible-typeerror 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 assigningbto 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 aconstfails. 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 destructuringletgained the same guarantee, which it did not previously have. Lowers to theAssignprimitive with aTuplepattern in the target position, held raw (canonicalizing it would fold a single-letter target such asiinto 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)becomeslet _tv1 = b; let _tv2 = a + b; a = _tv1; b = _tv2— which is what keeps the compiled form honest: the targets already exist, so the naivea = b; b = awould read theait 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 destructuringletin 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 behindsuccess: 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 reportsdestructuring-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 nominaltypedeclaration now takes the same type-parameter clause a generic alias takes, in Cortex as above and from the host withce.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) orinout(invariant) — saying how two applications relate: underout, atree<integer>is usable where atree<number>is expected. No marker meansout, 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 avariance-violationat 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.inoutverifies against any definition. Variance and bounds do not interact.A
tupledefinition mints a quantified constructor (tree: forall T. (T, list<tree<T>>) -> tree<T>), sotree(1, [])solvesT = finite_integerfrom its arguments; arecorddefinition 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 — witht: tree<number>,t.valueis anumber.matchis a binding of values, not a projection of the annotation: each capture takes the matched value's own type, usually narrower — on atbuilt astree(1, []),match t { tree(v, cs) => … }bindsv: integerandcs: list<never>, notnumberandlist<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 explicitlyinout/inparameterized 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 | missingandforall 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 bindsnever, the narrowest member of the family. At most one arm of a union may mention a variable (T | Uis unsolvable by construction). Intersections and negations remain rejected wherever the declaration mints a constructor — the minted signature is what is checked, so arecordbody or amint: falsedeclaration goes unchecked — and the intersection diagnostic now steers to the spelling that replaces it, a bound (forall T: number.). -
A Cortex
typestatement 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 asbox<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 ageneric-alias-arityerror, and one that makes a dependent's declared variance unsound is avariance-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 hostce.declareType()API still throws, unchanged. -
Cortex:
breakandcontinue. They leave, or skip to the next iteration of, the innermost enclosingwhile/forloop, and lower to the engine's existingBreak()/Continue()primitives. Valid anywhere in a loop body, including inside anif, amatchcase, or adoblock. The loop context resets at every function and lambda boundary — abreakwritten inside a lambda defined in a loop body does not target that loop, and is acontrol-outside-loopdiagnostic — because the engine'sBlockshort-circuits onBreak/Continuestructurally, so a laxer rule would permit non-local control flow. Value-carryingbreak valueremains unspelled, pending the ruling on a generalreturn. -
Cortex:
??for absence coalescing.a ?? bisCoalesce(a, b): the value ofaunless it is absent (MissingorNaN). It discharges absence; it does not rescue anError. Right-associative, at precedence 18 — looser than|>soxs |> f ?? 0defaults the pipeline's result, tighter than|->sox |-> x.a ?? 0defaults inside the body. -
Cortex:
isfor dynamic type tests.x is integerlowers toElement(x, integer)— the same test amatchtype 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>) reportstype-pattern-unsupported, as the equivalent typed pattern already does.isis a contextual word —let is = 5stays legal.
Improvements
-
Signature(op)works on thece.boxandce.parseroutes. The operator holds its operand and had nocanonicalhandler, so the name arrived unbound andSignatureansweredNothingfor every operator unless the caller went throughce.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,IndexWhereandPositionall 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 > bit annotatedaand leftbbare. 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.jsonregenerated, picking up the contextualcallback<S>signature strings, the variadicAppend, and three operators missing from it entirely (Adjoin,IdenticallyEqual,QuotientRing). Two defects in the generator are fixed along the way: aforall-quantified signature reported its arity as"unknown", and the arity count split on every comma — including those inside a parameter's own type, which madeFold4-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 itsErrorTracebreadcrumb, rendered as(while applying 'skipWs' element-wise over 4 elements (element 2)); an error thrown out of an element (a non-booleanifcondition, 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 Epsilruntime-errordiagnostics 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)— Epsilc 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 evidenceLength(cs)andcs[i]already contribute. The evidence lands on the parameter's binding only: the function's arrow still reports a scalar parameter slot asunknown, 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-valuedxisFalse, not a type error), and a Solve domain spec such asElement(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) -> …, andisDigitshows(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, solabel(6) = 1was accepted whilelabel(6)was an error.Relatedly, assigning to a literal word is no longer silently accepted:
true = 5andNaN = 1now reportreserved-wordlike every other binding position.
Resolved Issues
-
tuple · tupleandx / tuplenow 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 atuple<broadcastable<number>, …>operand was accepted (typednumber) and the identicalincompatible-typerejection 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 suggestsDot— the operator that does accept points — instead of only reporting that a tuple is not a number. (scalar + tuplewith unproven elements deliberately stays symbolic with an honest union type: per the retractable-evidence ruling,Addbakes its error only when both the tuple and the scalar are proven.) -
A seedless
Reducenow seeds with the collection's first element. Without an initial value,Reducefolded from theNothingsentinel, 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 correspondingScanends at-4, and a reducer that does not splice leaked the sentinel into the result (Reduce([2, 3, 2], Power)produced aNothing-valued power instead of64). The seedless fold now starts at the first element and folds from the second, matchingScanand the compiled fast path; an empty seedless fold still answersNothing. Folds given an explicit initial value are unchanged. -
AnyandAllnow 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, andAnycould even short-circuitTruestraight 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,Totaland other finite-only operations now work overFlatMap.FlatMapnever 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
Reducewith an annotated element parameter reject its ownNothingseed withincompatible-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
Falsewhen the underlying membership is undecidable. Nine collectioncontainshandlers (Filter,Join,Append,Reverse,RotateLeft,RotateRight,Cycle,CartesianProduct,PowerSet) collapsed an undecided sub-query intofalse— soElement((1, x), {1, 2} × ys)withysa symbolic set evaluated to"False"instead of staying symbolic. All nine are now three-valued: a definite refutation still answersFalseimmediately, and only a genuinely undecidable membership is left undecided. In the same pass, aFilterwhose 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 byTakeWhile/DropWhile(an Error treated as an ordinary "stop"), or be laundered intoNaNby a fusedMappipeline doing arithmetic on the Error value. All three paths now surface the element's ownincompatible-typeerror: 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 > 0compiled to a plain function with the annotation dropped, so compiledFilter/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, includingReduce/ScanandTabulate/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 typedboolean | indexed_collection | number | string(a gather index may be a collection, a dictionary key a string), which permanently widened the local, and thestringarm 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), anumber | stringparticipant,Less("a", [1, 2])) still fail closed. -
An else-less
ifstatement in a function body compiles (JavaScript target).if cs[j] == "-" { sign = -1; j = j + 1 }in statement position lowers to a bareif (…) { … }instead of failing withIf: wrong number of arguments. Loop bodies already handled this; a plain block's statements did not. An else-lessifin 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 csstayed untyped, so the lambda auto-broadcast mapped the function over the argument's elements instead of binding the collection whole: a function that accumulatedt = t + coverfor c in csand was applied to[1, 2, 3]returned[0, 0, 0]instead of6, and its inferred signature read(unknown) -> …. The iterated operand of aLoop/ComprehensionElementclause now narrows an as-yet-untyped symbol tocollection, matching whatLength(cs)andcs[i]already did. Scalar parameters are unaffected —f(x) = x * 2still broadcasts over a list. -
A function forward-declared with the bare
functionwildcard (ce.declare('clean', 'function')) now participates in parameter collection-inference. Previously a caller such asproc(cs) = clean(cs) + 1learned nothing fromcleanin 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])answers11instead 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:
EqualandNotEquallower to a tolerance test (Math.abs(a - b) <= tol), which for string operands isNaN <= tol— sos == "a"compiled tofalsebehindsuccess: true, andIndexOfover 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,falsecompiled): an all-string comparison compares strings exactly as the interpreter does (raw code-unit order) and keeps compiling, with parity pinned.matchon 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 runtimeTypeErrorrather than a silent value, and itsIndexOfis 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.bcastbefore any gate ran, and whole-array equality (Equal(["a","b"], ["a","b"])) compiled tofalsebecause_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 typedbroadcastable<string>orlist<string>in an ordering, and — via the same walk — whole-value equality overdictionary/record/tuple-typed symbols (whose element types reachstring) 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 < 9is False), and equality with atupleparticipant, where the tuple is looked inside instead of binding atomically (Equal(Tuple(1, 2), List(1, 2))ran toTrueagainst the interpreter'sFalse). Both now decline (D6) and the interpreter fallback answers correctly, as dodictionary/recordcomparisons (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 scalarFalsewith aFutureWarning, and emitted code runs on the user's NumPy. As on the JavaScript target, anunknown-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 —IndexOfin 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 to1on both the JavaScript and Python targets, where the interpreter's structural element test answers0(and symmetrically,IndexOf([True], 1)ran to1): Python'sTrue == 1isTrue, andMath.abs(true - 1)is0. Both targets now compare bool-ness first — Python through a new_ce_indexof/_ce_sameruntime adapter, JavaScript through a strict===in the emitted element test. This is a runtime adapter, not a compile-time gate, so booleanIndexOfkeeps compiling instead of declining. Both element tests also dropped their numeric TOLERANCE leaf: the interpreter's number.isSame()is exact, soIndexOf([0], 5e-11)andIndexOf([0.30000000000000004], 0.3)answer0, where the compiled forms answered1. (The tolerance leaf came from a probe artifact —IndexOf([0.3], Add(0.1, 0.2))agrees only becauseAdd(0.1, 0.2)evaluates to exactly0.3by 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 aNaNneedle, as the interpreter's structural element test does:IndexOf([NaN, 3], NaN)ran to0on both targets (nan == nanisFalsein Python, andMath.abs(NaN - NaN) <= tolisfalse) and now answers1. Otherwise unchanged: numbers still match acrossint/float, a tuple needle is still found in a point list, and a missing needle is still0. On the Python target annp.ndarrayon 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", …)forLess([1, 2, 3], [1, 2])and forLess([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 asuccess: true. The ordering ufuncs are now emitted through a_ce_ordruntime 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; anIndexOfbody, a collection/tupleEqualbody, or a collection ordering produced a lambda referencing_ce_indexof/_ce_eqcoll/_ce_ord, which a bare lambda has no place to define — aNameErrorat call time. All four now fail closed with a message pointing atcompileFunction. -
A destructuring
Declarewith a positional initial value now binds (tuple patterns).["Declare", ["Tuple", "x", "y"], "unknown", ["Tuple", 3, 4]]— the positional-value spelling theDeclarecontract 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 anincompatible-typeerror 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 declaredintegerused to writexand then fail ony, 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) = 2xapplied 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
ifbranch, awhilebody —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 deliberatelyany(Length(5)stays symbolic); it now treats a not-yet-typed symbol operand as collection evidence, like an indexed read does.
- a parameter referenced only from a nested block scope (an
-
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) + 1beforeclean(v: list<number>) = v[1]) andprocesswas canonicalized whilecleanwas still unknown:cslearned nothing, soprocess([10, 20, 30])broadcast elementwise, handingcleanone number at a time — each call then failed against the declaredlist<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 beforeais defined re-reads as an application whenaarrives): 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 answers11. 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, soA → B → Cwritten in that order all bind correctly onceCarrives — 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
whileloop 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/Notaccept a possibly-absent condition, Kleene-style. A comparison on an indexed read —cs[j] == "a", honestly typedboolean | missingsince the index may be out of range — was rejected at canonicalization by the logic operators'booleanparameters, so the guarded loop conditionj <= Length(cs) && cs[j] == "a"errored withincompatible-type. The three operators now declare thehandlemissing-value behavior and evaluate Kleene over absence:FalsedominatesAnd,TruedominatesOr,Not(Missing)isMissing, and a surviving absent condition still surfaces throughIf's absent-condition error. -
Epsil: a pinned
matchcase after a result line is a new case. The case body1 => "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 targetedmatch-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) reportsconditional-if-line-startinstead of the misleadingopening bracket expected. -
A shader loop body no longer emits a
returninside the loop. On the GLSL and WGSL targets, aforbody with more than one statement compiled as a value — its last statement becamereturn <statement>, so the shader returned on the first iteration while reportingsuccess: 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}atz=iise^{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 atk = 100returned the symbolkinstead of100, while the same body without the branch blocks — or a plaindo { k }— returned100.captureClosuresrebinds 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 —Ifbranches are the common case, and Cortex compiles everyifbranch to a block, so anyifinside an escaping lambda was affected, including one drained later from a lazyMap. -
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 symbolkrather than the argument — while the same function with a barekreturned it correctly.evaluateBlocksweeps 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 aDeclarestatement", which is what the sweep meant to ask; genuine block-locals still survive, including across a re-entered block. Cortex wraps eachifbranch in a block, which is why the conditional shape surfaced it. -
A lazy
Mapreturned 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, bypassingmakeLambdaand 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 —captureClosuresrebinds 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 momentfwas annotated, so a recursivemap(f, t.children)over a tree went inert while the identical function with a barefworked. 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) -> Bparameter 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. Withtype tree = tuple<value: any, children: list<tree>>, the expressiont.children[1].valueproduced that error rather than the field's value. Joining the element types of alist<tree>reachesunionTypes, whose de-duplication key wasJSON.stringify(type)— and a recursive type reference reaches itself through its resolveddef. The key now omitsdef, which is both cycle-safe and lossless: a reference is identified by its name, anddefis 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— producedMaximum call stack size exceededinstead of anincompatible-typeerror.hasValueComponentunfolded structural alias references with no cycle guard, unlikeisSubtype, 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 byce.declareType("json_array", "list<json>")— failed withThe 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 Cortextypestatement 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.isScalarTypedid 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,\prodand\frac{d}{dx}. The body of those operators was never simplified, so\int(\sin^2x+\cos^2x)dxstayed 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.
-
Coalesceno 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 formCoalesce(a, Coalesce(b, c))and the flatCoalesce(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, andExp(x)cost 10 versus 1 — yet both canonicalize to aPower. Sincesimplify()'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 asPower, 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 - cis nearly twice as complicated as-(a + b + c)when it reads more simply — and madeSubtract(a, b)cost less than theAdd(a, Negate(b))it canonicalizes to. One visible consequence:\int\sqrt{x^2-1}dxnow 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
10xcost 4 while11xcost 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:11xcosts 5,1000xcosts 7.2xstill costs less thanx + x, which is what the discount exists for. One visible consequence:\sum n^2returns\frac16(2b^3+3b^2+b)rather than\frac13 b^3+\frac12 b^2+\frac16 b, matching the shape\sum nalready 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 asx^{20}and barely abovex^2. The base is now counted.2q^2is still cheaper than the repeated multiplication it replaces, which is what that rule exists for. One visible consequence:(\sqrt2+\sqrt3)^2now reaches its closed form5+2\sqrt6instead of staying unexpanded.Two long-standing shortcuts came out with it. A negated power was priced without its base, so
-\sin^2xscored 4 where\sin^2xscored 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 ypast 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\sqrt3and\sqrt{17}all scored the same as the plain decimal0.5— the radical was invisible to every comparison. An exact value is now priced asrational × \sqrt{radical}with the radical counted, calibrated so a radical literal costs about what the equivalent expression costs (\sqrt3and\sqrt yboth 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}dxreturns\frac{2\sqrt3}{3}\arctan\left(\frac{\sqrt3}{3}(2x+1)\right)rather than distributing the\sqrt3over 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.
Integratewas priced like any unrecognized operator, but an antiderivative can be much larger than its integrand —\int\sec^3x\,dxscored 49 against a closed form of 78, and\int\frac{1}{x^4+1}dx62 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 ascopeoption 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 walksscope → parentsand every auto-declare and inference lands rooted there — but the option was missing fromIComputeEngine.parse(), which is the type the publicComputeEngineresolves to. Passing it was therefore a compile error (TS2769) even though it worked at runtime.ce.expr(),ce.box(),ce.function()andce._fn()all already declared it;parse()was the lone omission. No runtime change. -
Pochhammer(),Degrees()andDMS()no longer numericize an exact irrational argument. Found by auditing for theBeta()bug below, which turned out to be one instance of a small class.(\sqrt2)_2returned3.41421356…instead of the exact2 + \sqrt2, and\mathrm{Degrees}(\sqrt2)returned0.0246826…instead of\sqrt2\pi/180. Two different causes, one symptom:Pochhammerbuilt its rising-factorial terms with the.add()method, which folds two exact literals to a machine float (the same slip asBeta, and its own symbolic branch alongside already did it correctly);Degreesfell back toce.number(arg.re)for a non-rational argument, and.reis 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/Juliaare deliberately excluded (a float is the answer for an escape-time sampler), as areRational/Rationalize(they exist to turn a float into an exact value). -
Beta()no longer numericizes an exact irrational argument.\mathrm{B}(\sqrt2, 2)evaluated to0.2928932188…instead of the exact1/(\sqrt2(1+\sqrt2)), breaking the contract thatevaluate()returns the most exact form and onlyN()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 + 1collapsed 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
Mapover itself no longer overflows the stack. Assigningxsthe valueMap(xs, f)— the shape an accumulator loop produces — made any later query throw a rawRangeError: Maximum call stack size exceededout ofevaluate(), rather than being absorbed as anErrorvalue; a host callingevaluate()directly saw a hard crash. The self-referential-binding guard was in place and firing (xs.valuereadsundefined, as designed), butMapis the one lazy collection operator that answersisFinite/isEmptyfrom its source rather than its own iterator, and that path reached the stored value throughevaluate()instead of the guardedvalue. 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, puttingMapon the same symbolic residual thatFilterand 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,
AdjoinandQuotientRing, 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}_nand\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 aset<finite_real>,\mathbb{Z}[i]aset<finite_complex>,\mathbb{Z}_naset<finite_integer>. Note that\mathbb{Z}_pis read as the integers modulop— 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 namePositiveIntegers,NegativeNumbersandNonNegativeIntegers. -
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/\geqslantforms. -
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 ofBlocks, so the conditional introduces no scope and no statement can appear in a branch. Theelseis mandatory (it is what ends the condition; a missing branch would leave the false case with no value to name), and1 if creports the newconditional-else-expecteddiagnostic — use the block formif 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 noelse ifspelling 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 + 3reads as1 if c else (2 + 3). One layout rule: theifmust be on the same line as the value before it — a line break separates statements, so anifthat starts a line always begins a newif-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:
Ifnow serializes asif, not as a function call. Theif-expression syntax has always parsed, but the serializer had no rule to emit it, so a program writtenif c { 1 } else { 2 }came back from the formatter asIf(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:Blockbranches give the block form (chaining toelse ifwhen the alternative is itself a block-formIf), plain expression branches give the conditional forma if c else b. A shape with neither spelling — mixed branches, or anIfwith noelseand a non-Blockconsequent — keeps the genericIf(c, …)call form, which also re-parses faithfully. AnIfin operand position is parenthesized according to the conditional's precedence, soAdd(If(c, 1, 2), 3)serializes(1 if c else 2) + 3rather than the differently-parsing1 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}dxreturns\frac12(x\sqrt{1-x^2} + \arcsin x);\int e^x\sin x\,dxreturns\frac12(\sin x - \cos x)e^x; solvingx^2 - 2x\cos t + 1 = 0fortreturns\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 combinationsx^n·x^m → x^{n+m},x·x^n → x^{n+1}andx^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^yand its\log_csibling);\log_c(x^n) → n·\log_c(x); the geometric-series and shifted/falling-factorial closed forms forSumandProduct; 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-suppliedcostFunction. 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.engineandExpressionComputeEngineare now one interchangeable type — no more casts between them. TheComputeEngineexported from the package (and from the/coresub-path) is now a constructor value paired with the structuralIComputeEngineinterface, rather than the class itself, whose private fields made its type nominal. An engine obtained fromexpr.engine(typedExpressionComputeEngine) can now be assigned or passed wherever aComputeEngineis expected, and vice versa.new ComputeEngine(),instanceof ComputeEngine,InstanceType<typeof ComputeEngine>and the staticComputeEngine.getStandardLibrary()all work as before (the constructor's type is the newComputeEngineConstructorinterface). The interface also gained members that were previously only on the class:Two,toJSON(),suggestOperatorName()andfunctionProperties(). One typed-surface consequence: the legacycanonical/structuraloptions ofce.expr()andce.box()are not part of the interface — use the equivalentformoption ({ canonical: false }→{ form: 'raw' },{ structural: true }→{ form: 'structural' }); the legacy options still work at runtime. TheExpressionComputeEnginetype is now deprecated: it is interchangeable withComputeEngine, which should be used instead.
Resolved Issues
-
Serialization no longer writes to the current scope.
toLatex()andtoMathJson()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-callscopeleaked 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 producedaccumulator +/someLongVariableName + x + y +, which reportsunexpected-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 newifblock 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. Anelse ifchain is flattened first, so every clause stays at the same column instead of nesting a level deeper perelse 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}_nparsed as["At", "Integers", "n"]— indexing into a set — which is not a valid type, and serialized back to\Z[n], which parsed as anincompatible-typeerror, 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
NorDused 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 firstNofN, 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 asN, N+1, N+2or(DB+BC)^2 = AD^2+AC^2therefore 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)andD(x^2, x)still apply the builtin operators,x^2 |> Dstill 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
x2serialized asx_2and parsed back as the different symbolx_2(andArctan2as\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_2still serializes asx_2). This changes the rendering of digit-suffixed names: they display upright asx2rather than asx₂; writex_2for 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 symbolpiserialized as\piand parsed back asPi,zetaas\zeta→Zeta,phiLetteras\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 symbolgammastill serializes as\gamma— that command reads back asEulerGammaonly in an engine wheregammais undeclared, i.e. never in an engine holding the symbol. -
The imaginary unit now has a single canonical spelling:
["Complex", 0, 1]. A bareiparsed to the complex literal["Complex", 0, 1], but\imaginaryI(and\mathrm{i},\operatorname{i}, and the MathJSON symbol"ImaginaryUnit") canonicalized to the symbolImaginaryUnit. Since the serializer emits\imaginaryIfor both,ce.parse('i')did not round-trip, and two structurally identical expressions could compare unequal withisSame(). TheImaginaryUnitdefinition has always been declaredholdUntil: '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, soce.parse('i'),ce.parse('\imaginaryI')andce.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 theisImaginaryUnit()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 howireached it:["Power", "ImaginaryUnit", 2]stayedi^2(andce.parse('i^2').simplify()did not reduce) while["Power", ["Complex", 0, 1], 2]folded to-1— soce.box(expr.json)did not round-trip toexpr. Small integer powers ofinow fold on every route, and\sqrt{-1},(-1)^{1/2}and\frac{a}{i}canonicalize toi,iand-iarespectively. -
A product of an infinity and the imaginary unit no longer collapses to
NaNat canonicalization.["Multiply", "PositiveInfinity", ["Complex", 0, 1]]canonicalized toNaNwhile the other operand order stayed symbolic: then·ipromotion (which folds2·ito the exact2i) 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 returnsNaNfor the indeterminate form. -
Degrees()of a non-real argument no longer drops the imaginary part.\imaginaryI\degreecanonicalized to0(and(2+3i)\degreeto\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
Multiplyis now always flat. A product built by the invisible (juxtaposition) operator could keep a nestedMultiplyoperand — for examplece.parse('2f(ab)')canonicalized toMultiply(2, f, Multiply(a, b))instead ofMultiply(2, a, b, f)— which broke the associativity contract and made two structurally identical products compare unequal withisSame(). Note the MathJSON serializer flattens on output, soexpr.jsonprinted the same["Multiply", 2, "a", "b", "f"]either way; onlyexpr.opsshowed the difference. As a consequence exact numeric factors separated by parentheses now fold as they should:2(3x)canonicalizes to6x. One visible knock-on: two closed forms returned bySum—(b+1)(a+bd/2)and its sibling — now come back fromsimplify()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 undersimplify()'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\vertforAbs,!forFactorialor\sumforSum; none of those re-parse. A LaTeX dictionary entry now declares whether its notation stands on its own with the newstandaloneSymbolproperty — 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. LeavingstandaloneSymbolunset 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 = -iandi^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 withisSameandisEqualonly, andisEqualno longer proves identities in free variables) — this recovers theSincderivative identity. -
A negated product now has a single canonical spelling.
canonicalMultiplynormalizes 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-1operand:["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 onNegate(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/Dentry above) decided "standard library" by scope position, butce.assign('F', ce.parse('x \mapsto x^2'))lands its definition in the same scope as the library — soF + 1silently shadowed the function with an unknown variable, and from then onF(2)evaluated to the product2F. The fallback now discriminates on the definition's origin: a user-defined function used as a numeric operand surfaces anincompatible-typeerror 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
xassigned5,ce.box('x', { canonical: false }).evaluate()returned the symbolx; it now returns5, 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 aSequence, or as an explicit\cdot/*chain hanging off a juxtaposed run:(px_1 + 1) \cdots (px_n + 1) \cdot p^mre-serialized with the\dotsmoved 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
piserialized as\pi(x), which re-parses asPi— 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. -
InterpolatingFunctionused 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
Gis now a variable, not Catalan's constant. The LaTeX dictionary claimed the bare letterGforCatalanConstantahead of any declaration, so a formula such as\int_{t_i}^{t_e}(G - F)\,dtsilently read asSubtract(CatalanConstant, F), and declaringGcould not reclaim it. As witheandi, the constant is now reachable only through explicit upright markup:\operatorname{G}— also its serialized form, soCatalanConstantstill round-trips — and\mathrm{G}. Two consequences:G,G(2)andG_xare ordinary symbols, and\mathrm{G}no longer parses as the upright symbolG_upright. -
EulerGammanow serializes as\operatorname{EulerGamma}, not\gamma. Bare\gammastill 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 wheregammais a variable, serializing an expression carrying the constant and parsing it back silently returned the variable.\gammacould not be disambiguated with upright markup the wayGwas —\operatorname{\gamma}already means the plain symbolgamma— so the constant serializes to its MathJSON name, which reachesEulerGammathrough the generic symbol path regardless of what is declared. Rendered output that used to showγnow shows an uprightEulerGamma. -
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 theforalland 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 withince.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 = 1or(x+1)^2 = x^2 + 2x + 1, therefore returnsundefinedinstead oftrue, and the correspondingEqualexpression stays inert (as it already did for any undetermined comparison, so an equation is still usable as an argument toSolve). Every=comparison is now cheap and predictable. Migration: wherever an identity was being proven, callexpr.isIdenticallyEqual(other), or evaluate["IdenticallyEqual", lhs, rhs](LaTeXlhs \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 — withone := 1,ce.symbol('one').isSame(1)wastrue— while the same comparison between two symbols, or nested inside a larger expression, did not, which made the method inconsistent with itself. Withx := 5,ce.symbol('x').isSame(5)is nowfalse. Migration: useexpr.isEqual(value)— orexpr.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 asop.isSame(0), are unaffected. One canonicalization consequence: thex^0 → 1fold is now a pure generic-symbol fold (likex/x → 1), soz^0canonicalizes to1even whilez := 0— previously the assigned value was peeked and the fold was blocked. The literal0^0still canonicalizes toNaN. -
A bare
\equivnow parses asIdenticallyEqual, notEquivalent.\equivis the mathematical identity sign, sop \equiv qparses as["IdenticallyEqual", "p", "q"](see New Features). The biconditional keeps all of its other notations —\iff,\Leftrightarrow,\leftrightarrow,\Longleftrightarrow,\longleftrightarrow— andEquivalentstill serializes as\iff, so logic round-trips are unaffected; only the reading ofp \equiv qas 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: writep \iff qwhere a biconditional is meant. A\equivfollowed by\pmod{n}is still aCongruent, and\not\equivstill negates a congruence. -
ce.expr()'sscopeoption 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 passedscopeto 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 newscopeoption once.parse()provides — see New Features. -
evaluate()on a raw or structural expression now evaluates through its canonical form. Binder machinery — declaring aSumindex, normalizingTuple → 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 withstructural: trueevaluated to9instead of6, 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 raw2 + 3now evaluates to5instead of echoing itself, and arity or type errors that only canonicalization checks can now surface from evaluating a raw tree (a raw5 |> 3evaluates to the sameincompatible-typeerror the canonical route reports, where it used to stay an inertPipe). A bare unbound symbol still evaluates to itself. -
Quantifiers now canonicalize their operands.
ForAll,Exists,NotForAll,NotExistsandExistsUniqueheld their operands without a canonical handler, so a parsed quantifier kept raw parse sugar in its body:InvisibleOperatorwhereMultiplywas meant,Delimiter(Sequence(…))where aTuplewas meant, strayHorizontalSpacingfrom\quad. The condition and body are now canonical (e.g. a conditionx > 0normalizes to0 < xlike 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
expressionfield — the canonical node, whose.opsare the raw (pre-numericization) operands, unlike the handler's first parameter which holds the evaluated operands (see theEvaluateHandlerOptionsdocumentation for the caveats: positional correspondence does not survive associative flattening,ReleaseHold, or dropped operands, and forlazyoperators the first parameter is also unevaluated). The first consumer isPower: 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, aSumbody) keep the rate-bounded float reconstruction. -
expr.hashis 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)impliesa.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 withisSame(); and it folds bound-variable names (binding-identity, not alpha-equivalence), matchingisSame(). The property itself is unchanged — it was previously marked@internal. -
Parametric polymorphism:
foralltype 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 (forallitself 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) -> Tat anintegerand arealgivesreal), 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,matcheswith a generic pattern answers existentially ((number) -> numbermatches'forall T. (T) -> T') whilecouldMatchreads each variable as its declared bound. A generic declaration may be implemented by anevaluatehandler 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.Reverseof amatrix<integer^(2x3)>is now statically amatrix<integer^(2x3)>, andTakeof it alist<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
forallsignature 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 aTypedmarker on the body, or as the declared type of the symbol the literal is assigned to — and it works on every route:ce.assign, theAssignoperator, and an annotated Cortexconst/let. Each call instantiates the clause, so on one enginef(5)typesfinite_integerandf("a")typesstring, 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: Tis an ordinary unannotated parameter, a bound does not narrow it, two parameters sharingTare 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 aforallclause 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 aslet y: Tis 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) = xremains 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 withce.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>istuple<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-tripslet 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 — aforallclause'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 byany, so a bareforall T. (Keyed<T>) -> Treportsgeneric-alias-boundnaming 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 atypestatement 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 methodexpr.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 aTrueverdict 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 thatEqualused to run — but it is now reached explicitly. On the compile targets,Equalkeeps its tolerance comparison whileIdenticallyEqualandSamedecline to compile. -
Same(the Cortex===operator, also written≣) is now specified as canonical-syntactic equality, matchingexpr.isSame(). It compares the canonical form of its operands as written and never dereferences the value of a symbol: withx := 5,["Same", "x", 5]isFalsewhile["Equal", "x", 5]isTrue. It remains total — alwaysTrueorFalse, with no tolerance — and keeps no IEEE exemption forNaN:["Same", "NaN", "NaN"]isTruewhere["Equal", "NaN", "NaN"]isFalse, and the same holds inside a collection (["Equal", ["List", "NaN"], ["List", "NaN"]]isFalse). Together withEqualandIdenticallyEqual, 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 })andce.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 withpushScope/popScopediscipline.scopemakes the containment first-class: the whole parse runs with the supplied scope current, so name resolution walksscope → parentsand 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_zresolves 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 afunction-typed binding (orresolveSymbolanswer) foralpha_1makes\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 (asBoxedType, soentry.type.toString()is the canonical, fingerprintable spelling) and aninferredflag, 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,ImaginaryandArgumentare real-by-definition for the compile targets (Tycho item 147). The complexness analysis judged these heads by their operands, soMod(Im(z), 1)over a declared-complexztripped 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
Blockbody no longer prints a bare internalTypeErrorwhile boxing (Tycho item 150). Canonicalizing["Function", ["Block", ⟨body with an Error node⟩], …]dereferenced the invalid block's missing scope, and the caughtCannot read properties of undefined (reading 'bindings')was printed raw toconsole.errorbefore recovering to a non-canonical literal. The block is now rebuilt so scope creation runs even over an invalid body: the literal boxes canonically (stillisValid: false), with no console noise. Two siblings fixed alongside: the nullary form silently produced a canonical literal with an unscoped block, and an emptyBlockbody threw the same TypeError (it now follows the annotated branch's convention: an empty body isNothing). The two recovery catch sites inapplyOperatorDefinitionnow 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)^eon the bignum lane,(-2)^\pion 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()). ExactRationalexponents 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_numberansweredisFinite/isInfinitywithundefined; both predicates now decide from the declared type (isFiniteisfalse,isInfinityistrue), completing the type-consult work started for function expressions in 0.100.2. The workaround checks this blindness had required in theAdd/Multiply/Dividetype 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 requiredpushScope/popScopediscipline 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 aholdUntil: 'never'constant still substitutes its value — but a name that resolves to nothing stays unbound instead of being declared.canonical: true,canonical: falseandstructural: trueare unchanged. -
A function declared with a scalar return type is now rejected when added to a tuple.
scalar + tupleis 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 symbolicAdd. 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 functionat 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 appliesLnunary 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 (IsPrimeon 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 asDorNis 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
Tupleserialized as(x), which parses back as plainx. 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 nestedMultiplywhen 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 · … · Xcame back with a spurious9/5). An ellipsis product now joins every factor with an explicit multiplication sign and is never merged into one fraction. Primeserialized its exponent unbraced (A^\prime), so a following letter was swallowed into the command name:\angle BA'Cserialized 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=3parsed back with the\landbound above theForAll. The body is now parenthesized when its precedence requires it, and — with quantifier operands now canonical (see Breaking Changes) — the body round-trips structurally.
- A symbol naming an operator, used as a value, serialized to the empty
string, silently deleting the operand:
-
Findnow types as the element, not the collection. Its static type was the whole collection's (Find([1,2,3], p)claimedlist<integer>) while evaluation returns a single element orNothing; it is nowelement | nothing. -
BaseForm's declared result contradicted its behavior (-> string | nothingwhile echoing its numeric operand); it now echoes the operand type. -
Ifwithout an else branch keeps thenothingarm in its type. A false condition yieldsNothing, but the static type silently dropped that arm.
Improvements
-
Structural-tier
freeVariables,unknownsandreferencesnow 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]]reportednas 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, sof([1,2,3])withf: (x: number) -> number— orforall 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]typedlist<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, andRemainder(M, 7)-shaped calls no longer widen to a union at all —matrix<finite_integer^(2x2)>, where the whole-argument bind gavelist<finite_integer | vector<finite_integer^2>^(2x2)>. Only the kinds a broadcast actually maps are peeled: asetargument is admitted but never mapped (Conjugate(Set(1, 2))stays aset), 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: withf: forall T. (x: T) -> tuple<T, T>assignedx |-> (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( = 1resynchronized 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 amatchtuple 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, theAssignoperator, an annotated Cortexconst/let), so the stored value reportsforall T. (x: T) -> Tinstead 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 typesfinite_integerat5andstringat"a"rather than falling back tounknown. 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 (anyce.declareform) stays sticky, as before. -
Euler derivative notation and
\gammanow 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 + 1parsed as the derivative of the constant functionx ↦ 1, and\gammawas always the Euler-Mascheroni constant. Each parselet now consults the symbol oracle first — the engine scope, supplemented by theresolveSymbolparse option.D_xreads as a symbol when the joined nameD_xis declared (the same sibling-name rule subscripted spellings already use) or whenDitself is shadowed by a non-function declaration; a function-typedDkeeps the derivative reading.\gammareads as the symbolgammawhengammais 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^2stayed inert; one point needs no enumeration, and it now evaluates tox^2(Productlikewise). 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 tof(x)— the same generic-symbol fold family asx/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}witha := 5keeps itsSumstructure — values belong to evaluation, where the reduction does follow them). Bounds that are not provably a fixed one-point domain are unaffected:±∞andNaNbounds keep their previous behavior, impure or invalid bounds (two syntactically identicalRandomInteger(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 identicalMap(xs, f)applications with a pure user-definedfnow compile to one traversal, and the same applies to typed callbacks of eager operators such asCountIf(a drawingfstill 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 callervarsentry maps, and a name a callerfunctions/operatorsmapping overrides all stay conservatively excluded. Opt out as before withcompile(expr, { cse: false }). -
FindFit/FindRootnow 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 bareCancellationErrorthe 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, withtimedOut: Trueand a newphaseentry naming how far the call got:"setup"(nothing was fitted —iterationsis0,residualNormisNaN, 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 astrue, a sampled disagreement already degraded toundefinedunder 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 againstmin(⟨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\bmodserialization 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
isComplexValuedwalk 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 (nestedSum/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
-
Repeatcompiles 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 leavesRepeat(7, ∞)unevaluated, so a compiled[]would be a valid-looking value with the wrong meaning. -
Binomial/Choosecompile 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 to1.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)/2now typenon_finite_number(previously the top typenumber); shapes admitting0·∞,∞/∞or∞·ikeep the sound widen. Structurally,Ln(0)now reportsisFinite === falseandisInfinity === truefrom its static type (both wereundefined),valueOf()projects a direction-proven infinity (Ln(0).valueOf()is-Infinity;~oois reserved for provably non-real values),\ln(0)/\picanonicalizes to\ln(0)and2/\ln(0)to0, and an unfolded finite-real-over-±∞ quotient typesfinite_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 — substitutingk+1forxin a structural2(x+1)-\frac{y}{3}returnedAdd(Multiply(2, Add(k, 2)), …)instead of the structuralSubtract(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 explicitcanonicaloption 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
Modover wide-typed real expressions. The shader targets' real-only helper gate refused operands whose type merely could be complex: the complex type aSqrt/Lnof unknown sign carries since 0.100.0 propagated to enclosing arithmetic (10^5·√(⌈x⌉²+⌈y⌉²)), through boolean nodes, and into piecewise conditions, soModexpressions 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-signSqrt/Ln/Logreal-kernel contract — instead of the widened type. Provably complex operands still fail closed. -
Min/Maxover a collection whose element type is unknown. With a base declaredindexed_collection,Distance(S, p)'s result type degraded to scalarnumber(the broadcast arm was invisible through the elementless type), andMinthen compiled to the variadic-scalarMath.min(...)— which returnsNaNwhen handed the runtime array, silently, behindsuccess: true.Distancenow reportsnumber | list<number>when an operand's collection element type is undecidable, and theMin/MaxJavaScript lowering emits a runtime shape projection (reduce an array, pass a scalar through) for operands that could be collections, matching the interpreter both ways. -
invisibleMultiplyserialization option vs\bmod. WithinvisibleMultiply: '\\cdot',Mod(k·f, 1)serialized ask\cdot f\bmod1, which re-parses ask·Mod(f, 1)— theModserializer decided parenthesization assuming juxtaposition, which binds tighter than\bmodwhile an explicit\cdotbinds looser. AMultiplyoperand of an infix\bmodis now parenthesized whenever the option is set (products that serialize as\fracremain unwrapped). -
Ordering comparisons over a provably complex operand now fail closed at compile time.
Less/LessEqual/Greater/GreaterEqualwith a complex-valued operand (i·x < 0) compiled to a raw JavaScript comparison of a{re, im}object — a silentfalsebehindsuccess: 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/NotEqualkeep 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: falseexpression (toMathJson/toLatexwithprettify) could rebuild aMultiplycontaining a symbolicDividefactor through the canonical product machinery: explicitDelimiterfences were dropped, factors reordered, and the round-trip changed the expression (Mod((k·f), 1)/n + yre-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/NotEqualover a complex operand and across n-ary chains,Range(start, stop, step)(which re-drew once per element),Round, odd-degreeRoot,Variance(12 draws where the interpreter makes 2), complexArgument/Conjugate, chained relations and element-wise selection masks,Matchsubjects, and a double-compile in the complexAddfallback that orphaned a hoisted draw. Impure operands are now bound to a temporary exactly once and in argument order — binding only the middle ofRandom() < Random() < 0.9had executed the second draw first, inverting the comparison. Pure emissions are byte-identical throughout, and regression tests count draw sites in the emitted code. -
ContrastingColoremitted invalid WGSL. Both the 1-argument and 3-argument forms lowered to a GLSL-only?:ternary on every GPU language; WGSL now emitsselect(…)(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
PointZapplied to a 2-D point is now a typedincompatible-dimensionserror instead of theNaNabsence 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 silentNaNmasked upstream pipeline defects.PointX/PointY, all 3-D behavior, and the compiled runtimeNaNmarker 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 asJ(0, z) = zpreviously failed withincompatible-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_0evaluated 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.
-
Distancebroadcasts over point lists.Distance(S, p)(either argument order) whereSis 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, somin(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/Zproject a coordinate-row list (PointX([[10,11],[20,21]])is now[10, 20], not the first row), andNormover a list of point tuples returns per-point norms[0, 5, 10]on both routes, matchingAbs.Normover a plain matrix (list of lists) keeps its Frobenius meaning. CompiledNormandAbspreviously disagreed with the interpreter on these shapes (a flattened norm and componentwise values behindsuccess: true).
Performance
- Registering many interdependent user functions (
declare+assignchains, the shape a document importer produces) was quadratic in the number of functions: thetypeaccessor'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-identicalisPure/effects/typeanswers. New benchmark:benchmarks/effects-registration.ts.
Resolved Issues
-
isPureand.effectsno 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 — reportedisPure: truewith 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 reportsisPure: falseand["random"], including when the view leaves the frame as a cell of a returnedList/Tuple/Pairor 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 step0.008000000000000007into 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 theD/Derivativeinert — 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}\,dxnumericized to709.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 returnNaNon all three numeric routes (.N(), iterated.N(), and compiled code), and detection also stops the refinement early, so\int_0^\infty x\,dxresolves 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 ofa(t) \coloneq [\cos t, \sin t]— froze the reference as a multiplication (2 \cdot a \cdot t), producing scalar results interpreted andnull/NaNfrom compiled functions behindsuccess: 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)wherexnever 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_2withhdeclared(list<real>) -> realparsed the subscripts as unrelated symbols, anAt-indexing body inferred a scalar parameter (so list arguments broadcast elementwise instead of applying), and the JavaScript compile target declinedAtover 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'wheref := 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 compiledNDdouble-converted. -
Sqrtno 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 asfinite_complex, while the folded\sqrt{0.96}typed asfinite_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
NandDused 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 declaredstring(or any provably disjoint type) now reportsincompatible-typeinstead 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, andce.assign()all reject values incompatible with an explicit symbol type. Inferred types retain their widening behavior. -
Purity and effects reporting is more precise.
expr.isPureandexpr.effectsnow 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: truecannot be combined withdrawsRandom: true; inconsistent legacy flags, effect annotations, andeffects:declarations also fail registration. -
Errors now propagate through strict built-in operators and function application. An invalid operand generally produces the underlying
Errorvalue instead of leaving an inert expression. Collection constructors and error-observing or lazy operators can still contain or inspect errors.Assumereturns string status values such as"ok"and"not-a-predicate". -
Literal
Nothingarguments are removed consistently.f(Nothing),Apply(f, Nothing), andNothing |> fnow all behave likef(). An expression that evaluates toNothingis still passed as an argument. InvalidPipecallees now return the error directly. -
Positional collection operators require indexed collections.
First,Second,Third, andLastnow reject sets and other non-indexed collections. Empty indexed collections still returnMissing. -
Several elementary functions now report complex result types when their real-valued domain cannot be proven. This includes
Sqrt,Ln,Log,Arcsec, andArccsc. 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 = …andtype alias name = …. Declarations provide checked constructors where appropriate; nominal values are opaque, support structural equality by tag and payload, and can expose named fields withvalue.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 includingrandom,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 existingpureanddrawsRandomproperties remain supported as shorthand. -
Expressions and function types expose effects directly through
expr.effectsandtype.effects. Effect-discharging operators such asWithRandomSeedcan absorb an effect, whileHolddefers 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. -
matchsupports 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.
matchcan catch error values, and the new held predicateIsError(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, andNaN. -
Countaccepts either a value or a predicate, andTableaccepts tuple iterator specifications. Tuple and brace iterator forms now behave consistently forSum,Product, andIntegrate. -
Static type errors are reported by
cortex checkand before program evaluation. Checking canonicalizes without executing effects.
Compilation
-
PointListwith collection-valued components compiles to JavaScript. GPU coordinate accessors can also project compatible point-list components. -
Atcompiles to GLSL and WGSL for statically sized numeric collections, including guarded dynamic scalar indexes and literal gathers or masks. -
D,Derivative, andNDcompile 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.
-
RangeandLinspacecan enumerate exact symbolic bounds and steps that have a numeric value, such as multiples of π. Bracket range syntax also recognizes more exact arithmetic progressions. -
SumandProductevaluate pure, closed bound expressions such asLength(P)while retaining symbolic behavior for genuinely free bounds. -
Distanceaccepts 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, andIterate, 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
Mapbroadcasts 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
Atexpressions for symbols, subscripted expressions, and literal collections. -
Ranges with compound first anchors, such as
n+1..n+10,[2n..3n], orx = 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
Powerbase correctly. -
Invalid multi-argument
ExpandExp2expressions remain well-formed inert expressions with an arity error.
Evaluation and functions
-
Atreports incompatible dimensions when more indexes are supplied than a collection can consume. -
matchinside 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
Mapcallbacks 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.
-
FindFitandFindRootobserve ambient time limits during expensive iterations and return their best result withtimedOut: Truewhen possible. -
Differentiating control-flow or binding operators such as
Which,Sum, andIntegratestays symbolic instead of throwing or producing an invalid slot-wise derivative.
Collections
-
Bounded
Takeexpressions 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
Iteratefunctions 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. -
Partitiondistinguishes 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 explicitChopoperations continue to honor the configured tolerance. CompiledChopnow 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
SinandCospreserve 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)returns0, andSigmaMinus1preserves exact results underevaluate().
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
MaxandMinover one collection reduce to a scalar instead of returning the input vector. -
JavaScript compilation now broadcasts
Sign,Arctan2,Hypot, andSincover lists. -
Complex constant folding is consistent with
realOnly: non-real constants produceNaNin real-only mode and principal complex values when complex compilation is supported. -
Compiled
InverseHaversinesupports 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.
| Expression | CE 0.100.0 | CE 0.99.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 8.2 | 8.7 | 201 | 190 | 3.7 |
\sin 1 | 25 | 23 | 253 | 599 | 5.8 |
\cos 1 | 24 | 23 | 240 | 668 | 7.6 |
\ln 2 | 15 | 15 | 375 | 4,939 | 4.1 |
e^{\pi} | 15 | 15 | 238 | 5,375 | 5.1 |
\zeta(3) | 1,707 | 1,716 | 290 | — | 54 |
\Gamma(\tfrac13) | 915 | 933 | 382 | — | 233 |
\psi(\tfrac13) | 797 | 786 | 3,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.
| Operation | CE 0.100.0 | CE + R/F | CE 0.99.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 3.5× | 1.8× | 2.8× | 0.4× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 6.4× | 1.0× | 5.7× | 0.08× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 3.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\,x | 73× | 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)^x | 4.9× | 3.6× | 4.7× | 2.2× | — | 1× |
\int_1^2\tfrac1x\,dx | 18356× | 20053× | 17734× | 310× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 258× | 110× | 251× | 2.6× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.3× | 0.2× | 0.3× | 0.06× | — | 1× |
x^3-x-1=0 | 1.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 Engine0.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; whenfcallsg,gis declared first. Signatures are synthesized statically (declared parameter types, or the body's inferred shape:float,bool,vec2–vec4, complex asvec2); 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 staticvec2–vec4shapes, and argument shapes that disagree with the declared parameter type. Undeclared parameters default tofloat. Caller-declared types oncompileFunctionparameters and on shader inputs/uniforms are authoritative for these checks, with element-aware matching across both languages' spellings (bvecN,ivecN,vec2<i32>, …) — avec2<bool>argument no longer passes for avec2<f32>parameter. A WGSL body referencing a declared input now correctly emitsinput.<name>(previously a bare, undeclared identifier).
Performance
-
Compiled code now shares repeated subexpressions (common-subexpression elimination) on the
javascript,interval-js, andpythontargets: 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 (unselectedWhich/Ifarms, short-circuitedAnd/Ortails) are never merged, so values, draw streams, and selection laziness are unchanged. Opt out per call withcompile(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/_cseNnames from a per-compilation counter instead ofMath.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
vmcontext, a sandboxed worker, or an embedder-supplied global. V8 compilesMath.imul(...)down to a single machine instruction only whenMathis 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 avmcontext (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 > 0parsed asD(x^2 > 0, x)— the derivative of a boolean — which then evaluated to anincompatible-typeerror. 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 … dxintegrand convention), but stops before a relational, assignment or arrow operator, so the expression parses asD(x^2, x) > 0. -
An undecidable comparison no longer discards its evaluated operands.
x^2 + x^2 > 0evaluated to0 < x^2 + x^2and\frac{d}{dx}x^2 > 0to0 < D(x^2, x):Equal,NotEqual,LessandLessEqualevaluate their own operands (they are lazy, so theircanonicalhandlers 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^2and0 < 2x— which is what the non-lazy relations (Approx,Tilde,Precedes…) already did. The comparison itself is unchanged: an undecidable one still stays inert, sincex^2 = 4is a condition rather than a falsity, and decidable ones still fold toTrue/False. -
Monte-Carlo integration and stochastic equality now replay under
WithRandomSeed(). Both sampledMath.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 seededisEqual()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 trailingdrawparameter defaulting toMath.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. Seedocs/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
readsRandomFrameflag 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. UnlikedrawsRandom, 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.countare available on any canonical expression, and the broadcast that zipsAdd/Multiplyoperands runs before they are evaluated. An argument spelledN-1was therefore still an unevaluated sum at that point, and each operator silently fell back to its default: withNassigned6,RotateLeft(S, N-1) + RotateLeft(S, N-2)returned2·RotateLeft(S, 1), andTake(S, N-2) + Take(S, N-2)returned nothing at all. Literal arguments were never affected. Fixed forTake,Drop,RotateLeft,RotateRight,Repeat,Fill,Partition,Tabulate,Insert,DeleteAt,ReplaceAt,Slice,PermutationsandCombinations. -
Take,Drop,RotateLeft,RotateRightandSlicenow 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 ofS, andRotateLeft(S, n)andRotateRight(S, n)rotated by one. They now report an unknown count and evaluate to themselves untilnhas a value — matchingRepeat,Insert,DeleteAt,ReplaceAt,Permutations,Combinations,Tabulate,ChunkandRange, which already did. An omitted argument still takes the operator's default, soRotateLeft(S)still rotates by one; aNaNargument counts as unresolved, not omitted.Filljoins them:Fill(f, (n, 3))used to produce an empty matrix andFill(f, (2, n))two empty rows.IsEmptyof such aTakeover an infinite collection no longer answersFalseeither — 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 unboundnno longer throws.Partitionaccepts either a chunk size or a predicate, and chose the arm by whether the size read as an integer — so an argument that is typedintegerbut has no value yet was applied as a predicate, and the resulting exception escapedevaluate(). The arm is now chosen by type, and an unresolved predicate (a symbol declaredfunctionwith 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, likeMap: 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 asMap: 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
pureanddrawsRandomflags from its body. Previously every user function was born pure, sof() := Random()reportedisConstant: truewhile drawing from the random stream on every call. Two things broke as a result:N(f() + \pi)consumed two draws whereN(\mathrm{Random}() + \pi)consumed one, and a partially evaluatedWithRandomSeed()body callingflost 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 underWithRandomSeed(). It declareddrawsRandom: true, but its draws bypassed the seed frame, soWithRandomSeed(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 madeisConstanttrue for a generator that returns a different expression on every call, and admitted it to common-subexpression elimination.
Improvements
-
A loop-form
Sum/Productcan now be a sub-expression in GLSL and WGSL. A shader has no expression-level loop, so aSumwith a symbolic bound was emitted as statements — valid as a whole function body, but nothing more:\sum_{k=0}^{n}kxcompiled while1 + \sum_{k=0}^{n}kxand0.03\sum_{k=0}^{n}kxfailed 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/Matcharm) 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 compiledRandom()advances a counter at run time, a loop stranded ahead of a branch it never feeds would change the value of every later draw.LoopandBlockstill 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.); andUnknown 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.
| Expression | CE (current) | CE 0.98.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 6.8 | 7.4 | 173 | 106 | 3.9 |
\sin 1 | 20 | 21 | 218 | 442 | 5.3 |
\cos 1 | 20 | 21 | 219 | 573 | 7.1 |
\ln 2 | 14 | 14 | 332 | 4,344 | 3.8 |
e^{\pi} | 12 | 13 | 216 | 4,751 | 4.7 |
\zeta(3) | 1,511 | 1,547 | 265 | — | 49 |
\Gamma(\tfrac13) | 831 | 812 | 346 | — | 213 |
\psi(\tfrac13) | 712 | 712 | 2,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.
| Operation | CE (current) | CE + R/F | CE 0.98.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 4.6× | 2.5× | 3.5× | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 8.3× | 1.5× | 6.6× | 0.09× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 5.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\,x | 79× | 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)^x | 5.2× | 4.0× | 6.9× | 2.1× | — | 1× |
\int_1^2\tfrac1x\,dx | 4458× | 4875× | 4193× | 77× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 316× | 128× | 274× | 2.5× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.3× | 0.3× | 0.3× | 0.07× | — | 1× |
x^3-x-1=0 | 1.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 Engine0.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
-
WhichandIfnow 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 returnsNaN.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
drawsRandommarks operators that consume the engine's random stream (Random,RandomShuffle, …). It is narrower thanpure: false, which also covers side-effecting operators such asAssign, and is used byWithRandomSeedto decide whether a partially evaluated body still owes draws to its seed frame.
Breaking Changes
-
Multiplying lists of different lengths now returns an
incompatible-dimensionserror, 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 symbolSin, butSin(0)continues to call the builtin function. -
The unused
BindingSite.shieldfield 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/Productbodies all broadcast element-wise. Length mismatches compile toNaN. -
Unsupported collection-valued conditions and vector operations now fail compilation with a diagnostic instead of producing incorrect Python, interval-js, GLSL, or WGSL code.
-
RandomChoicenow infers more precise result types, includingfinite_realfor finite intervals andfinite_integerfor 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
WithRandomSeedexpressions now retain their seed, so later substitutions produce the same result as substituting before evaluation. -
Nested
NandEvaluateexpressions now preserve numeric approximation and precision. For example,N(Evaluate(pi))now returns a numeric value instead of the symbolicpi.
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.
| Expression | CE (current) | CE 0.97.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 7.9 | 8.5 | 203 | 288 | 4.5 |
\sin 1 | 23 | 23 | 253 | 598 | 6.0 |
\cos 1 | 21 | 22 | 253 | 781 | 8.1 |
\ln 2 | 16 | 16 | 386 | 5,471 | 4.7 |
e^{\pi} | 15 | 15 | 273 | 7,477 | 5.9 |
\zeta(3) | 1,738 | 1,702 | 337 | — | 56 |
\Gamma(\tfrac13) | 924 | 913 | 407 | — | 268 |
\psi(\tfrac13) | 781 | 790 | 3,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.
| Operation | CE (current) | CE + R/F | CE 0.97.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 4.5× | 2.2× | 3.5× | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 8.1× | 1.6× | 6.8× | 0.08× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 4.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\,x | 74× | 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)^x | 7.2× | 4.6× | 6.8× | 1.9× | — | 1× |
\int_1^2\tfrac1x\,dx | 5778× | 6836× | 5520× | 107× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 352× | 146× | 298× | 2.7× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.3× | 0.3× | 0.3× | 0.05× | — | 1× |
x^3-x-1=0 | 2.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 Engine0.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 LMap(L, l ↦ l < Random()) // a draw per element, written explicitlyComparisons 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-dimensionsAddalready answeredincompatible-dimensionsfor 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 lazyMapform — so the ordering relations, the logical connectives,Divide/Power/Mod,Add/Multiply, andElementMax/ElementMin/Clampall 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 whereLesson the same operands errored.An unbounded operand against a finite one is a mismatch too (
countisInfinity, 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 toNothing(Not([])).PointListis 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 variadicMap,PointList) defines its length as the shortest input. Seedocs/BROADCAST-MODEL.mdfor 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, som1 && m2returns a whole operand). They now wrap the head's own scalar codegen in the_SYS.bcastruntime 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 < 3with anunknown-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/NotEqualover two collections (whole- collection equality, which keeps its_SYS.eqdispatch 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 plausiblefalse.
Issues Resolved
-
Covariance/Correlationreport a length mismatch asincompatible-dimensions. They were already strict — a ragged pair of data collections errored — but with their ownunexpected-argument("collections differ in length") rather than theincompatible-dimensionserror every broadcast path answers. One error tag now covers every length-mismatch diagnosis (seedocs/BROADCAST-MODEL.md). The other argument errors (at least 2 data points required, the shape error,zero variance) are unchanged. -
Multiplyover operands of mixed collection kinds is element-wise again. AListliteral packs as a tensor value while aRange/Filter/Take/Reverseresult 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-wiseNot 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.Addavoids it by declining its tensor kernel when fewer than two operands pack;Multiplynow 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.
.latexandtoString()materialize a lazy collection before serializing, and a symbol whose assigned value is a derived collection (the result of evaluating aJoin, a comprehension, …) delegatesisLazyCollectionto that value — so the symbol serialized as its materialized value while.jsonstill 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
Listvalue (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. -
AddandMultiplyfold aMeasurementoperand on the first.N(). Every unary/binary arithmetic head already folded a quadrature result (Power,Divide,Sqrt,Sin,Negate,Absof aMeasurementyield anotherMeasurement), but the two n-ary heads checked forMeasurementoperands against the plainly evaluated operands. A quadrature operand only becomes aMeasurementunder numeric approximation —\int_0^1 \sin x\,dxevaluates to the exact1 - \cos 1— so the check saw nothing, and the first.N()returned an inertAdd/Multiplywhose.rewasNaN(the same dead numeric read fixed forMeasurementitself 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.4596976941318605once.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 siblingQuantitycheck in the same handlers had the identical structural gap (an operand that only becomes aQuantityunder 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
Integrateto a closed form (so a plotted∫₀ˣ fcosts ~µ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}withka 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: truece.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 thatce.timeLimitwas retired in 0.89.0: to boundevaluate()itself, usece.withTimeLimit({ms, label}, () => …), which this shape honors to the millisecond. Reported by the Tycho team. -
Compiled
Factorialof 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 returnedNaN— silently zeroing out, e.g., a χ² density's normalizing constant(k/2-1)!at oddk: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
gammathe 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'sComplexInfinity). The Python target likewise now emitsscipy.special.gamma(x + 1)instead ofscipy.special.factorial(which answers0for 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.
| Expression | CE (current) | CE 0.96.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 7.3 | 7.5 | 180 | 110 | 3.9 |
\sin 1 | 20 | 21 | 218 | 439 | 5.2 |
\cos 1 | 20 | 20 | 221 | 456 | 7.1 |
\ln 2 | 14 | 14 | 347 | 4,406 | 3.7 |
e^{\pi} | 13 | 13 | 214 | 4,655 | 4.5 |
\zeta(3) | 1,534 | 1,562 | 272 | — | 49 |
\Gamma(\tfrac13) | 840 | 834 | 350 | — | 213 |
\psi(\tfrac13) | 724 | 720 | 2,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.
| Operation | CE (current) | CE + R/F | CE 0.96.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 4.6× | 2.3× | 3.4× | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 7.9× | 1.5× | 6.1× | 0.08× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 5.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\,x | 77× | 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)^x | 7.3× | 4.9× | 6.8× | 2.2× | — | 1× |
\int_1^2\tfrac1x\,dx | 4819× | 4740× | 4259× | 80× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 293× | 124× | 250× | 2.6× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.3× | 0.3× | 0.3× | 0.06× | — | 1× |
x^3-x-1=0 | 1.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 Engine0.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 anoperator-removederror naming its replacement, andce.randomSeedwas a throwing accessor. Those guards are now gone: the removed heads behave like any other unrecognized operator — a valid, inert expression — andrandomSeedis 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 + 1let x = 2d // was: 3x^2 + 1 now: 13N(d) // 13 — unchangedN()andcompile()already resolved it, so plainevaluate()was the outlier and disagreed with both; a secondevaluate()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)is13, whilelet d = 3x^2 + 1; let x = 2; x = 3; N(d)is28. 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 + 1g(x) = ag(5) // was: 6 now: x + 1N(g(5)) // was: 6 now: x + 1With a global
x = 100,g(5)is101— the lexically correct binding — where it used to be6. 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-localletdoes 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,Limitandsimplify()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 xx = 100a + 5 // 106 — unchangedBlock(Declare(x, "real"), a + 5) // was: x + 6 now: 106acaptured a binding; the innerDeclarecreated 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 to106before and after. The genuine shields are unaffected: with a globalx = 100,Solve(x + 1 = 0, x)is still[-1], andsimplify()remains value-blind. -
A union type is assignable only to a target that covers EVERY arm.
isSubtype()(and thereforetype.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>matchedtuple<number, number, number>, whilenumber | list<number>matched neithernumbernorcollection. Assignability is now all-branch throughout — the dual of the intersection rule, whereA & Bis 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 aT" was asking the wrong question and now getsfalse; ask it the other way round (ce.type(T).matches(unionType)— "would aTsatisfy this"), which is how the matrix-inference repair gate on union-typed parameters such asLinearSolve'smatrix | vectoris 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.0000000000000002The 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 xgoes 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
NaNintegrand, so it fell through to the Monte-Carlo estimator, which spent 1e7 samples — 250–450 ms per call — to return theNaNthat was decidable up front. The usual trigger is an unbound variable reaching a compiled artifact asundefined; a plotted curve then froze the thread and drew nothing.monteCarloEstimatenow probes a few samples and returnsNaNimmediately 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 rawReferenceErrorout 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 symbolicA 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
Measurementnow answers the numeric accessors.Measurement(value, error)types as its nominal's scalar type, soIntegrate(…).N()reportsfinite_realandisNumber === true— but every numeric read was dead, which silently poisoned any consumer reading.re, and propagated, sinceMeasurement + 1is anotherMeasurement:const n = ce.parse('\\int_0^1 \\sin(x) dx').N();n.re; // was NaN, now 0.45969769413186023n.numericValue; // was undefined, now the nominaln.valueOf(); // was the string '0.4596… ± 0.0000…', now the numbern.sgn; // was undefined, now 'positive'.re,.im,.bignumRe,.bignumIm,.sgnandvalueOf()project the nominal — all of them on the publicExpressiontype, so.reis the channel and no cast is needed. The uncertainty stays reachable through the MathJSON, or throughop2after narrowing withisFunction(), andtoString()still shows±.Three members are deliberately NOT projected, all for the same reason: a
Measurementis a function expression, and the number-literal surface belongs behind theisNumber()guard, which narrows on expression kind.numericValuestaysundefined— it is declared only onNumberLiteralInterface, and projecting it would advertise an exact numeric representation that a quadrature result does not have;isNumberLiteraland theisNumber()guard stayfalse; and.valuestaysundefined(it is the expression for a literal andundefinedfor a symbolic expression)..re/.imare sufficient for aMeasurementprecisely 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.
Pipeis 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_1holding 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.
[]typedlist<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: trueThe cause was in the type lattice rather than in lists:
widen()(a join) returnednothingfor an empty input, where the join of no types is the bottom type.nothingis the unit type of the valueNothing, not the bottom type —neveris. Withnever, covariance does the rest, sincenever <: Xgiveslist<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 thatcouldMatch()deliberately ignores the empty list as a witness —list<integer>.couldMatch('list<string>')staysfalse, since that question is about element shape. -
nothingandmissingwere reported as disjoint from any union containing them —nothingvsboolean | nothingclaimed disjointness, refuted by the valueNothing, 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 unsoundnothing <: !(boolean | nothing). -
A quantifier's variable is no longer captured by a same-named global value.
ForAll,Exists,NotExists,ExistsUniqueandNotForAlldeclared a local scope that was created and then stayed empty, so the quantified variable was bound wherever the caller had it. Withxassigned, 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 globalxis 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 aLimitsoperand was bound nowhere: it was left raw on the parse route and carried the caller's binding on thece.functionroute, 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: trueIntegratenow uses the sanctioned binder mechanism (scoped: indexingSetSites(1)), soexpr.localScopereports 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.Ddeclared 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: trueDnow 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.localScopereports 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
Dalready 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 nowfalse, as it has been for aSum's index since that operator was migrated. Code that lifts a differentiand back OUT of aDnode into the ambient scope has to re-bind it;expr.explain('D'), which presents the differentiand as a free-standing expression, now does. -
A
Functionliteral's parameter operand denotes the literal's own parameter. The bodyBlock's binding was already the authority for occurrences in the body, but the parameter operand itself was left raw on the parse andce.boxroutes and carried the CALLER's binding on thece.functionroute — the same route disagreementSeriesandIntegratewere 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: 11The parse and
ce.boxroutes gave11throughout, 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 — includingD(Pi^2, Pi), whose value was already right while the binding underneath was the constant's. -
Intervalnow survives a LaTeX round-trip. A closed interval serialized as\lbrack a, b\rbrack, which the parser reads as a two-elementList, and a fully-open one as\lparen a, b\rparen, read back as a parenthesizedSequence. The substitution was silent and could be destructive: underRandomChoice— the documented migration target for the removedRandomList(n)—RandomChoice(Interval(0, 1), n)came back asRandomChoice(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-openInterval(0, Open(1))remains the more precise spelling for whatRandomList(n)meant (upper-exclusive) and also round-trips. - In a set position of a set operator — the right side of
-
ListFromnow 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 underWithRandomSeed: 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.ListFrominside 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, compilesOn 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-sizevecN/array literals), so an all-scalarListFromcompiles exactly like the equivalentListand anything with a provably collection operand fails closed. Note thatRepeat(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 := bwithb := ais 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 reportedisFiniteCollection: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,Rationalizeand 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,isEqualagainst such a chain took ~1.8 s andSortof five of them far longer; both are now milliseconds. Arguments that can numericize — including partially numericizable symbolic ones such assin(2) + x— are unaffected. (Same class as the elementwise-Sinfix below; that one was the exact path, these are the.N()path.) -
Round/Floor/Ceil/Truncateof a symbolic term keeps its integer type.Round(4Q)typednumberwhile both the less informativeRound(Q)and the fully knownRound(4.7)typedfinite_integer: an operand typedfinite_numberreportsisReal === 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))withNunbound) 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. Evaluatingsin(π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'sconst 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.
Integratebinds its integration variable, and an integrand's free coefficients are declared alongside it — but the antiderivative machinery (and the Rubi rule driver installed byloadIntegrationRules) 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
scopedflag accepts a binding-site selector in addition totrue:import { operandSites } from '@cortex-js/compute-engine';ce.declare('MyBinder', {lazy: true,scoped: operandSites(1), // operand 1 is my bound variablesignature: '(expression, symbol) -> number',});A selector implies a scope, so
scopedremains 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 thecanonicalhandler runs, and binds every occurrence of those names to that scope afterwards — so theparse,ce.box()andce.function()routes agree about which binding a bound variable denotes, whichever route built the expression. The prebuilt selectors (operandSites,indexingSetSites,limitsIndexSites,lambdaParamSites) and theBindingSite/BindingSiteSelectortypes are exported from@cortex-js/compute-engine.Sum,Product,Loop,Comprehension,SeriesandNDSolveFunctionnow 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, […]))drawsifrom the ambientj). -
BoxedType.couldMatch()— "could a value of this type be atarget?", the predicate for classifying a value by shape.matches()answers the other question — "is every value of this type atarget" — so it reportsfalsefor 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>>'); // falset.couldMatch('list<tuple<number, number>>'); // trueUnions are distributed at every depth, so a union nested inside a parameter is handled too:
list<integer | tuple<number, number>>could be alist<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 alist<tuple<number, number>>,list<integer>could not be alist<string>, and aset<T>could not be alist<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 thanmatches()— with one deliberate exception:neveris uninhabited, so nothing could be anever.unknowncould be anything; consumers that treat an inconclusive type as "no" should checkisUnknownthemselves. -
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 rawTypeAST. 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); // falseb.matches(a); // false — yet they share `integer`a.isDisjointFrom(b); // false: they may overlapThe 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 aType, a type string, or aBoxedType, and throws on a string that is not a valid type.
Improvements
-
Disjointness now distributes over unions, so
integer | stringis recognized as disjoint frombooleaninstead 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 astring, atuple<number, number>is not alist<tuple<number, number>>, asetis not alist, and arecordis not adictionary. Broad categories still contain the narrow ones, sointegerandvalue, orlist<integer>andcollection, 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>vslist<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-evaluationThe 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 (anddocs/RANDOMNESS-MODEL.mdin 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:
Randomis 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 oldRandom(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 (aSetor anIntervalis now invalid), andk < 0ork > nis now anout-of-rangeerror rather thanundefined. Without-replacement remains over positions, not values: sampling a multiset can repeat a value.RandomInteger,RandomList, andRandomSeedare removed, along with thece.randomSeedproperty. For one release, evaluating a removed head throws anoperator-removederror naming its replacement, andce.randomSeedis 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)—kindependent draws with replacement, the twin ofRandomSample(without replacement). The domain may be a boundedInterval, aRange, or any finite collection, and is never materialized:RandomChoice(Range(1, 10^9), 5)is O(k). The count is typednumber(a computed count need not be pre-rounded; it is rounded on evaluation), andkmay exceed the domain size — that is what replacement means. -
Random draws now compile — including inside auto-compiled
Mapbodies 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,WithRandomSeedframes compile lexically (per-invocation counters): per-pixel seeding isWithRandomSeed(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; // → falsePreviously 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
xinDraw(x)is inferredcollection, notset<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 & stringis a single signature returningreal & string. See Overload Sets.
Issues Resolved
-
.N()evaluated the operands ofAddandMultiplytwice. The numeric path evaluated every operand exactly, discarded the results, and re-evaluated them numerically — observably wrong for impure operands (a framedRandom()consumed two draw indices underN()and one underevaluate()) and a 2× evaluation tax otherwise. Impure operands now evaluate exactly once; pure operands keep the substitute-once-guarded path. -
Samplematerialized 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.RandomSamplenow runs a sparse Fisher-Yates over the index space — O(k) time and memory — andRandomShufflerefuses sources past the element cap instead of exhausting the heap. -
Compiled random draws bypassed the engine's stream. A compiled
Random()emitted a bareMath.random(), so a compiledMapsilently stopped being reproducible under a seed. Compiled and interpreted draws now share one code path (and the auto-compile gate that excluded impure bodies fromMapcompilation is gone — per-sample-point draws stay in the hot path). -
compileShaderemitted 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.compileShadernow 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 & Bis now a subtype ofRwhenever any arm is, matching the behavior that already applied to primitive types. -
ce.assume()threw aTypeErrorwhen 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.
-
ShuffleandSamplewere treated as pure functions. Neither declaredpure: false, soisPure— and thereforeisConstant— wastruefor a random permutation or sample of a literal collection, andSamplewas not gated out ofMapauto-compilation. Both are now declared impure. -
A masked GPU branch could draw. The
When/Whichfall-through NaN compiled to0.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 returnsintBitsToFloat(0x7FC00000), a guaranteed quiet-NaN bit pattern. -
Desktop GLSL 4.x shaders were emitted with GLSL ES 1.00 syntax.
compileShaderchosein/outoverattribute/varyingby testing whether the version string began with3, so450 corefell 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#versionheader could not have compiled.
Improvements
-
The result type of the bare
functiontype is now reported asunknownrather thanany.functionis shorthand for(any*) -> unknownand carries no information about its result, so an application of an undeclared function typesunknown. This is visible in derived types —[h(x)]for an undeclaredhnow typeslist<unknown>instead oflist<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.
| Expression | CE (current) | CE 0.92.1 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 6.1 | 6.5 | 175 | 101 | 3.8 |
\sin 1 | 19 | 20 | 219 | 483 | 5.2 |
\cos 1 | 20 | 20 | 218 | 567 | 7.0 |
\ln 2 | 14 | 14 | 340 | 4,558 | 3.8 |
e^{\pi} | 12 | 12 | 226 | 4,770 | 4.5 |
\zeta(3) | 1,519 | 1,542 | 268 | — | 49 |
\Gamma(\tfrac13) | 831 | 827 | 348 | — | 212 |
\psi(\tfrac13) | 717 | 713 | 2,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.
| Operation | CE (current) | CE + R/F | CE 0.92.1 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 6.2× | 2.9× | 5.6× | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 11× | 1.6× | 8.8× | 0.1× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 6.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\,x | 83× | 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)^x | 8.5× | 5.2× | 8.5× | 2.1× | — | 1× |
\int_1^2\tfrac1x\,dx | 6294× | 6380× | 6457× | 92× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 388× | 143× | 389× | 2.4× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.3× | 0.3× | 0.3× | 0.06× | — | 1× |
x^3-x-1=0 | 1.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 Engine0.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
-
Nothingnow erases inside collections, as it already did inside operator argument lists.Nothingis the ERASURE marker — an empty-sequence splice — so aNothingelement is spliced out of aList,SetorTupleliteral 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
Nothingis 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 withce._fn('Tuple', …)and useMissing(below) for the hole. This also applies to lazy iteration: an element that evaluates toNothingis dropped (Map(xs, _ ↦ Nothing)is the empty collection — themapMaybeidiom). -
New
Missingmarker andmissingtype for an absent-but-positioned value.Missingis the complement ofNothing: "a position exists, its value is absent" (Juliamissing, RNA). It is never erased —[1, Missing, 3]is a 3-elementlist<integer | missing>— andmissingis a primitive unit type (a subtype only of itself andany, mirroringnothing), reachable asce.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 keepsMissing. So a numeric operator ABSORBS aMissingoperand intoNaNrather than carrying amissingarm:ce.box(['Add', 'Missing', 1]).evaluate(); // → NaN (Add(Missing, 1) : number)ce.box(['Sin', 'Missing']).evaluate(); // → NaNce.box(['Sin', ['List', 1, 'Missing', 3]]).evaluate(); // → [Sin(1), NaN, Sin(3)] -
Out-of-band access preserves position instead of yielding
Nothingor 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:NaNwhen the elements are numeric,Missingotherwise.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
NaNon an absent datum or empty input.Mean,Variance,PopulationVariance,StandardDeviation,PopulationStandardDeviation,Kurtosis,Skewness,Median,InterquartileRange,Quartiles,Max,Min,Supremum,Infimum, andMode— over both call shapes (Max(1, Missing, 3)andMax([1, Missing, 3])) — now evaluate toNaNwhen any datum is absent (MissingorNaN) or the input is empty (Quartiles→(NaN, NaN, NaN)).Max([])/Min([])are thereforeNaN(was∓∞), and these operators now type asnumberrather thanfinite_real, since their result may beNaN.ce.box(['Max', 1, 'Missing', 3]).evaluate(); // → NaNce.box(['Mean', ['List']]).evaluate(); // → NaN -
Comparisons follow IEEE 754 for
NaNand Kleene for theMissingsymbol, across the whole relational family (Equal,NotEqual,Less,LessEqual,Greater,GreaterEqual). This is the Julia model:- The
Missingsymbol is Kleene — a comparison with aMissingoperand is itselfMissing:Equal(x, Missing) = Missing,NotEqual(Missing, x) = Missing,Less(Missing, 1) = Missing. (An ordering with aMissingoperand previously stayed symbolically unevaluated.) NaNfollows IEEE —NaNis unequal to everything (including itself) and unordered:Equal(NaN, NaN) = False,NotEqual(NaN, x) = True, and every ordering with aNaNoperand isFalse. TheEqual/NotEqualresults match native float==/!=; the ordering comparisons withNaNpreviously stayed symbolic (NaN < 1was inert), so they now resolve toFalse.
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 — aNaNis still absent there (IsMissing(NaN) = True,Coalesce(NaN, d) = d,Max(1, NaN, 3) = NaN). Broadcast comparisons apply the rule per cell. BecauseNaNfollows IEEE, compiled and interpreted comparisons now agree by construction on numeric operands (plain==is the IEEE semantics — no guard is emitted, andNaN == NaNcompiles tofalse). A numeric-domainmissingarm (number | missing) does not widen a comparison's result type — that slot's absence value isNaN, so the result is a plainboolean, aMissingvalue read through such a slot compares asNaN(IEEE), and the comparison compiles on float-only targets (GLSL/WGSL). Amissing-arm operand over an object domain (e.g.string | missing) still typesboolean | missingand lowers via the guarded form (isAbsent(a) || isAbsent(b) ? null : a == b) so aMissingbecomes the target null. A scalarIf/Whichcondition that evaluates toMissingyields a catchable error expression (The condition is absent…) rather than crashingevaluate()— absence is a runtime data state, so it must be renderable and catchable; discharge withCoalesce/IsMissingto branch on possibly-absent data. (A condition that is not boolean at all, e.g.If(3, …), keeps the existing spell-check throw.) ANaN-comparison condition yields a plain boolean (IEEE) and branches normally. - The
-
Compiled
Max([])/Min([])now returnNaN, matching the interpreter (previously-Infinity/+Infinityfrom 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 is0when there are no errors. With--jsonit 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 Sinshows 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--jsonemits a structured{ query, matches }envelope. -
Cortex for AI Agentslanguage 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: anevaluatetool (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,parseandserializetools, and theCortex for AI Agentslanguage card as thecortex://docs/for-agentsresource. Register the stdio transport with, e.g.,claude mcp add cortex -- npx -y @cortex-js/compute-engine mcp, or start the URL endpoint withcortex 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 engineSpreadmarker: 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 ofa(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 aListor a scalar is anincompatible-typeerror, 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 Cortexlet/constmay 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 (usematchfor conditional destructuring) — and require an initializer. The value is evaluated once; a shape mismatch (wrong length, or not a tuple) yields anincompatible-typeerror value and binds nothing.const (x, y) = …makes each binding constant. Lowers to theDeclareprimitive with aTuplepattern 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 behindsuccess: true.)ce.box(['Declare', ['Tuple', 'x', 'y'], d]).evaluate(); // d = {value: (3, 4)} -
IsMissingandCoalesce— absence testing and discharge.IsMissing(x)isTruewhenxis absent (theMissingsymbol OR aNaN, R’sis.na);IsNaNremains 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 preserveisnan),IsMissing/Coalescefail closed with a compile error; propagation still works natively.ce.box(['Coalesce', ['At', ['List', 10, 20], 9], 0]).evaluate(); // → 0ce.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'sBlock[{x}, …]. Now that theSimplifyoperator evaluates its argument first,HoldValuesis 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 secondList/Set/Tupleor 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/constsyntax. ADeclarenode now serializes back to its statement form —let x = 5,const c = 6.28,let x: real, and destructuring patternslet (x, y) = p— instead of the genericDeclare(x, {value -> 5})function spelling, so declarations round-trip source → MathJSON → source. Shapes with noletspelling (aholdUntilattribute, 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) < ywithq(t) = n·t+1compiles 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-> numberreturn annotation, the look-through never mis-compiles the broadcast call. Element-wise compiled comparisons over collections remain a separate roadmap item. -
declare()acceptsinferredSignature: true, to vouch that a name is an operator without pinning its types. Declaring asignaturenormally makes it a contract, so a wide placeholder such as(unknown) -> unknownkeeps every call typedunknowneven 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 thatf(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 closedThe flag was already honored at run time and is now part of the
OperatorDefinitiontype, so it no longer needs a cast. A declaration that omitssignatureentirely behaves the same way. -
An unapplied
Derivative(f)now evaluates to a named-parameter function literal.Derivative(Sin)evaluates tox ↦ cos(x)(["Function", ["Cos","x"],"x"]) instead of the hole-formcos(_), which was typedfinite_number— so a stored derivative is now callable:let g = Derivative(f); g(2)works instead of erroring withincompatible-type. Results with no closed form stay symbolic, and the multivariate mixed-partial form no longer throwsFunction body must be a scoped Block expressionwhen applied. -
The Cortex CLI's
--jsonoutput materializes finite lazy collections.Range,Map/Filterresults, and loop-builtJoinchains 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 index0(indexing is 1-based;xs[0]isNaN), and a//comment that reads as floor division (7 // 2is7followed by a comment; useFloor(a / b)). Callingprint(orprintln,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 forD/Integrate. -
Pipe/Applyreject or defer a non-function right operand more sensibly.x |> f(Pipe) now returns anincompatible-typeerror whenfis a number, string, or boolean literal (which can never be applied), instead of staying silently inert; a symbol or unevaluatedfstill defers (definitions may arrive later).ApplyandPipeno longer throw an uncaughtInvalid function literalwhen given a string function operand — they decline gracefully. Applying a function-valued expression such asInverseFunction(f)now stays symbolic (Apply(InverseFunction(f), 2)) instead of misinterpreting it as a lambda body and substituting the argument forf. -
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 substitutiont = √a·x + √Qthat rationalizes√Qand 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: withw := 5,|w|.simplify()now stays|w|and√(w²).simplify()is|w|(previously both collapsed tow, silently baking inw ≥ 0and evaluating wrong after a laterw := -3). Sign and parity are taken only from a symbol's declared type and in-scope assumptions — soassume(w > 0)still licenses|w| → w— never from its assigned value..evaluate()and.N()are unchanged — they still substitute the value. -
The
Simplifyoperator now evaluates its argument before simplifying.Simplify(expr)is now evaluate-then-simplify — the operator counterpart of theexpr.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 gives30. The other transformers (Expand,Factor,Together,Distribute) are unchanged — they keep reduce-not-evaluate. -
The
.simplify()method no longer evaluates structural operators.Determinant,Trace,TransposeandLengthare 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'sevaluatehandler is.evaluate()'s job. Useexpr.evaluate()(or theSimplifyoperator, 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-enginenow provides acortexexecutable. It evaluates inline source (cortex -e '1 + 2'),.cx/.cortexfiles, 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.timecommands 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-limitchanges it and--time-limit 0disables 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], …]varsmay be omitted: the free variables offsare used, in lexicographic order, so the column order is predictable.- A single (non-list)
fsis 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 undersimplify()— soJacobianDeterminantis 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 andDdifferentiates with respect tov.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. Whetherfsis 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 asx |> fpreviously lowered immediately to["Apply", "f", "x"]; it now produces["Pipe", "x", "f"], matching the Cortex parser and retainingPipe's held-operand semantics. Chained stages remain left-associated, prefix stages useFunction(Pipe(_, f), _), and a programmaticPipeserializes with\rhd. Pipeline topic markers remain direct substitutions, sox |> f(\square)still becomesf(x). -
simplify()reaches a distributed form when it is cheaper. Rules taggedpurpose: 'expand'are excluded from its scan, because expansion usually grows an expression — but that leftsimplify()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)andx(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 operatorevaluatehandler 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 * cThe 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/Mindeliberately fail that rule and are not members: they reduce their operands' values, which is evaluation's job.simplify()remains value-blind. Witha := 5,(a + 2).simplify()is stilla + 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()thensimplify();simplify()alone is not a superset. -
The
Simplifyoperator resolves symbols bound to a value. An operator normally evaluates its arguments; the transformers arelazyonly to keep the operand's structure from being rewritten early, not to keep its values symbolic. This applies toExpand,ExpandAll,Factor,TogetherandDistributeas well.ce.assign('v', ce.parse('\\frac{x^2-1}{x-1}'));ce.box(['Simplify', 'v']).evaluate();// Before → v Now → x + 1This is the operator, not the method:
ce.symbol('v').simplify()is stillv, because.simplify()is value-blind. -
Togetherreduces 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^2The multivariate case needs Brown's algorithm: the univariate Euclidean GCD treats
yas an opaque coefficient and reports1for that pair.cancelCommonFactorsnow 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 asanyand its limits stayed a rawTuple. It now types asnumberand canonicalizes its limits intoLimitsexpressions, matchingIntegrateso a limits-consuming caller sees a uniform shape.CircularIntegrateremains 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) -
RandomListnow compiles on the JavaScript target.RandomList(n)draws fresh values on every call of the compiled function, matchingevaluate(). For draws that stay the same from call to call, use the explicit-seed formRandomList(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 returningNaN. (evaluate()reports anout-of-rangeerror 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
Atsupports a collection-valued index. An index that is a list of indices or a boolean mask now works when compiled, matchingevaluate():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]]gave20rather than[20]), or threw.An
Atwith a collection-valued index now has typelist<T>rather than the element typeT. If you dispatch on.type, expect the new value. This is what lets surrounding operations compose:At(p, I) + 1broadcasts element-wise, andLength(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()afterassign()now records the assumption. The predicate was evaluated through the symbol's assigned value before the assumption system saw it, so withw := 5,assume(w > 0)folded toTrue, 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)afterw := 5returns'ok'and|w|then simplifies tow. A predicate that contradicts the current value (e.g.assume(w > 0)withw := -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-limitIntegrateno longer drops all but the first limit. The numeric-approximation branch read only the firstLimitsoperand, soIntegrate(f, Limits(x,0,3), Limits(y,0,2))numericized as a single-variable integral —∫∫ 1over[0,3]×[0,2]gave3(a wrong value, not a decline) and a multivariate integrand gaveNaN. 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 tocompile(): 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/ConjugateTransposereport the transposed static type. They had no type handler, so an unevaluatedTranspose(m)typed as the genericvalueand was rejected by matrix arithmetic:Multiply(Transpose(J), J)forJ = JacobianMatrix(…)— the Gram-matrix idiom JᵀJ — errored withincompatible-typeunlessJwas 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 intoDivideoperands 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 tor. Trig-bearing operands of aDividenow get the full recursion (the same carve-outAdd/Multiplyalready made), so it simplifies tocos(θ); factored-polynomial cancellation is unaffected. -
Value-resolution overreach in the lazy-operand fixes. The machinery that lets
Solve/Simplify/JacobianMatrixsee through a held operand resolved bound symbols too aggressively. Fixed:resolveBoundSymbolsis now binder-aware: it no longer resolves a variable bound by aFunction,Block,Sum, … to a same-named global value (Simplify(x ↦ x+1)no longer corrupts the body's boundx).- A transformer nested in a
Solveequation could substitute an unknown that also carries a value —Solve(Simplify(x-2)=0, x)withx:=5returned[]. The unknown is now shielded across transformer reduction. JacobianMatrixdifferentiated a system in which a diff variable had already been replaced by its global value (JacobianMatrix(g,[x,y])withx:=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.
-
Dno longer evaluates its result at the differentiation variable's assigned value. Withx := 5,D(x², x)is now2x(was10). 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: witha := 3too,D(a·x², x)is6x. -
Integrate,Limit, and bundledSolveshield a value-bound bound variable. The same convention: a same-named global assignment no longer leaks into these binders or their results. Withx := 5,Integrate(x², x)isx³/3(was125/3),∫₀¹ x² dxis1/3(was0), andLimit(Simplify(x²), x, 0)is0(was25, on both the box and parse routes). A bundledSolve(\{Simplify(9 − w²) = 8, w ∈ -3..3\})withw := 9now returns[1, -1](was[]): the value-bound bundled unknown is now discovered before the shield is computed. A sharedwithValueShieldhelper implements the shield and replacesJacobianMatrix's per-variable rename. -
Distributefold,numeratorDenominator,Together, transformers, andtoString()grouping — as previously listed. -
Beta-reduction completeness. A finite self-composition (
g(g(x))for a non-recursiveg) only inlined one level, soSolve(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 |> 3stays inert) and its handler now delegates toapplyinstead of duplicating its named-operator path. -
Pipe(|>) holds its operands, sox |> fbehaves exactly likef(x). It evaluated the topic eagerly, regardless off— 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 |> JacobianMatrixpassed an evaluatedF, stripped of its definition, so the Jacobian could not see the map's body.// F |> JacobianMatrix |> Determinant |> Simplifyce.assign('F', /* the counterexample map */);// Before → Pipe(Pipe(F, JacobianMatrix), Determinant) Now → -2fnow decides whether the topic is evaluated (a lazyfreceives it unevaluated). A chained topic — the inner pipe ofa |> g |> f— is plumbing whose value flows on, so it is evaluated before reachingf. 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 containingAt(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:Atis 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()is99, which inside a held equation would replace the unknown being solved for. Only a literalListwith 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. -
Solvereturned[]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 itce.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 —
.subson the lambda body,.valueon a binding — never.evaluate(). Withxassigned5,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,TogetherandDistributereturnedg(a)unchanged, andIntegrate(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.
-
Distributereturned a product where it should return a sum. The helper recombined the branches of a distributed sum withMultiplyinstead ofAdd, so(a + b)·cbecame(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 -
numeratorDenominatorreported a denominator of1for a bare negative power. Canonical form writes1/x^2asPower(x, -2), and thePowerbranch never moved a negative exponent into the denominator. The same factor inside aMultiply(y/x^2) routes throughProduct.asNumeratorDenominator, which splits on exponent sign and was already correct — so the two disagreed. This also affected theNumeratorDenominatoroperator 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] -
Togetherdropped denominators written as negative powers. It treated only aDividenode 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
ReplaceAllin their operand.Expand,ExpandAll,Factor,Together,DistributeandSimplifyare lazy and took only.canonicalof their held operand, so aReplaceAllreached 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 + 2Note this is deliberately a different set from the transformer heads reduced by
Solve/Integrate/Limit:ReplaceAllends 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)) -
Mapnow evaluates over a source that only becomes a collection when evaluated.Map(X - 1, f)stayed in its unevaluated lazy form whileMap(X, f)andMap([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 asUnicodeScalars.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 returnedundefinedfor 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,OrandNotcould produce a scalarfalse(or one of their operands) whereevaluate()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 asNot([True, False]), still compile. -
Complex values are handled correctly when compiled.
At(p, 1+2i)andRandomList(n, 7+3i)now agree withevaluate(), which uses the real part of a complex index or seed. Previously the compiled forms returnedNaNand a different random sequence respectively.A compiled scalar comparison against a complex value is still wrong (it returns
falserather than declining); this is unchanged and tracked inROADMAP.md. -
Atwith several indices reports the correct type. For a 2×3 matrixM,At(M, 1, [1,2])is the 2-element list[1,2]andAt(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
-
Joinappends a tuple operand as a single element instead of splicing its components. A tuple is anindexed_collection, soJoinused 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 idiomL → Join(L, P): the result grew by 2 instead of 1 and stopped matchinglist<point>.// Beforece.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 pointsJoinof 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)].Joinnow agrees withAppendon a tuple element. -
Joinnow reports the joined ELEMENT type instead of a barelist. Its type handler returnedlistwhatever it was given, so a joined point list did not matchlist<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 aJoinresult sees the narrowed type instead — butmatches()-based queries only gain precision.
Issues Resolved
-
evaluateAsync()no longer tears down a scoped operator's local scope while the operator is still running. AnevaluateAsynchandler 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 throwingCannot assign a value to the constant "i"for the commonest index spelling of all, since globaliisImaginaryUnit: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: 7The synchronous lane was fixed in 0.87.1; this brings the asynchronous lane — the cancellation path
withTimeLimitdocuments — 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. TheParseLatexOptionshandlersgetSymbolTypeandhasSubscriptEvaluate(and the internalisSymbolDeclared) are replaced by a single optional handler:resolveSymbol?: (symbol) => { type, subscriptEvaluate? } | undefinedReturn
undefinedfor a symbol the handler does not know; return a record (with aBoxedTypeor type-stringtype) 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 withgetSymbolType):// Before: vouch + hand-written scope delegationgetSymbolType: (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 scoperesolveSymbol: (id) => (vouched(id) ? { type: 'function' } : undefined);Similarly, the
Parserinterface (custom LaTeX dictionary entries) replacesgetSymbolType()/hasSubscriptEvaluate()withparser.resolveSymbol(), e.g.parser.getSymbolType(id).matches('function')becomesparser.resolveSymbol(id)?.type.matches('function').
Improvements
-
A declared name now outranks subscript-index capture. Once a symbol
Bis bound to an indexed-collection value (a point, list, tuple…), the parser readsB_{2}as indexing (At(B, 2)), which made every subscripted sibling name (B_2,B_3, … alongside the pointB) unspellable — and, sinceB_{2}andB[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-defaultindexStyle: 'subscript'serialization,At(B, 2)serializes asB_{2}, which re-parses as the symbolB_2when such a declaration exists. -
Rubi integration (experimental) — nested-radical substitution fallback (R31).
loadIntegrationRulesnow 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 substitutingu = (a+b·x)^{1/k}(or the Laurent(a+b/x)^{1/k}) at the innermost radical linear inx, 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 withRUBI_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-formLinearRegression/PolynomialFit, the model may be any composition (a·e^{b·x} + c, Gaussians, power laws, cosines, …).datais a list of(x…, y)tuples or a plain list ofyvalues (x = 1, 2, …). Each parameter spec is a bare symbol (start1, 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 asconverged: Falsewith the best-so-far values, never a silent wrong answer. Jacobians are analytic (viaD), 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 asFindFit(root-finding is the zero-residual case of the same Levenberg–Marquardt core).equationsis 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 viace.declare('g', '(number) -> number')+ce.assign('g', x ↦ …)resolved through a value definition whose application-site broadcast still zipped eagerly, walking the whole collection atevaluate()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 thresholdg(X)returns a lazyMap, 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.
| Expression | CE (current) | CE 0.86.1 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 6.2 | 7.0 | 175 | 173 | 3.9 |
\sin 1 | 20 | 20 | 220 | 506 | 5.2 |
\cos 1 | 20 | 21 | 219 | 612 | 6.9 |
\ln 2 | 14 | 14 | 348 | 4,616 | 4.0 |
e^{\pi} | 12 | 12 | 221 | 5,086 | 4.6 |
\zeta(3) | 1,580 | 1,619 | 261 | — | 49 |
\Gamma(\tfrac13) | 842 | 839 | 345 | — | 211 |
\psi(\tfrac13) | 728 | 720 | 2,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.
| Operation | CE (current) | CE + R/F | CE 0.86.1 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 6.7× | 3.1× | 5.2× | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 10× | 1.6× | 8.4× | 0.08× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 6.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\,x | 104× | 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)^x | 9.3× | 5.6× | 8.0× | 2.1× | — | 1× |
\int_1^2\tfrac1x\,dx | 7405× | 6951× | 6437× | 77× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 423× | 161× | 363× | 2.6× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.3× | 0.3× | 0.2× | 0.06× | — | 1× |
x^3-x-1=0 | 1.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 Engine0.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 ofnindependent uniform reals in[0, 1). Draws come from the engine's random stream (honoringce.randomSeedfor reproducibility); the two-argument formRandomList(n, seed)produces a deterministic list from an explicit seed, independent of the engine stream. Eagerness is deliberate: the result is a concreteList, so every reference to it sees the same draws (a lazy collection ofRandom()calls re-draws on each traversal). With a literal count the length is part of the type (RandomList(5)typesvector<finite_real^5>). The count is capped at 10⁶ elements; a larger count — or a negative one — returns anout-of-rangeerror rather than silently misbehaving.
Improvements and Resolved Issues
-
Absof a fixed-arity point is now the Euclidean norm.|(3, 4)|evaluates to5— 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 underevaluate(), 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.normon 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.Absover aListis unchanged and still broadcasts elementwise.Hypotwith a point argument now squares through the norm as well:Hypot((3,4), 1) = √26(previously an inertPowerof a tuple). AndNorm/Absof a point now honor the exactness contract:evaluate()keeps the exact√2,.N()numericizes. -
Normcompiles on theinterval-jstarget for fixed-arity points (default L2 norm;hypot-based in 2-D for a tighter enclosure). Implicit curves and line series expressed with\lVert…\rVertor|…|now get interval-arithmetic break detection instead of silently degrading to point sampling. -
Norm/Absof a point with a broadcasting component reports an honestlist<number>type.‖(x+[0.5,1], y, z+2)‖evaluates to aList(one norm per zipped element); its static type now says so instead ofnumber, matching the equivalent\sqrt{(x+[0.5,1])^2+…}spelling. -
A
Comprehensionserializes with bracket delimiters so it survives its own round trip in every position:H=[k^2 \operatorname{for} k=[1...4]]now serializes asH=\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 anAddthe trailing term was absorbed into the iteration range). The fence is unconditional —[body for …]parses back to the sameComprehension, so it is lossless. (Non-canonical serialization-shape callout, same class as the 0.83 big-operator body fence.) -
Expression.isValidis 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 repeatedisValidwalks.
0.89.0 2026-07-21
Breaking Changes
-
ComputeEngine.timeLimitis removed, completing the deprecation begun in 0.88.0. There is no implicit ambient deadline anymore: anevaluate()orsimplify()outside a span runs unbounded, andce.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 — everyCancellationError.attributionnow names a span you created (or isundefinedfor an unlabelled span). -
The
BoxedTensorclass is removed. Tensor values (vectors, matrices) are now ordinary canonicalListexpressions; "tensor-ness" is a property (shape-regularity), not a distinct representation. TheBoxedTensortype export and theTensorInterface.tensoraccessor (which exposed the internal packedTensorobject) are gone. TheisTensor()guard remains and now answers the representation-independent question "is this a shape-regular list?";.shapeand.rankremain and now report honestly on every tensor-shaped list — including broadcast results, which previously reported[]/0. Code that usedexpr.tensorshould operate onexpr.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]typesvector<finite_integer^3>(previouslyvector<3>, i.e.numberelements),[[1, 2], [3.5, 4.5]]typesmatrix<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)]typeslist<color^2>, notvector<2>. The new types are strict subtypes of the old ones, sotype.matches('vector<3>')and similar queries continue to answertrue; only code comparing exact type strings is affected. An evaluated broadcast result now carries its shape too (Sin([0,1]).evaluate()typesvector<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>, abroadcastable<…>intermediate) is accepted at canonicalization and checked against its actual value when the operator evaluates. Provably-wrong operands (Determinant("abc"),Determinant(v)withv: list<number>— a flat vector can never be a matrix) still error immediately. Consequently someincompatible-typeerrors 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)withMa 2×2 matrix types as a 2×2 matrix), so expressions likeDeterminant(Sqrt(M)),MatrixMultiply(Sqrt(M), Sqrt(M)), orInverse(A + B)— which used to fail withincompatible-typeat canonicalization — now evaluate. Symbols declaredlistparticipate 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 exact27021597764222973(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])istrue(engine tolerance),[x,2].isEqual([y,2])isundefined(symbolic), and a list containingNaNis neverisEqualto itself (mirroring scalarNaN ≠ NaN) — uniformly for every list, whatever produced it. Ordering comparisons (isLessEqual, …) between equal tensors of any cell type now answertrueinstead ofundefined. -
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
ha function over numbers (declared or inferred) andLa list,h(L)already typed as a list — buth(L + 1)orh(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-elementL), and a declared scalar signature no longer rejects a collection argument withincompatible-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.
| Expression | CE (current) | CE 0.86.1 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 6.7 | 7.5 | 176 | 137 | 4.1 |
\sin 1 | 21 | 21 | 220 | 444 | 5.1 |
\cos 1 | 20 | 20 | 224 | 536 | 6.9 |
\ln 2 | 16 | 16 | 352 | 5,754 | 3.8 |
e^{\pi} | 14 | 15 | 215 | 4,765 | 4.5 |
\zeta(3) | 1,734 | 1,790 | 282 | — | 51 |
\Gamma(\tfrac13) | 957 | 950 | 356 | — | 209 |
\psi(\tfrac13) | 806 | 795 | 2,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.
| Operation | CE (current) | CE + R/F | CE 0.86.1 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 6.6× | 3.1× | 5.4× | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 8.0× | 1.3× | 6.8× | 0.09× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 5.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\,x | 131× | 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)^x | 8.7× | 5.1× | 7.2× | 2.1× | — | 1× |
\int_1^2\tfrac1x\,dx | 6954× | 6552× | 5758× | 93× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 382× | 133× | 341× | 2.6× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.2× | 0.3× | 0.2× | 0.06× | — | 1× |
x^3-x-1=0 | 1.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 Engine0.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 anincompatible-typeerror. 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 JavaScriptError; 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.timeLimitis deprecated in favor ofComputeEngine.withTimeLimit().timeLimitarms a hard-to-scope implicit deadline around eachevaluate()/simplify(); wrap the work you want bounded in a span instead:// Beforece.timeLimit = 500;const r = expr.evaluate();// Afterconst r = ce.withTimeLimit({ ms: 500, label: 'my-app:eval' }, () =>expr.evaluate());timeLimitstill 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 formwithTimeLimit(ms, fn), an object formwithTimeLimit({ ms, label }, fn)(preferred for new code) records a label on the span. Nesting still composes asmin()— a labelled inner span can only shorten the effective deadline, never extend it past an enclosing one. -
CancellationErrornow carriesattributionandspans. When a timeout fires,attributionis 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"), andspanslists all active span labels, outermost first. Timeouts armed by the deprecated ambientce.timeLimitare attributed asengine.timeLimit:<operator>(e.g.engine.timeLimit:Integrate). -
Divisor functions are now O(√n) instead of O(n).
Sigma0,Sigma1,SigmaMinus1,IsPerfect, andTotientcompute 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
withTimeLimitspan 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
withTimeLimitspan enclosing an integration expires mid-attempt, theCancellationErrornow propagates to the caller (identified viaattribution) instead of being silently converted into "no antiderivative found". Rubi's own internal sub-budgets still degrade gracefully.
Fixes
-
Unary
Dapplications no longer serialize to a bareD. An arity-1 (or otherwise unrecognized)Dapplication — e.g. a document-defined function namedD— 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: withKassigned-32.3,K(2-0.1)parsed as a call ofKand evaluated to anincompatible-typeerror. 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 asK(group), which re-parses as a function call ofK— corrupting the expression whenKis 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 nativebigintvalues.bigintwas declared inExpressionInputbut unhandled by the boxing dispatch, soce.box(123n)— bare or as an operand ince.box(['Add', 10n, 5])— silently became theUndefinedsymbol. Bigints now box as exact integer literals, preserving exactness at any magnitude (never routed through a float), identical to thece.number(bigint)path. -
Unbounded collection walks over infinite lazy sources.
First/Aton aFilterorTakeWhilewhose predicate never (or eventually never) matches, and any walk over aDedupof a source with infinitely repeating duplicates (e.g.Dedup(Cycle([1,1]))), could previously spin until the ambient time limit fired — or forever withtimeLimit = 0. These walks are now bounded byiterationLimitand degrade gracefully (Nothing/undefined), consistent with the documented lazy-collection contract. Note: as a consequence,Count(Dedup(...))over a finite source larger thaniterationLimitnow returnsundefined(unknown) instead of walking the whole source, matchingFilter's existingcountbehavior.
0.87.2 2026-07-20
Breaking Changes
-
Addno 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 + 1wasfinite_integer | matrix,2·[1,2,3] + awasnumber | 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 dimensionlesslist<number> + 1was already repaired tolist<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 confidentfalseon 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
-
isValidnow honors its documented contract through a list/tensor.isValidis specified asfalseif the expression "or any of its subexpressions" is an["Error"], butBoxedTensor.isValidreturnedtrueunconditionally — so aListwhose every element was anErrorreportedisValid: true.(1,2)+[3,4]broadcasts to a list ofincompatible-typeerrors and passed the gate. An embeddedErrorelement now poisons the enclosing expression, matching whatBoxedFunction.isValidalready did.Behavior change worth noting even though the contract is unchanged: consumers using
isValidas an admission gate before compiling or plotting will now correctly reject expressions they previously admitted.Only
expression-dtype tensors are scanned;float64/complex128/boolfields cannot hold anErrorby construction and keep the O(1) answer, so this does not add a per-element walk to large numeric tensors.The
isValiddocumentation 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,NaNand±∞are all valid.
0.87.1 2026-07-20
Breaking Changes
compile(..., { realOnly: true })now rejects a complex-valued tuple/list component.realOnlycoerced 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 existingSqrt(-1)"no real value" error. The component's type cannot decide this — withtundeclared,(t, i t)and(t, t²)both inferfinite_number— so the check uses the sameisComplexValuedanalysis the GPU targets fail closed on, and every target now rejects the same shapes. Real-valued tuples are unaffected, and the runtimerealOnlycoercion now also recurses into array results for values that only become complex when called ((t, √t)att = -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.isCollectionwasfalse,countundefinedandeach()yielded nothing, even though.typealready reportedvector<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-valuedWhennow behaves as[When(L₁,c), …, When(Lₙ,c)]: at or belowMAX_SIZE_EAGER_COLLECTIONit distributes into aList, and above the threshold it stays a heldWhenthat is nonetheless fully enumerable (count/each()/at()), following the same hybrid-lazy convention asPointList. A scalarWhenstill reports as a scalar, and aTuple-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 optionalBaseCollectionHandlers.isCollectionpredicate. Every handler-backed collection API honors that opt-out —count,each(),at(),get(),indexWhere(),subsetOf(),contains(),isLazyCollectionandisIndexedCollection— so none of them can report a collection answer for a value that says it is not one. The opt-out is deliberately narrower thanisCollection === false: an eager collection operator such asUnicodeScalarshas no collection handlers at all until it is evaluated, and still goes through the materialize-then-iterate path. -
A
Sum/Productover 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 assignedundefined. 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/Productwith 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 beforerun()/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 exceedsNumber.MAX_SAFE_INTEGERover a non-degenerate range can no longer be enumerated faithfully atnumberprecision — 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
Listelement whose container type was revealed only by canonicalization was flattened into a numeric tensor. Tensor eligibility is decided on raw operands so nestedLists stay visible, but a wrapper such as a parsedDelimiter,If,Which,WhenorHoldreports itstuple/set/dictionary/recordtype only after it is canonicalized. Such elements were taken for scalar tensor components, so a list of tuples collapsed into avector<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 aList. -
A
Sum/Productindex namediwas read asImaginaryUnitby 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 — theSumitself emitted correctly.\sum_{i=0}^{2}\cos(it)+2.5compiled (success: true) toNaN, and\sin(\sum_{i=0}^{2}\cos(it))+2.5to a silently wrong2.5; the interpreter was correct throughout. The index need not appear in the body, and\prodwas 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.timeLimitwas silently inert inside ace.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 nowmin(ambient, now + timeLimit). Plain nested evaluations are unaffected. Note this applies to synchronousevaluate(): a span's deadline is still restored when its callback returns, soevaluateAsync()under a span remains unbounded. -
GPU targets emitted invalid shader source for vector-valued block locals and over-wide
vecNconstructors. A tuple/point-valued block local was declaredfloatwhile being assigned avecN/array, vector width did not propagate through an aliased local (q := p), andvecNconstructor 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 avecNassignment both satisfy). -
A delimited
\mapstobody was read as a statementBlockrather than aTuple, so a point-valued lambda silently dropped all but its last component.t \mapsto (\cos t, \sin t)applied att = 0.5returned0.479…— justsin 0.5— on every target,jsincluded; the equivalentg(t) := (\cos t, \sin t)was already correct. A delimited lambda body is now data (aTuple) whatever its separator; a genuine statement block is built by the;infix parser when the sequence contains anAssignand 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 likez² + 0.3fired a full-subtreeN()walk that could make no progress, at every nested level, mutually recursive withevaluate— ~×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 lexicalisConstant(every symbol a constant binding) — depth 7 drops ~500× to ~15 ms, float and exact now cost the same, and0.5 + π,0.5 + √2, and0.5 + xall 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 returned0(now stays symbolic), andDegree(w²)returned0(now2).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
lazyoperator'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,Negatein 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); andText. Symbolic recursive unwinding is now linear: depth 80 unwinds in ~95 ms where depth ~20 previously hit the time limit. -
Timingnow measures the actual evaluation.Timingwas 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.Timingis nowlazy: 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'stimeLimit/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 unrelatedTypeError; an interruption now propagates as theCancellationErrorit 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 (azslot declarednumbercarrying a complex value at run time) stay bare. The same coercion now applies at the two sibling seams: aTypedcomplex ascription over a provably-real operand (previously silently inert in compiled code — an all-real body under a declared-> complexreturn), 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 literal0).
New Features
- New engine flag
ce.jit: 'auto' | 'off'governing every implicit compilation path — the new lazy-Mapauto-compilation (below) and the pre-existing compiled numeric kernels (NIntegrate/ND/NLimit, theIntegrate/Limitnumeric fallbacks,NDSolveright-hand sides, the solve-domain enumeration sieve, the stochastic-equality probes, the compiledReducefast path). Default'auto': attempts run, and on the first environment-levelEvalError(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. Explicitcompile()is exempt and keeps failing loudly. Implicit compile failures now fall back to the interpreter silently (previously some of these paths logged aCompilation fallbackwarning).
Performance
- Lazy-
Mapelement lambdas auto-compile on numeric drains. Draining a lazy broadcast (f(Range(1, 10^5)).N(), aPointListsweep, anaddN/mulNbroadcast) 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 logicalMap— 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√xover a sign-crossing source still yields complex values), and ineligible bodies (e.g. containingRandom) 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.
| Expression | CE (current) | CE 0.86.1 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 7.2 | 8.1 | 179 | 102 | 3.9 |
\sin 1 | 23 | 24 | 224 | 442 | 5.2 |
\cos 1 | 23 | 23 | 224 | 571 | 7.0 |
\ln 2 | 15 | 15 | 344 | 4,392 | 3.7 |
e^{\pi} | 13 | 13 | 212 | 4,930 | 4.5 |
\zeta(3) | 1,732 | 1,733 | 265 | — | 48 |
\Gamma(\tfrac13) | 928 | 942 | 347 | — | 219 |
\psi(\tfrac13) | 777 | 814 | 2,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.
| Operation | CE (current) | CE + R/F | CE 0.86.1 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 5.4× | 2.4× | 4.3× | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 8.2× | 1.1× | 7.0× | 0.09× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 4.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\,x | 88× | 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)^x | 8.5× | 5.1× | 8.0× | 1.9× | — | 1× |
\int_1^2\tfrac1x\,dx | 6388× | 6536× | 6482× | 75× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 375× | 144× | 356× | 2.2× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.4× | 0.3× | 0.3× | 0.08× | — | 1× |
x^3-x-1=0 | 1.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 Engine0.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 thejavascriptandinterval-jstargets to true recursion, instead of failing closed. Termination is the caller's contract, matching compiled unboundedLoop: on thejavascripttarget runaway recursion throws a catchableRangeError; oninterval-jsthe runner converts runtime errors to the entire interval ("cannot bound"), per that target's error philosophy. (The interpreter throwsCancellationErroron its time limit instead.) A complex-valued recursive function needs aTypedcomplexreturn ascription on the function literal so the self-call types as a scalar — without it the application typesbroadcastable<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 structuralTypedform (typed parameters plus a return-type ascription) — one compact string instead of nestedTypedwrappers, 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^kfor literalk= 2…8 with a complex-valuedzemitted 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
angularUnitsemantics) was applied only to the top-level expression: compilingt ↦ f(t)wheref(x) := sin(x)underangularUnit: 'deg'emitted radian-based trig insidef's definition, while the inlinedt ↦ 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. AFunctionliteral canonicalized beforece.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 typedanyand compiling the function fail-closed on the collection guard, while a lucky subset of names (pre-declared shells such asKorJ) masked the bug.ce.assignnow pre-declares the target as function-typed and re-canonicalizes such a literal, matching the behavior of theAssignoperator and off(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 usingz_0emitted the binding as a complex object literal while the operand analysis read only the DECLARED type (widenumber/unknown⇒ real) —number + {re, im}arithmetic, silentlyNaNat 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 namedidoes not pick up the imaginary unit. Block/\coloneqlocals infer complex-ness from their assigned right-hand side. Inw_1 ⩴ (x+iy)² + z_0; w_2 ⩴ w_1² + z_0the localw_1was 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 GPUvec2local hints to chained locals).- Complex
Addbinds 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(andSupremum/Infimum) now type asnumber. Their declared result type was the vestigial unionnumber | list, so evenMax(1, 2)typed aslist | number, a comparison over one typedlist<boolean>, and the compilation targets' scalar-condition assert fail-closed everyWhenrestriction 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/ElementMinare the broadcasting variants), so the result type isnumberunconditionally. 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 ofTuplepairs came back flattened ([("a",1), ("b",2)]→["a",1,"b",2], so the result no longer fedDictionaryFrom), 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. Takeof an infinite collection with a finite bound is now finite.Take(Range(1,+∞), 3)reportedcount3 butisFiniteCollectionfalse (it propagated the source's finiteness), which leftListFrom(Take(<infinite>, n))symbolic.Takenow 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.)SortandShuffletype aslist<…>. Both always rebuild aList, but their static type claimed the source's type (Sort(Range(1,5))typed as anindexed_collection-shaped Range). Both now reportlist<element-type>, matchingTake.Slicefacets are now coherent over infinite and unknown-length sources.SliceclaimedisFiniteCollectionunconditionally, and a negative start over an infinite source produced aNaNcount whileat(1)fabricated the element+oo(fromsource.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/Productbodies 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 toList(0, Range(1/6, 1))— silently wrong values — because the sample reader did not recognize fraction literals; it now yieldsRange(0, 1, 1/6)with an EXACT rational step (a float0.1666…step would drift and miss the end anchor; the range lands exactly on1). And[m+n,m+n+15...m+n+60]parsed to a nested-RangeList— 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, yieldingRange(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 literalListentry.
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-levelevaluate()arms its owntimeLimitbudget, so a long sequence of short evaluations — e.g. draining a lazy collection element by element viaeach()/at()— can run unboundedly without ever tripping the limit. Wrapping the loop inwithTimeLimit()arms one shared deadline for its full duration: any evaluation inside throwsCancellationError(cause: 'timeout') once the deadline is exceeded. Re-entrant (an inner call can only shorten the effective deadline, never extend it).
Resolved Issues
.N()ofTuple ± scalar·Tupleno 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 readbignumRe— which is undefined on a machine numeric value — and threwTypeError: Cannot read properties of undefined (reading 'toFixed'). At scale this killed composed lazy streams mid-drain (a 4001-pointPointList − scalar·PointListdied at the first integer-valued element) and madeat(k)returnundefinedat 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) emitted1/2(sq)— which re-parses as1/(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 ass\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, andRecordFromstay 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 threwCancellationErrorinstead of returning a result. They now detect the non-finite input structurally and stay symbolic immediately. (Orderingpreviously returned a spurious empty list, claiming a complete ordering it never computed.) Huge-but-finite inputs still walk under the deadline as before, andFindis unchanged: it streams and short-circuits, soFind(Range(1, +∞), x ↦ x > 5)still returns6.
Collections
Insert,DeleteAt,ReplaceAt,Partition(chunk and window forms),SlidingWindow, andChunkByare now hybrid-lazy. Inputs at or below the 100-element eager threshold evaluate to an eagerListexactly as before (byte-identical shapes); larger, lazy, or infinite inputs stay symbolic and serve their elements on demand throughcount/at/ iteration, following the same convention as the hybrid-lazy broadcast forms.Insertinto a million-elementRangeno longer materializes the whole list to answerCountor 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
PointListtransposes and point-coordinate projections are now hybrid-lazy, and scalar arithmetic over lazy broadcasts composes lazily instead of grinding (or staying inert). Numeric evaluation ofscalar × (PointX(P), PointY(P), …)over a 4001-pointPointListof broadcast components took ~600–1000 ms per product — each coordinate projection eagerly transposed the whole point list into nTuples (at ~150 µs/element of per-element boxed re-canonicalization) only to project one slot back out — and could exceed the 2 stimeLimiton 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):PointListpast the threshold transposes to the lazyMapform (consumable viaat/each/count) instead of materializing every point-Tuple. BREAKING (shape): a >100-pointPointListnow evaluates to a lazyMap, not an eagerList— element values are unchanged.PointX/PointY/PointZproject lazily past the threshold, and project straight to the source collection when the operand is the lazy transpose form (PointX(PointList(a, b, c))≡afor equal-length components; ragged or scalar slots keep transpose semantics).addN/mulNre-dispatch their broadcast branches after numeric operand evaluation, so an operand that only becomes a collection through evaluation (Mod(L, 11)over a listL) now composes into the lazyMapform — 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-indexat()calls (which re-instantiated a lazyMap's mapping lambda on every access). The filed repro (a 4001-member 3Dvectorrow) went from a 2 s timeout to ~10 ms of lazy composition, with materialization deferred to the consumer sweep.
Resolved Issues
- String
varsvalues now splice into compiled JavaScript and Python as source, not as string literals. Thevarscompile 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, socompile(expr, { vars: { s: '_.s' } })emittedMath.sin("_.s" * _.x)— a string literal yieldingNaNat 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
-
DSolvefrontier 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 asc₁·AiryAi(t) + c₂·AiryBi(t)witht = ∛p·x + q/∛p²(real cube root, either sign ofp). - Airy-type Riccati
y′ = q₀(x) + q₂·y²(constantq₂, linearq₀): they = −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)² = 0check so near-repeated numeric eigenvalues stay inert rather than producing an approximately-wrong solution.
- Nonhomogeneous Cauchy–Euler (
-
AiryAiPrime/AiryBiPrimeoperators (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. WhereNDSolvereturns a sampleList, the newNDSolveFunction(same arguments, without the sample count) returns the solution as a callable function — aFunctionliteral wrapping the newInterpolatingFunctionoperator, 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)), andcompile(f(t))an expression overt(~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
-
NDSolvenow uses adaptive stepping (Dormand–Prince 5(4)) with dense output. The output is unchanged in shape — aListofsteps + 1uniform[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) leaveNDSolveinert 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 countP(n, k) = C(n, k)·k!, joining the existingnCr(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)inferringA,Bas 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 inmatrix-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
zof typeimaginary,z²reportsnegative,z⁴positive(the cycle(βi)^p = (-1)^{p/2}·β^pfor evenp, including negative exponents:(2i)^{-2} = -1/4), and odd powers reportunsigned(pure imaginary). Powers of a general finite non-real base reportnot-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 — buti⁴ = 1and(1+i)² = 2i).
Improvements
Abstyping now follows the operand's finiteness.|x|of a provably finite operand (real or complex) typesfinite_realinstead of the signature's genericreal; a provably infinite operand typesnon_finite_number, and a literalNaNtypesnumber. Finiteness also propagates structurally (|x|is finite iffxis), which makes signs of products of absolute values determinate:|x|·|y|for finitex,ynow reportsnon-negative(this path previously hit a latent inverted parity claim inMultiply— see the sgn audit below — and before that was masked entirely).
Resolved Issues
-
Lazy broadcast over a declared-
unknownsymbol no longer throwsNot canonical(Tycho item 42). Evaluatingmod(L, N)/NwithLa declared-unknownsymbol holding a >100-element list built the lazyMap(L, …)over the SYMBOL, whose static type isunknown; every lazy collection operator's canonical handler hard-rejected such a source andboxFunctionfell 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 — andMapover such a source keeps value-aware indexed-ness (type,at,count, and the display preview, which no longer renders with a misleadingSethead). The composed lazy result is consumable and honorsx.N() ≡ x.evaluate().N(). -
A user symbol shadowing a builtin no longer breaks function application of that builtin. With
N := 85declared (ubiquitous in Desmos-style documents), any["N", …]application — including the engine's own internalN(…)wrapper that makes lazy.N()elements float on access — resolved to the user's number and produced anincompatible-typeerror (surfacing asNothingelements 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 laterN(3.14159, 2)now numericizes instead of staying symbolic.) -
The JavaScript compile target's floored-
Modemission is parenthesized (Tycho item 43). The fragment((a % b) + b) % bwas emitted without outer parentheses; composed as aMultiply/Dividefactor, JS's left-associative same-precedence%reduced the whole product modb(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/Productover a collection-valued body type as the collection, andAtextracts element types (Tycho item 44). A big-op whose body typesvector<2>(e.g. summing scaled calls ofa(t) := [cos t, sin t]) typednumber, so indexing the sum baked anincompatible-typeerror at parse time; it now typesvector<2>.Aton atuple-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 madeA(t)[1]typeany; it now typesnumber). -
Atover a typed-collection application compiles; a collection-valued big-op body fails closed instead of emitting wrong code (Tycho item 45).a(x)[1]withareturningvector<2>now compiles (the collection gate is type-aware, so_SYS.atis emitted). A compiledSum/Productwhose 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)fora(t) := [cos t, sin t]withtunbound: symbol values resolve by name through the evaluation context, soBoxedSymbol.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 plainevaluate()— so nested helper-call expressions (the Tycho item-46PointList(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 inertList(0, ContinuationPlaceholder·300)),[1,...N]and[0,...3N^{2}-1]likewise, and the stepped[0,15...210]→Range(0, 210, 15)(was the silently WRONGList(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 literalList. 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 aList. -
A
Rangeoperand of a tighter-binding parent now serializes parenthesized (Tycho item 48)...parses its end operand at a precedence belowAdd, soAdd(Range(0, L-1), 3)serialized as0..(L-1)+3, which re-parses asRange(0, L+2)— wrong values on any serialize→re-parse round-trip (withL = 5, an 8-element list instead of the shifted 5-element one). ARangeunderAdd/Subtract/Multiply/Power/solidus-Divideparents now wraps in parentheses ((0..(L-1))+3); bare and stepped ranges serialize unchanged. Same round-trip precedence class as the 0.83.2Modfix. -
GPU targets emit a shape-matched NaN for masked conditional branches (Tycho item 49). A
When/Whichwhose value is a tuple body — a restricted parametric(x(t), y(t))with\{0 \le t \le 1\}— compiles the value to avec2, 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'sselectrequires matching operand types); scalar bodies are unchanged. -
Sign (
sgn) handler audit. A mathematical-correctness pass over all ~69sgnhandlers fixed a dozen wrong claims (each could mislead simplifications or comparisons built onisPositive/isNegative):Gamma(0)andGamma(-n)reportedzero/indeterminate instead of recognizing poles;Logwith a negative base claimed a real sign (the sign only flips for a base in (0,1));Truncate(1/2)claimedpositive(truncation of |x| < 1 is 0);Round(-1/2)claimedzerowhileevaluaterounds halves away from zero (−1);GCD(0,0)andLCM(0,n)claimedpositive(both are 0);Floor/Ceilof 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 forFactorial2);Abs(NaN)claimedpositive;Random(-5, 5)claimednon-negative; tensorRankof a scalar claimedpositive(it is 0); and a latent parity inversion inMultiplyswappednon-negative/non-positivefor products of sign-indefinite factors.Arctannow 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 5devolves the un-applied builtinNto 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 laterN \coloneq 11was 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.
| Expression | CE (current) | CE 0.84.1 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 7.0 | 12 | 184 | 110 | 4.0 |
\sin 1 | 21 | 26 | 226 | 479 | 5.3 |
\cos 1 | 21 | 25 | 230 | 667 | 7.1 |
\ln 2 | 14 | 18 | 356 | 5,093 | 3.1 |
e^{\pi} | 12 | 17 | 215 | 4,862 | 4.5 |
\zeta(3) | 1,573 | 1,620 | 268 | — | 49 |
\Gamma(\tfrac13) | 914 | 921 | 366 | — | 225 |
\psi(\tfrac13) | 749 | 743 | 6,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.
| Operation | CE (current) | CE + R/F | CE 0.84.1 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 6.4× | 2.7× | 3.4× | 0.4× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 9.6× | 1.7× | 6.0× | 0.09× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 7.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\,x | 79× | 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)^x | 9.6× | 5.1× | 4.5× | 2.3× | — | 1× |
\int_1^2\tfrac1x\,dx | 6653× | 6680× | 2654× | 63× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 402× | 104× | 159× | 2.5× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.2× | 0.2× | 0.1× | 0.07× | — | 1× |
x^3-x-1=0 | 1.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 Engine0.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/NotEqualover a possibly-collection operand now compile on thejavascripttarget with an interpreter-faithful runtime dispatch. A comparison likeq(2) = 9whereqis 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 (withinengine.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,falseon 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]whereLis declared(number) -> unknownand 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 lazyMapnow yields numeric elements. The 0.84.0 fix wrapped a lazy broadcast's elements inNonly when the broadcast was constructed under.N(); calling.N()on an already-evaluated lazyMapwas an identity, soSin(Range(1, 200)).evaluate().N()streamed exact elements (sin(1),sin(2), …) from botheach()andAt. Requesting a numeric approximation of a lazyMapnow rewraps its mapping function so every element floats on access — restoringx.evaluate().N()≡x.N()— whileevaluate()alone still keeps elements exact and the result stays lazy (O(1) onSin(Range(1, 10^8))).
0.84.0 2026-07-17
Resolved Issues
-
SortandShuffleof a non-Listcollection 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))producedRange(1, 2, 3)— the one-element list[1]— andShuffle(Range(1, 5))could produce an empty collection. Both now return aList, matching every other eager collection operation.Sortof 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, soLast,At(xs, -1)and anything built on end-relative access silently returnedNothing— or worse:Reversewalks its source from the end, soListFrom(Reverse(Range(1, 5)))returned[]instead of[5,4,3,2,1]. Normalization is now centralized in the index dispatcher and works forRange,Linspace,Zip,Scan,Differencesand every other indexed collection with a known finite length; infinite or unknown-length collections correctly returnNothingwithout enumerating. -
A
Filterover more than ~1000 elements crashed numeric canonicalization. Determining whether aFilterresult was finite ran itscounthandler, which walks the source applying the predicate — and throwsiteration-limit-exceededpast the iteration limit. Boxing an expression as simple asFilter(Range(1, 100000), p) + 1threw. Finiteness and emptiness of aFilterare now answered structurally (a filter of a finite collection is finite) without running the predicate, andcountof a filter of an infinite or unknown-length collection now correctly reports unknown instead of claimingInfinity(a filter of an infinite collection can be finite:Filter(Range(1, ∞), x < 5)has 4 elements). -
IsEmptyandContainsno longer answerFalsewhen the answer is unknown. Both coerced an undetermined result to a definiteFalse:IsEmpty(Filter(Range(1, 10^5), x ↦ False))— a collection that is empty, but whose emptiness can't be established within the iteration limit — returnedFalse. 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,Blockor 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()andforget()— including the implicit revert when a scope that assumed exits — all refresh it, and a comprehension nested under aSum/Productrefills for each value of the enclosing binder. Unrelated evaluations between reads leave the cache intact. -
IdentityMatrix,ZeroMatrix,OnesMatrixandDiagonalof 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 withDeterminant,Inverseand the rest of the dense linear-algebra operations. -
.N()of a lazily-broadcast operation yields numeric elements. When a broadcast returns a lazyMap(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, whileevaluate()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-lengthRangenow returns a lazyMapsupportingFirst,At,TakeandLength, rather than staying an inert expression:Add(Cycle([1,2]), 1)is the lazy[2,3,2,3,…], andAdd(Range(1, n), 1)withna declared, unassigned integer becomesMap(Range(1, n), _ ↦ _ + 1), which picks upn's value reactively when later evaluated. This also fixes a wrong-answer bug: the eager broadcast read aFilter's unknown length as 1, soAdd(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 lazyMapinstead of materializing every element:Add(Range(1, 10^8), 1)andSin(Range(1, 10^8))evaluate instantly to a lazy collection supportingAt,Take,First,LastandLengthwithout enumeration. Collections of 100 elements or fewer are unchanged and still evaluate eagerly to aList. -
Length,Count,IsEmptyandContainssee through wrappers that cannot change their answer.Sort,ShuffleandReversepreserve element count, and (together withUnique, forContains) preserve membership, so these consumers now strip such wrappers at canonicalization:Count(Sort(xs))becomesCount(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
Mapover 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
compilehandler on an operator definition emits target source for that operator when the expression is compiled; returningundefinedfalls 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—javascriptorpython). 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
Modin a product or a power base now serializes parenthesized, fixing a round-trip corruption. Juxtaposition (invisible multiply) and superscripts bind tighter than infix\bmodon re-parse, so an unparenthesizedModfactor absorbed the adjacent notation into its trailing operand:["Multiply", ["Mod", "A", 2], ["Mod", "B", 2]]serialized toA\bmod2B\bmod2, which re-parses asA 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 ofA\bmod2^{x}(which re-parsed asA mod 2^x). Same class as the 0.79.2 compound-operandModparenthesization 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 twoMod(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 unrecoverableincompatible-typeerror at canonicalization. An application of an undeclared function symbol (["f", "k"]) flowing throughAdd,Multiply, orPowerlifts the surrounding expression tobroadcastable<number>; a non-threadable operator with anumberparameter (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. Abroadcastable<T>operand could be a plain scalarTat runtime, so validation now admits it wheneverTmatches the parameter, exactly restoring the pre-0.82.0 admission. LaTeX input was largely unaffected (undeclaredf(k)parses as the productf \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 ofv_1. The bracket form is the round-trip-safe notation:v[1]always parses back toAt, while the subscript formv_1only does whenvis declared as an indexed collection — otherwise it re-parses as the unrelated subscripted symbolv_1, silently changing the meaning on a serialize→parse cycle. The previous behavior remains available engine-wide viace.latexOptions.indexStyle = () => 'subscript'or per call viaexpr.toLatex({ indexStyle: () => 'subscript' }). (Requested by the Tycho/Graph Paper team, whose per-callindexStyleopt-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), andRank. Previously these threw at compile time and fell back to the interpreter. Note thatRankis the tensor rank — the number of axes (scalar0, vector1, matrix2) — not the linear-algebra (row) rank.
Resolved Issues
-
The
javascriptcompile target now lowers a reduce (Sum/Product) and rank-dispatched multiplication over anunknown- orbroadcastable-typed collection operand, where it previously failed closed and fell back to the interpreter. A collectionSum/Productover such an operand reduces under a runtime guard (a scalar at run time still matchesSum(scalar) = scalar), with an element-wise-aware combiner so a nested (matrix-valued) element reduces correctly rather than string-concatenating. AMultiplyof 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(…) -> unknownrender 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 aTypedascription, which the compile targets did not handle, so every compiled call threwUnknown operator `Typed`.Typedis a transparent, no-op-at-runtime ascription and now compiles to its value operand on every target. -
Multiplyof 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/Clampbroadcast 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 scalar3. -
Broadcasting an element-wise operator over an
N×1column 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 amatrix<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
Comprehensionbody containing a scoped subexpression now sees the iteration index correctly. When the body contained aBlock(e.g. awith-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]withf(a,b) := [{a>b: b, a}, a-b]returned expressions still containingaandb) — 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.countreads during a paused iteration are unaffected), and evaluating a canonicalComprehensionno 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 ofAdd/Multiply(untaken branches were, and are, never evaluated). -
Fixed a stack overflow when evaluating
Negateof an indexed collection that cannot be materialized, such as aRangewith symbolic bounds reached inside a comprehension body (-Range(0, m + 5)withmunbound): the element-wise distribution retried the same non-distributable negation without progress.
0.82.0 2026-07-17
Breaking Changes
-
Multiplyof 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 scalar32. This makesMultiplyover lists consistent: element-wise is whatAdd,Power(k^2), scalar scaling (2k), and symbol-bound list operands (k \cdot kwithk := [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 ksilently collapsed a 3-element family to one scalar). For the dot product, use the explicitDotorMatrixMultiplyoperators, 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·vis(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-lengthvector·vectorcompiles 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 theReduceshape 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
Listinstead 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 ann-element indexed collection now produces ann-elementListfor everyn ≥ 1, matching the expression's staticlist<…>type (avector<1>operand no longer typeslist<number>while evaluating to a scalar) and the user-function broadcast path, which already returned aListfor single-element collections. An empty broadcast still evaluates toNothing.
Resolved Issues
-
Sum/Productover 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. theAddof["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 namedi(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 heldLimitsindex outside its binding scope, re-typingias the imaginary unit: the index slot became anincompatible-typeerror and serialization dropped the index (\sum_1^9…). Held (non-canonical) operands now stay raw throughsubs()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 clean0..N()of\int_{-\infty}^{\infty} x\,dx(andx^3,\sin x, any odd divergent integrand) returned an exact scalar0: 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 aMeasurementwith 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) - 1wherehmay return a list produced scalar code that yieldedNaNon a list value at run time (behindsuccess: true). Such operands — typedbroadcastable<…>, 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/NotEqualover a possibly-list operand, and — on the Python target, where*/+repeat or concatenate a plainlist— 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)-1withhreturningunknown) used to collapse to scalarnumber, even though evaluation broadcasts ifhreturns a list. Such expressions now typebroadcastable<T>— "aT, or an indexed collection ofT, applied element-wise". The type is produced byAdd/Multiplyand every broadcastable operator (Sin,Sqrt,Power,Abs, …) over an operand whose type is a top type (an unknown-return call) or alreadybroadcastable; 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 typenumber). Relatedly, a scalar function over a fixed-shape-typed intermediate no longer collapses either:\sin(10^4 \cdot [1,2,3])— whose inner product typesvector<3>— now typeslist<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>andlist<number> <: broadcastable<number>, butbroadcastable<number>is not a subtype ofnumber(it may be a list). The type can be used in declarations (ce.declare('b', 'broadcastable<number>')) and signatures. Bare symbols are unaffected: an undeclaredxin2xstill 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))wheregreturns a list), and for every body — previously a non-arithmetic body such asx \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, whereRis 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
PointListoperator (the point-list surface form).PointListis the explicit, importer-emitted operator that zips a point-with-collection into a list of points:PointList(-6, n)withna 21-element list evaluates to the 21-element list of points, whilePointList(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 plainTuplenow stays inert data — it never transposes — so tuples used as data are genuinely unaffected; tuple-with-collection arithmetic still scales component-wise, with no bakedincompatible-typeerror, so a definition such asm(P) \coloneq P + s(P)\cdot(1, 0.3n)stays valid. (An earlier, evaluate-timeTuple-transpose of this idiom was replaced by the explicitPointListoperator before it ever shipped in a release.) PointListcompiles on thejavascript,glsl, andwgsltargets. With all-scalar components (including free plot variables), it emits byte-identically to the equivalentTuple([x, y]/vec2(x, y)/vec2f(x, y)), so point literals rewritten toPointListstay 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 theinterval-jstarget (whereTupleitself has no lowering).Atdefers 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]withhreturningunknown), 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 reportincompatible-typeat canonicalization, and a base that turns out scalar at runtime errors at evaluation.- Pipeline contract test suite.
test/compute-engine/pipeline-contracts.test.tspins the guarantees for MathJSON-carrying pipelines: non-canonicalbox(json).jsonstructural fidelity (with its documented normalizations), non-canonical.latexround-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 sizen, notngroups.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, useChunkinstead (Chunk([1, 2, 3, 4, 5], 2)→[[1, 2, 3], [4, 5]]). A new sliding-window formPartition(xs, size, step)returns the complete windows ofsizeelements whose starting positions arestepapart (Partition([1, 2, 3, 4, 5], 2, 1)→[[1, 2], [2, 3], [3, 4], [4, 5]]). The predicate formPartition(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 …orce.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 widevaluetype (e.g. any bare symbol that had passed throughMax/Min-style(value*)signatures on the same engine) made subsequent parses of2xsilently produceTuple(2, x)instead ofMultiply(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 treatsvaluelikeunknownand 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 symbolsintimesx^2withisValid: true— silent wrong math; it now parses to\sin(x)^2with 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, andarg; bare identifiers (sinwithout\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}wasGCD · 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}yisSin(x·y)and\sin{x}^2isSin(x²), matching\sin x yand\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)→ loudincompatible-typeerror) and a postfix power stole the argument group (\operatorname{mod}(-x,1)^{2}parsed silently asMod · ((−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.Aton a non-indexed base) always threw out of a compilation target'scompile(); passing the new{ fallback: true }option returns the documented{ success: false, error, run }shape instead, withrunfalling back to the interpreter — matching the engine-levelcompile()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 + yto a name declared(number) -> numberwas silently accepted, andf(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/Ifguard over an undetermined symbol), the application returned the body inert with the parameter unsubstituted — soMap([1,2,3], k \mapsto \operatorname{Which}(k = m, 10^9, k))yielded three identical copies of the raw body withkleaked free and the elements gone. The parameter's value is now substituted into the held result, fixingMap,Filter,Tabulate,Zip-with-function, and directApplyin one place. - Lazy collections serialize faithfully.
.latexof a canonicalMap,Filter,Zip,Tabulate,Range,Linspace, orComprehensionno longer materializes an elided or value-baked preview (which could re-parse to a corrupt expression — aMapover 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
Takewas off by one.Take(xs, n).at(-1)returned the second-to-last element of the taken prefix (at(-1)onTake([10, 20, 30], 2)was10instead of20). - The display preview of a lazy
Takesampled the wrong tail. DisplayingTake(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 — beforeTakeconsumed them. - A boolean
Sortcomparator now orders instead of silently doing nothing. A comparator returningTrue/False(e.g.(a, b) -> a > b) never reordered — only signed-number comparators worked. Boolean comparators are now interpreted Elixir-style:Truemeans the first argument sorts first, so(a, b) -> a > bsorts descending. - A mistyped
GroupBykey 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, likeFilterandPartitiondo for broken predicates. Grouping by explicitly declared symbolic functions is unaffected. - The optimization form of
ArgMax/ArgMincanonicalizes 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
Comprehensionnow serializes to LaTeX that round-trips. Its.latexwas an elided display preview (\lbrack 1, 4, 9, \dots, 62\,500\rbrack) that silently re-parsed to a corrupt 11-elementListcontaining a literal\dots; it now serializes through the faithfulbody \operatorname{for} var = domainform, which re-parses to the identical comprehension (including tuple bodies, dependent domains, and infinite domains). - Lazy
Comprehensionelements 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) threwCondition 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/LCMreturned wrong numbers. The compiled form passed a third operand into the internal tolerance slot (GCD(2.25, 2.1, 0.6)compiled to2.1, silently consuming the0.6as ε;GCD(12, 18, 8)→6instead of2;LCM(4, 6, 10)→12instead of60), and a collection argument (GCD([12, 18])) compiled toNaN. 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. Withf(x) := x^2declared, compiling\sum_{f=1}^{3} femitted references to the function instead of the loop index (returning garbage withsuccess: true; a null interval oninterval-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
NaNsample. A single integrandNaNat a quadrature node (e.g. the removable singularity of\sin(x)/xat 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 \, dxconverges to2\,\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
.countmid-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 gives11, 12, 13, not13, 13, 13). - GPU
gcdregressed on large integers. The tolerant float loop shipped in 0.80.0 dropped the exact-integer path onglsl/wgsl:gcd(4000000, 2)returned4000000. Exact Euclid is restored for integer inputs within f32 range. - Tolerant
GCD/LCMinvariants. Scale-mismatched inputs violatedgcd ≤ min/lcm ≥ max(\gcd(2.5, 10^{21})→10^{21}); zero-argumentGCD()/LCM()crashed (now the identities0/1); nested collections now fold in a single evaluation. Max/MinabsorbNaNfound inside collections:Max([1, NaN, 3])now returnsNaN, consistent withMax(NaN, 5)and with compiled code.IndexOfon infinite lazy collections hung indefinitely, ignoringce.timeLimit. The search now streams (linear instead of quadratic on lazy collections) with deadline checkpoints. CompiledIndexOfalso 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 ignoredce.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^2no longer emitsjuxtaposition-as-multiply/undeclared-symbolfor the definition's own head, and symbols declared through thegetSymbolTypehandler are no longer reported as undeclared. - Custom
compilehandler 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), andanalyzeReferencesno longer reports custom-compiled operators asunsupported. - Assorted: applying a non-numeric symbol to a collection (
t(\{1,2\})withta string) reports an application type error again instead of a confusingMultiplyerror;PointX/PointYwork onSets of points (previously returned[]); a provider timeout insideIntegrateis re-thrown instead of swallowed;\operatorname{erf}at a directionless complex infinity stays symbolic instead of saturating to1; compiledMap/Filterno 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 cnow parses asCircleTimes(Multiply(a,b), c), notMultiply(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 (Negatefolded likeSubtract, returning-6behindsuccess: true). Non-arithmetic operator symbols and combiners of the wrong arity now fail closed to the interpreter. Also: compiledReduceover an empty collection without an initial value returnsNaNinstead of throwing, andTabulatewith a statically non-positive dimension fails closed instead of returning[]. Flattenwith no depth now fully flattens ragged lists.Flatten([[1,x],[2]])was a no-op (only uniform tensors flattened), which became visibly inconsistent onceFlatten(expr, depth)shipped; the default now flattens completely, per the documented (Wolfram) semantics.Scanno longer silently drops an invalid initial value — the error is surfaced instead of computing the unseeded scan.ElementMax/ElementMin/Clampon thepythontarget now match the interpreter's broadcasting. Length-mismatched arrays previously raised a NumPyValueError(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.bodyis canonical for every declaration route. The public accessor returned a raw, non-canonical body for functions declared with a MathJSONevaluatehandler (parse/assign routes were fine), tripping canonical-only asserts in consumers; it now returns the canonical scopedBlockshape everywhere. AndanalyzeReferencesnow probes a customcompilehandler per target language, so an operator whose handler only supports some targets is correctly reported inunsupportedon the others.
New Features
- New collection operators. A batch of higher-order and structural
collection operators (see the
Collections reference):
- Quantifiers —
Any(xs, predicate?)andAll(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([])isFalse,All([])isTrue. - Cumulative —
Scan(xs, f, initial?)is the running fold (same length as the input:Scan([1, 2, 3, 4], Add)→[1, 3, 6, 10]), andDifferences(xs)gives the successive differences (lengthn − 1, computed exactly). - Prefix/suffix —
TakeWhile(xs, predicate)andDropWhile(xs, predicate)take or drop leading elements while the predicate holds; both are lazy and compose with infinite collections. - Mapping —
FlatMap(xs, f)mapsfoverxsand 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 keyf(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 keyf(x);Dedup(xs)collapses consecutive duplicates only (contrastUnique, which removes all duplicates). - Functional element updates —
Insert(xs, index, value),DeleteAt(xs, index), andReplaceAt(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);Insertat-1orn + 1appends.
- Quantifiers —
Mapis now variadic.Map(xs, ys, …, f)appliesfelement-wise across several collections (azipWith), 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]).Sortaccepts a one-argument key function. In addition to a two-argument comparator,Sort(xs, f)with a unaryfsorts ascending by the keyf(x)(stable on ties), discriminated from a comparator by arity.Flattenaccepts an optional depth.Flatten(xs, depth)flattens onlydepthlevels of nesting; without it, the collection is fully flattened (Flatten([[1, [2]], [3]], 1)→[1, [2], 3]).- More collection operators compile on the
javascripttarget.Reduceaccepts a custom combiner: aFunctionliteral (Reduce([1, 2, 3], (a, b) \mapsto a + 2b, 0)→12), a user-defined function symbol, or an operator symbol such asSubtract— previously only theAdd/Multiply/Min/Maxfolds compiled. A custom combiner requires an explicit initial value (without one the interpreter folds fromNothing, which has no numeric equivalent — that form still fails closed).Fold, which canonicalizes toReduce, now compiles too.Tabulate(1-D and 2-D) andFillcompile to native array construction with 1-based indexes — and thereforeTable, in both its alias and Mathematica-style iterator forms. Compiling is the natural fast path for materializing these now-lazy collections.CountIf,Find,IndexWhere,Positioncompile their predicate lambda to native array operations, with the interpreter's conventions preserved: 1-based indexes,IndexWhere→0andFind→NaN(Nothingprojected 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, andShufflecompile to native array operations, each verified element-for-element against the interpreter (1-based inclusiveSlicewith negative-from-end indexes, rotation shift normalized modulo the length,Chunk/Partitionproducingkchunks of⌈len/k⌉, stableOrderingties,Ziptruncating to the shortest input,Linspaceincluding both endpoints). Non-finite runtime counts/indexes fall back to the interpreter's defaults instead of crashing or silently diverging, andShufflehonors the engine'srandomSeed(a deterministic, reproducible permutation, likeRandom). Forms with no numeric equivalent on the target fail closed: a customOrderingfunction, the explicit-seedShuffleform, statically non-positiveChunk/Partitioncounts, andContains/Uniqueover compound (nested-list, tuple, complex) elements, whose JS equality is referential rather than structural.Any,All,TakeWhile,DropWhile,FlatMap, andScancompile (predicate/mapping lambdas → nativesome/every/flatMapand slices;Scanis 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
javascripttarget:Boole(Iverson bracket, with the same runtime boolean guard asWhich/When),KroneckerDelta(n-ary, tolerance-aware like compiledEqual),Element(x, list)membership,Identity, andApplyof a function literal. - Linear algebra compiles on the
javascripttarget (closing the parity gap with thepythontarget):DotandMatrixMultiply(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, andReshape(with the interpreter's cyclic padding). - The
pythoncompilation target now covers collections and function literals.Functionliterals compile to Python lambdas, and the collection operators above (list access/slicing,Map/Filterand the other higher-order operators,Reduce/Scan,Tabulate/Fill,Linspace,Zip,Ordering, plusFlatten/Shape/Reshape/Tracevia NumPy) emit Python with the same interpreter-verified semantics, validated by executing the emitted code (venv-gated parity suite). Also fixed: compiledRangewas off by one on the Python target —np.arangeexcludes the stop value and is 0-based in the one-argument form, soRange(2, 6)compiled to[2..5]andRange(5)to[0..4]; CERangeis inclusive and 1-based. - Compiled
Rangewith no explicit step now auto-descends on both targets, like the interpreter:Range(5, 1)→[5,4,3,2,1]andRange(-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
OperatorDefinitioncan now supply acompilehandler —(args, compile, { language }) => string | undefined— that emits target-language source for a call to that operator. It mirrors a built-in compiled-function handler (recursivelycompileoperands, branch onlanguageforjavascript/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-toleranceGCD), or add compilation for an operator the target doesn't know. Returningundefinedfalls back to the default compilation. This replaces the never-wired, interpreter-onlyxcompilestub. SeeOperatorCompileHandler. ElementMax,ElementMin— element-wise (broadcasting) maximum and minimum (the NumPymaximum/minimumprimitive). UnlikeMax/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(nativemax/min), andpython(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 nativeclamponglsl/wgslandnp.cliponpython.
Resolved Issues
GCD/LCMnow evaluate on non-integer real arguments.\gcd(2.25, 2.1)and\operatorname{lcm}(2.5, 1.5)previously stayed symbolic (and compiled toNaN, 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)underevaluate(),0.15under.N()) behavior. The compiledjavascript,glsl, andwgsltargets fold reals the same way (the GPU_gpu_gcdcutoff 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 asr ≤ gcd(θ², θ + a).)GCD/LCMreduce a finite collection argument.\gcd([12, 18, 24])→6and\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/Minof a scalar and a collection no longer mis-compiles toNaN.Max(0, [1, -2, 3])evaluates to3(the reduction folds the collection's elements), but on the JavaScript target it compiled toMath.max(0, [1,-2,3])— passing an array as an argument — and returnedNaNat run time. It now folds the scalar and every collection's elements into a single reduction, matchingevaluate(). (Surfaced by the Tycho/Graph Paper team'smax(0, …)plot expressions.)Max/Mincompile correctly on thepythontarget. They were mapped to the element-wise, strictly-binarynp.maximum/np.minimum, so a collection operand was mis-reduced (Max(0, [1,2,3])→[1,2,3]instead of3) and a single-list or n-ary call errored at run time.Max/Minnow reduce a collection operand withnp.max/np.minand combine the per-operand results element-wise (keeping a scalar/array operand — e.g. the plot variable — vectorized), matchingevaluate()and the JavaScript target.At(base, index)serialization now parenthesizes a compound base.At(x+1, 2)serialized tox+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 toAtindependent of the base's declared type — remains available viaindexStyle: () => 'bracket'.)
Collections
- A list comprehension is now a lazy collection:
evaluate()no longer walks its whole domain. AComprehension([body \operatorname{for} i=…]) behaves likeRange/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 positiveat(n)walks just the firstn); 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
Listwrapping it.[body \operatorname{for} i=…]previously produced["List", ["Comprehension", …]], which reportedcount: 1and mis-indexed (W[3]→NaN); it now returns theComprehensiondirectly, mirroring theRange/Linspacebracket passthrough. (Reported by the Tycho/Graph Paper team.) Tabulate(and itsTablealias) is now a lazy indexed collection.evaluate()returns theTabulateitself rather than building the whole array;.countis the outer dimension and an element is computed by applying the function only when indexed or iterated. ATabulate(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-styleTable(i^2, {i, 1, n})inherits this (it canonicalizes toTabulate).PermutationsandCombinationsare now lazy collections with closed-form counts.Permutations(xs, k?)andCombinations(xs, k)no longer materialize their factorially-many elements to be bound, counted, or indexed:.countisP(n, k)/C(n, k)computed directly (previouslyPermutationsof a 9-element list took ~33 s just to answer.count), elements stream from the iterator, andat(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)— wherefis a function declared on the engine (f(x) := …,x ↦ …) rather than an inline lambda — previously emitted a dangling_.fand 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. ItsfreeSymbolsare computed from the operand function's body (so a free symbol used only insidefis reported), and inline-lambda operands are unaffected. (Reported by the Tycho/Graph Paper team.)
API
BoxedOperatorDefinition.lambdaexposes a user-defined function's body and parameters.ce.lookupDefinition(name).operator.lambdareturns{ 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)), orundefinedfor 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))withka number andSa bound list), it parsed askapplied to the body — an illegal application of a number, yieldingNaN— instead ofk\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 thef(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 wasunknownoranyand the argument symbol had been declaredunknownand assigned a value, inference computedunknown(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 tounknown. So merely parsingf(S)(noevaluate/N) silently clearedS's assigned value, leaving it unbound and every dependent expressionNaN. Inferringunknownadds 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/.zpoint-coordinate accessors now broadcast over a list of points. They previously parsed toFirst/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)].xreturned the first point(1,2)instead of the list of x-coordinates[1,3,5]. The accessors now parse to dedicatedPointX/PointY/PointZoperators that extract a coordinate and map element-wise over a list of points (matching the threadable.real/.imagaccessors), while a single point still returns the scalar coordinate.First/Second/Thirdare unchanged and continue to index a collection. The new operators broadcast on thejavascriptcompile 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
javascriptcompile 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 listsL + M— previously compiled to scalar JavaScript that returned garbage (-_.L + _.x→NaN) behind asuccess: 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.bcastruntime 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/.ypoint-broadcast change above, a compiled expression such as\min((x - V.x)^2 + (y - V.y)^2)over a list of pointsVnow evaluates correctly. (Reported by the Tycho/Graph Paper team.)
0.79.2 2026-07-15
Compilation
- The collection form of
Sum,Product,Max, andMinnow compiles on thejavascripttarget. Applied to a collection with no indexing set — e.g.[3,4,5].\operatorname{total}(which canonicalizes toSum([3,4,5])), a list product, or\max(v)for a listv— these previously threwSum: no indexing set(dropping the whole expression to interpretation) or, forMax/Min, compiled toMath.max([…])and returnedNaN. 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/\maxcompile 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
javascripttarget.Last,Rest,Take,Drop,Join,Reverse,Sort,IndexOf,Map, andFilterpreviously fell back to interpretation (Unknown operator); they now lower to native array operations (.slice,.reverse,.sort,.indexOf,.map,.filter, …).Take/Dropclamp a negative count to match the interpreter (Take(xs, -2) = [],Drop(xs, -2) = xs);ReverseandSortcopy first so the source is not mutated;Sortcompiles the default ascending numeric order (a custom comparator fails closed);IndexOfis 1-based (0 when absent);Map/Filtercompile their lambda operand. A non-indexed-collection operand fails closed. (Requested by the Tycho/Graph Paper team.)
Evaluation
IndexOf/IndexWherenow work on a tensor-backed list. A rectangular numeric list is represented as aBoxedTensor, which inherited the abstract no-opindexWhereand so madeIndexOf/IndexWherealways return0(not found) even for a present element. The baseindexWherenow 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\bmodbinds tighter than+/-on re-parse, soMod(x+5, 2\pi)serialized tox+5\bmod2\piand re-parsed asx + (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-nestedModis also now parenthesized (\bmodis right-associative, soMod(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_collectionorcollectiontype but not yet assigned a value — e.g.y_riny_r\sin(a)— grouped into aTupleinstead of aMultiply, even though its concrete subtypes (list,vector,matrix, numerictuple) and the assigned-value case already multiplied. The abstract collection type now scales like its subtypes; non-indexedsetand heterogeneoustupleoperands still group as aTuple. (Reported by the Tycho/Graph Paper team.)
0.79.1 2026-07-15
Evaluation
ce.timeLimitis 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)^pinto a multinomial withC(p+2, 2)terms (≈ 6·10⁴ atp=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 atce.timeLimit(default 2 s) as intended, and a bareevaluate()of such an integral throws a catchableCancellationError(cause: 'timeout') rather than running unbounded. (Reported by the Tycho/Graph Paper team.)
0.79.0 2026-07-14
Evaluation
ce.timeLimitis 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 iterations(y, x_p) := \frac{y - f(x_p)}{f'(x_p)} + x_papplied ass(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 honoringtimeLimit. A cooperative checkpoint on the per-node evaluation path now cancels such evaluations at the deadline with a catchableCancellationError(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 exceedingtimeLimitnow throw — raisece.timeLimit(or set it to0for 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
Solveaccepts 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)].Elementitems inside aSet/List/Andfirst operand are lifted into per-variable domain specs (equivalent to passinga \in Das 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 aSet(orAnd) now also solves like the equivalentList.- 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 explicitElementdomain 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 definitelyFalse) 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 ofSum,Product,IntegrateandD:\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 (aniindex does not collapse to the imaginary unit). - New
Tableoperator, an alias forTabulate.\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 toTabulate; general bounds map toMapoverRange.Tabulatealso gained thetablesearch keyword soce.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 aList/Andof 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
ReplaceAlloperator.\mathrm{ReplaceAll}(x^2+x, x\to 2)→6(Mathematicaexpr /. rules). Rules arelhs \to rhs(orRule(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 toFalse. 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 asPseudoInverse(0)/Superminus(0), making every one-sided limit unevaluatable. The marker now maps toLimit'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. \mapstolambda bodies extend through comparisons.n \mapsto n > 102now parses asn \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 madea \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
Interpretno longer spends ~13 s rejecting a non-polynomial sequence. Interpreting a continuation such as1 + 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-decayno longer returnNaN. A definite integral to±∞whose antiderivative carries a term likey^2 e^{-y}was evaluated at the infinite bound by naive substitution, producing an∞·0indeterminate that collapsed toNaN— so\int_0^\infty y^2 e^{-y}\,dy(which is\Gamma(3) = 2) returnedNaN. 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}\,dyto3\sqrt{2\pi} - F(x). An endpoint that still cannot be resolved keeps the integral inert (soN()quadrature applies) rather than leakingNaN. Limitat infinity resolvesErf/Erfcand\sqrt{}/\sqrt[n]{}.\lim_{x\to\infty}\operatorname{erf}(x) = 1,\lim_{x\to-\infty}\operatorname{erf}(x) = -1,\operatorname{erfc}saturating to0/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}\,dtvanishes for any finites(thee^{-t}factor dominates), so\Gamma(s, \infty)— including symbolicssuch as\Gamma(\frac{k}{2}, \infty)— now evaluates to0instead 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}}\,dyto 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}\,duspun for ~109 s (ignoring a 3 stimeLimit) 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 to1(coprime over the coefficient field), and the loop carries a deadline checkpoint as a backstop. The integral closes in ~200 ms (to anArcTanh/Lnform with the Rubi rules loaded);polynomialGCDof coprime symbolic polynomials returns1instead of spinning.
Compilation
- Compiled definite integrals now resolve symbolically before falling back to
quadrature.
compile()of an expression containing a definiteIntegratefirst attempts a closed form (the same antiderivative machineryevaluate()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}\,dtcompiles to its closed form0.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 byce.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 thevarsoption (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-multiplynow 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), whereNreads as the newton unit) fires withdetail.nameset to the source symbol and a new additivedetail: { lexedAs: "unit" }hint; and a letter-run applied to a group (divisors(60), which segments intod·i·v·i·s·o·r·s) fires a single diagnostic whosenameis the joined run ("divisors") and whose span covers the fulldivisors(60)source shape. Run reconstruction stops at numbers and multi-character commands (2x(3)reportsx;\pi r(2)reportsr).
0.78.0 2026-07-14
Parse Diagnostics
- New opt-in
diagnosticsparse option.ce.parse(latex, { diagnostics: true })attaches aparseDiagnosticsarray 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 }), andrecovered(trailing noise silently skipped by non-strict error recovery). Each carries astart/endsource span:undeclared-symbolandjuxtaposition-as-multiplyare offsets into CE's normalized LaTeX, whilecomment-discarded(and best-effortrecovered) use original-input coordinates. The feature is purely additive — enabling it never changes the parse output — and works under{ canonical: false }.parseDiagnosticsis present (a possibly-empty array) only on the result of adiagnostics: trueparse, andundefinedotherwise.
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 to0instead off'evaluated at 2 (e.g.6forf(x) := x^2+2x+1), andf'(2x)picked up a spurious chain-rule factor (8x+4instead of4x+2). Higher orders (f''(2)→2) and thef^{(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 byf(x) := x^2 + 2x + 1— left the function's derivative inert:f'(x)evaluated to itself instead of2x + 2, and["D", "f", "x"]evaluated to0, even though direct calls likef(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
Matchoperator 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 likePi, or the current value of a variable);["Alternatives", p1, p2, …]shares one body among several binding-free patterns. UnlikeWhich, which stays unevaluated while a condition is undecidable,Matchalways decides — a symbolic subject falls through to a wildcard case. No matching case yields an["Error", "'match-no-case'"]value. - Cortex:
matchexpression. The reservedmatchkeyword 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 likePi => …is a parse error suggesting== Pito match the constant);== exprpins a value;|gives or-alternatives;[…],(…)and{key -> pat}destructure lists, tuples and dictionaries (open matching);n: integeradds a type guard;...restcaptures 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 JavaScriptswitchfor 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
Functionparameter 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) -> integerinstead of(unknown) -> number. Annotations feed body type inference, and in strict mode arguments are checked at application: applying2.5to anintegerparameter yields anincompatible-typeerror 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
Typedoperator 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, andserializeCortexreconstructs 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
MaporFilterapplied 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 piecewiseWhich(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)), andWhich(x > 0, 1, x < 0, -1) + 2→Which(x > 0, 3, x < 0, 1). Logic operators are excluded (soAnd(When(A, g), False)still short-circuits toFalse), and piecewise products above 16 combined branches stay unevaluated. - Restriction guards survive arithmetic cancellation. Evaluating
When(x, c) − When(x, c)previously folded to plain0, silently discarding the restriction on the (fat) region wherecfails; it now yieldsWhen(0, c). Similarly0 · When(x, c)→When(0, c)andWhen(x, c) / When(x, c)→When(1, c). Whenrespects 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'is19/2°), andDegreesconverts any rational — not just integers — to an exact multiple of π, so5°37'30"simplifies toπ/32. Decimal components are recovered exactly when possible (9°30'15.5"→68431/7200°) and otherwise fall back to floats.Degreesof values beyond 2⁵³ no longer loses precision. In raw (non-canonical) parsing, DMS angles that previously produced a float now produce aRational. (#321)
Calculus
- Exponentials with any linear exponent integrate.
∫e^{−ax}dxwith a symbolicapreviously stayed unevaluated (onlye^{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)dxwith a freeapreviously stayed unevaluated; it now returns1/a {0 < a}. Results that formerly leaked indeterminate endpoint forms are fixed:∫₀^1 xⁿdxreturned an expression containing0^(n+1), and∫₁^∞ x^(−s)dxone containing∞^(1−s); they now return1/(n+1) {0 < n+1}and1/(s−1) {1 < s}. Integrals whose endpoint behavior cannot be classified stay unevaluated rather than leaking indeterminates. Numeric-parameter integrals are unchanged. Seriesexpands 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 powersg^{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 − γ + …).Seriesexpands 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⁶), andSeries(x^x, x)→1 + x·ln x + x²·ln²x/2 + …. Base-blogarithms (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.Seriesof an irrational power no longer returns an invalid expansion.Series(x^π, x)previously produced coefficients containing unresolved0^{π−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 toxinstead of deferring:Series(ln x, x, +∞)→ln x, andSeries(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 parsedln Γ(x)) now returns Stirling's seriesx·ln x − x − ½ln x + ½ln(2π) + 1/(12x) − 1/(360x³) + O(1/x⁵). The series is asymptotic (divergent), so theBigOis placed at the true remainder order.Series(Γ(x), x, +∞)— the exponential of a trans-series — still defers. GammaLnevaluates to+∞at the poles ofΓ(the non-positive integers, whereln|Γ| → +∞— as in Mathematica'sLogGammaand SymPy'sloggamma); it previously stayed inert there. ConsequentlySeries(GammaLn(x), x)at such a pole now returns the log-aware expansion−ln x − γ·x + (π²/12)·x² + …(matching the parsedln Γ(x)) instead of an invalid expansion with inertGammaLn(0),Digamma(0), … coefficients.- Provably-exact expansions drop the
BigOremainder. When the truncated sum is symbolically equal to the whole function,Seriesnow 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 theirBigO.
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 returnsa² − 3 {0 <= a}(a square root is non-negative, so a real solution exists only fora ≥ 0). Substituting a concrete value resolves the guard:a = 2gives1,a = −2givesUndefined. Numeric right-hand sides are unchanged. - Trigonometric and hyperbolic equations with symbolic coefficients record
their validity condition.
Solve(sin(x) = a, x)previously returnedarcsin(a)andπ − arcsin(a)unconditionally — wrong whenever|a| > 1. Roots now carry their domain-of-validity guard — aWhenrestriction, displayedarcsin(a) {|a| <= 1}(LaTeX\arcsin(a)\left\{|a|\le 1\right\}). The guard resolves as soon as it is decidable: substitutinga = 1/2collapses the root toπ/6, whilea = 3yieldsUndefined, and a guard known false at solve time prunes the root (down to[]). Numeric-coefficient equations are unchanged. The same applies tocos(|ratio| ≤ 1),cosh(ratio ≥ 1), andtanh(|ratio| < 1) equations. Solveof 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-glslcompilation 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. Useinterval-js(CPU interval arithmetic) or the scalarglsl/wgsltargets instead. TheIntervalGLSLTargetexport from@cortex-js/compute-engine/compileis gone, andcompile(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 notationa[6]a[6]…— triggered exponential-time backtracking, so a few-hundred-character string could hang the parser for tens of seconds (andtimeLimit, 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 toChoose(n, r)·r!(= n!/(n−r)!), sonPr(5, 2)evaluates to20. Previously it parsed to an inert symbol and stayed symbolic underN(), silently producingNaNin a compiled function.
Arithmetic
Roundaccepts an optional precision argument.Round(x, n)roundsxtondecimal places —Round(2.567, 2) → 2.57,Round(1234.5, −2) → 1200— matching the Desmos/spreadsheetround(x, n)convention. Previously the second argument produced anunexpected-argumenterror. The single-argument round-to-integer form is unchanged, and the two-argument form compiles on thejavascriptandinterval-jstargets.- New
Rationalizeoperator for rational approximation.Rationalize(x)approximates a real number by a rational at full working precision (like single-argumentRational); 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
StirlingS1operator: 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 existingStirling(second kind).
Relational Operators
EqualandNotEqualbroadcast over a named list operand. WithR = [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 singleFalse. 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 thusd["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 anincompatible-typeerror. It is now typed as the value; a record indexed by a literal string gets that field's precise type. -
Reduce/Foldhonor the exactness contract. The compiled floating-point fast path no longer runs under plainevaluate(): exact operands fold exactly (Fold((a, k) ↦ a + 1/k, 0, Range(1, 5))→137/60instead of2.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, previously6). -
Mapinfers its element type from the mapped function. The result ofMap(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
Rangenow broadcasts like an eagerListin tuple products: withR = 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 - 2evaluates to[-1, 2, 7]instead of the unevaluatedAdd(-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 anincompatible-typeerror at canonicalization even though the eager-list form broadcast fine; the argument validator now recognizes parametrizedindexed_collection<T>wherever broadcasting applies. Declared types follow the values: broadcast results typelist<…>(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. -
StringFromjoins 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) }leftsq(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 explicitdoprefix — 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 statefulmakeCounter-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, andStringJoinover a list.true/falseare now input aliases forTrue/False(and reserved as binding names);1..nis a range (for k in 1..5), equivalent to the existing‥, without disturbing decimal literals like1.5; andStringJoinaccepts 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:
executeCortexemits a warning diagnostic with the suggestion (Quartile(xs)→ did you meanQuartiles?). 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 asce.suggestOperatorName(name).Argis now an alias forArgument.
Compilation
-
Calls to user-defined functions compile. After
f(x) := e^{-x^2/2}, compilingf(2)— or any expression referencingf— emits the definition as a named local function instead of throwingUnknown 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 thejavascriptandinterval-jstargets. -
Collection-valued conditions fail closed instead of compiling wrong code.
Equal/NotEqualover a collection-typed operand, andIf/Which/Whenwith a collection-typed condition, previously compiled withsuccess: trueand returnednullor 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, andAtcompile on thejavascripttarget.Reduce(xs, Add|Multiply|Min|Max, init?)compiles to a loop;Lengthreturns the element count;Atfollows the interpreter's 1-based, negative-from-end indexing (out-of-range yieldsNaN). -
GLSL:
Lengthno longer collides with thelength()builtin. CE'sLength(element count) compiled to GLSLlength()— the Euclidean norm — reporting success while computing the wrong value, or emitting invalid GLSL for lists longer than four.length()is now emitted forNorm; collectionLengthfails closed on the GPU targets. -
GLSL/WGSL: literal integer powers are sign-correct on the GPU.
x^3compiled topow(x, 3.0), which the GLSL specification leaves undefined for negative bases — real GPUs returnedpow(-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_powipreamble helper; negative integer exponents wrap the reciprocal. Fractional exponents still emitpow. -
The
interval-glsltarget 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 tointerval-jsand the scalarglsl/wgsltargets. 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 asProduct(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
Gammakeeps full relative accuracy through the overflow edge. Positive real arguments now use a balanced recurrence from the Lanczos core instead of reconstructing large values fromexp(gammaln(z)), which preserves about 15-16 digits up to the IEEE-754 limit nearGamma(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 typedAandBas numbers beforeDeterminantrequired 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 explicitExpandrequired) and numericizes withoutNaN. -
Infinite p-series support positive-integer lower bounds beyond 1.
Sum(k^(-s), k, a, +∞)now returnsZeta(s) − Sum(k^(-s), k, 1, a−1)for exact reals > 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 to1/2 + (√3/2)iinstead 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
BigOremainder last, matching LaTeX output. Canonical expression order and ordinary sums without aBigOterm are unchanged.
Parsing
- Stepped ellipsis ranges accept negative and symbolic samples.
[-9,-6,\ldots,9]now parses toRange(-9, 9, 3)— previously any negative leading sample fell back to a literal list containing aContinuationPlaceholderthat enumerated asNaN. 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 exportedCancellationCausetype. In Cortex, a final-statement breach carries the cause as a second operand on theErrorvalue, and non-final statements emit a dedicatedevaluation-canceleddiagnostic. 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.317iand\operatorname{arcosh}(0.5)→1.047i. Complex arguments such as\operatorname{arsinh}(1+i)are also supported. Exact arguments remain symbolic withevaluate(). Incorrect complex values fromArcothon part of its branch cut and fromArsechhave 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
Solvehandles 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)]forx + 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/2now 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}\,dxnow 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,averagefindsMean), and LaTeX commands (for example,\gcdfindsGCD). Use the optionallimitargument to control the number of results (default: 10). Custom definitions declared withce.declare()can provide an optionalkeywordslist, and returned IDs can be passed toce.lookupDefinition(). - Definition descriptions filled in. About 80 operators and constants that
lacked a
descriptionnow have one (the trigonometric family, logic and relational operators, collection primitives such asList,Range, andFold, and constants likePiandExponentialE), andSec's description was corrected (secant is the reciprocal, not the inverse, of cosine). These surface ince.searchDefinitions()andce.lookupDefinition(). - New builtin operators are available:
Pipe(x, f),Append(collection, element),Fold(f, init, collection),StringJoin(s1, s2, …), andRandomInteger(n)orRandomInteger(a, b).Pipeenables evaluation ofx |> f;StringJoinenables the existing<>notation; andRandomIntegeruses inclusive bounds and honors the seeded random-number generator.
Calculus
Limitaccepts 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 asLimit(expr, point), matching the conventionSeriesalready uses. The(function, point, direction)reading is preserved when the middle operand is not a free variable of the expression.
Linear Algebra
Inverseof 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 systemA·x = b, exactly for exact input. Composed forms likeDot(Inverse(A), b)also work now:Inverse's result is typed as a matrix, so matrix operators accept it.
Units and Quantities
Quantityaccepts 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. Forf(x) := x/3,N(f(2))now returns0.666…instead of the exact2/3; plainevaluate()still returns the exact form, and the approximation is applied within the function's own scope, preserving lexical scoping.Keys(dict)andValues(dict)evaluate, returning the keys (as strings) and values in the dictionary's iteration order — the same orderfor kv in dictyields.Intersectionaccepts lists (any finite collection), deduplicating into aSet;Unionalready did.- A 2-element MathJSON
Listin 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 toIntervalexactly as before, and\setminusnow 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
IntersectionorUnionresult, a lazyMap,Filter, orJoinpipeline, 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}isTrue(it wasFalseunless 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,Equalbetween 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 asL = 4. Intersectionof twoFiltercollections no longer overflows the stack. Membership tests on a lazyFilterrecursed 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]]withy = 7evaluates to[7, 8], and[1/3].N()is numericized. Lazy collections such asRange,Map, andFilterremain lazily enumerated. - Ellipsis lists with symbolic bounds parse to
Range.\left[-N,\ldots,N\right]and\left[-3N,\ldots,3N\right]now parse toRange(-N, N)andRange(-3N, 3N), matching the numeric-start forms ([1,\ldots,N]). Previously a symbolic start fell through to a rawListcontaining a literalContinuationPlaceholder, which enumerated asNaN. In addition, aRangewhose bounds bind looser than the..operator now serializes with parentheses ((-N)..N) so it round-trips through LaTeX (an unwrapped-N..Nreads 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]')), constructing2 L_1— viasubs(),ce.box(), orce.function()— permanently broke the builtinNoperator for the lifetime of the engine: every subsequent parse of the tokenNreturned anunexpected-symbolerror. Type inference on the list elements no longer overwrites an operator definition with an unsatisfiable (never) type.
Cortex
%and postfix!operators:a % bisMod(a, b)(multiplicative precedence) andn!isFactorial(n)(the!must directly follow its operand; prefix!xis stillNotandx != yis stillNotEqual).- Chained indexing:
m[2][1]now works alongsidem[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, andZetathat 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.
Seriesnow produces correct Laurent expansions ofTrigammaand integer-orderPolyGamma(m, x)at their poles (previously a spurious regular expansion could be produced), andDknows\psi_1' = \psi^{(2)}and the generald/du\,\psi^{(m)}(u) = \psi^{(m+1)}(u).Seriesat aDigammapole 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^2through 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\inftyfrom 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 xat their poles) deliberately stay inert — the engine does not produceComplexInfinitylimits. Betajoins the meromorphic pole family. The Laurent kernel expands\operatorname{B}(a,b)through the\Gamma-quotient identity, so residues, limits andSeriesat 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 aLimitat\inftywhose body contains a variable-lengthSumorProduct. 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 returnedNaN. Decaying oscillations such as\operatorname{sinc}at-\inftynow resolve to0, while divergent oscillations such as\sin xat\inftystill returnNaN.
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 stablerubi:…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 bracketF |_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 toevaluate(). 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^bEvaluateAtform dropped the upper bound.) -
explain('solve')traces systems of inequalities and mixed systems. AList/Andof 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 1rewrite before the expansion). At default verbosity, consecutive applications of the same rule are coalesced into a single step; passverbosity: '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 inx; for example,\int t^2\,dtnow correctly returnst^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}\,dxand 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)\,dxclose, and inverse-trig antiderivatives producing complex-argumentErfievaluate (riding the new complex error-function kernels). \int F(\ln(a\,x^n))/x\,dxcloses via a function-of-logarithm recognizer (substitutionu = \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-1could 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. Numericconstsymbols follow the same evaluation behavior asPi. - 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-\inftyfor negative mantissas), matching float semantics. - Complex values with an infinite component type as
complex. AComplexwhose real or imaginary part is infinite was typedfinite_complex, so type-gated paths mishandled it; it now reports the non-finitecomplextype.
Sums and Products
- Telescoping sums and products evaluate in closed form. A sum whose body is
a
k \to k+1shift pair collapses exactly, for arbitrary symbolic bounds and either orientation:\sum_{k=0}^{n} \bigl(g(k+1) - g(k)\bigr)evaluates tog(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 ton. \prod_{k=1}^{n} kevaluates ton!. The bare-index product with a symbolic upper bound returnsFactorial(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 exactevaluate(), 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^2now 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 = 0solved fortnow 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 dentailsa \ge d, strictness propagates (p > q > rentailsp > randp \ne r), and an antisymmetric cycle collapses to equality — Wester 21'sx \ge y, y \ge z, z \ge xnow provesx = zisTrue. 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^2now evaluates toTrue(a difference of equally-scaled squares factors ask(x-y)(x+y)with both factor signs settled from the assumptions).x > yalone deliberately does not concludex^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 xto-\cos 2x(and the mirrored/2nd-power forms): a difference of squares whose Pythagorean sum factor is1, which the exponent-2-only TR5/TR6/TR7 transforms could not reach. Verified numerically; the defaultsimplify()path is deliberately unchanged (pinned by test). - Exact modulus of complex expressions with radical parts.
Absof a constanta + b\,iwith 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 exactly1(its.N()alone carries a1.0000000000000000315float 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.
Dover a vector/matrixListliteral 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.Derivativeshares the fix. Togethercombines 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
RowReducein 0.73.0 now backsKernel(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 Iis 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 intoListelements (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 to1.- Symbolic matrix rank via the determinant.
MatrixRankof 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 rank1(its determinant vanishes underTrigReduce). 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]], and3/2,-1/2etc. 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 thewester.test.tsskip 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 tox = 3. This also fixes Cortex string interpolation, which lowers toString— the documentation's headline example"\(x) has type \(Type(x))"now produces"2047 has type integer".Typereports the type of symbols and expressions. TheTypeoperator holds its operand unevaluated, but an unevaluated operand is not canonical and a non-canonical expression has no type — soType(y)returned"unknown"even for a symbol bound to an integer, andType(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-programconstreassignment went completely unreported. Each non-final statement that evaluates to an error value now emits aruntime-errordiagnostic carrying the statement's source range; the final statement's errors stay invalue, 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\sinto 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-
Mapto 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
RangeandLinspacestay inert instead of collapsing. A symbolic bound was silently coerced to1, soRange(1, n)behaved as the one-element range[1]everywhere:Count(Range(1, n))evaluated to1,Sum(Range(1, n))to1,Range(1, n) = Range(1, m)toTrue, 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 forLinspace: a symbolic point count is indeterminate (only a missing count selects the default of 50), and symbolic endpoints no longer materialize asNaNliterals or foldSum(Linspace(a, 1, 3))to0— 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/Infimumkeep unenumerable collections symbolic. The extrema used to iterate any collection operand: an infinite one (aMapover a continuousInterval) ground through the interval's dense sampler until the evaluation deadline, and one that reports elements it cannot compute (aMapover aLinspacewith a symbolic endpoint) silently vanished from the result —Min(Map(...), 5)returned5even though the mapped values could be smaller. Both now stay in the symbolic result. A genuinely empty lazy collection (aFilterwith no matches) still folds away, and finite collections fold as before.
Compilation
- New
iterationBudgetcompile option.expr.compile({ iterationBudget: 1e6 })caps the trip count of emittedSum/Productloops: a loop whose iteration count would exceed the budget — including an infinite bound, which previously compiled to a loop that never terminated — evaluates toNaNinstead 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-jstarget compiles every operand of n-ary nodes. Chained relations (1<x<4) compiled to only their first binary comparison, and n-aryAnd/Ordropped every operand past the first pair — forOrthis 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, andAnd/Orfold all operands; thejavascript/glsltargets were always correct. - The
javascripttarget fails closed on scalar arithmetic over a list-valued operand.L + xwith a list-valuedLpreviously compiled withsuccess: trueto JS array coercion (returning a string). It now reportssuccess: falsewith 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 anumberorstringseed makesRandom()/Random(n)(andShuffle,Sample) draw from a per-engine deterministic PRNG stream; re-assigning the same seed resets the stream so identical evaluation sequences reproduce, andnull(the default) restores non-deterministic behavior. With a seed set at compile time, eachRandomnode in ajavascript-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-callRandom(seed)overload is unchanged. - GLSL masked branches emit an overridable
_gpu_nan()helper. The else-branch of a compiledWhen/Which/Ifwas a bare0.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 withintBitsToFloat(0x7FC00000)for a guaranteed bit pattern.
Parsing
- Bare-command function names
\abs,\floor,\mod,\signparse 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 withunexpected-command. The infixa \mod b(synonym of\bmod) is unchanged. Also,\operatorname{sign}now aliases toSignlikesgn(it previously parsed silently as a free symbolsignmultiplied by the argument). - A dot-number after a closing group multiplies.
\left(1-t\right).9\left(2\right)andt^{i}.4parse the.9/.4as a decimal literal juxtaposed with the preceding operand (implicit multiplication), instead of erroring withunexpected-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 actuald-marker in the denominator (\frac{d}{dx},\frac{dy}{dx},\frac{d^2}{dx^2}…). A bare-dnumerator over a plain denominator —\frac{d}{L}wheredis an ordinary variable, common in pedagogy graphs — previously parsed to a malformed derivativeD(missing, L); it is nowDivide(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 apmatrix-family environment, with or without\left/\right— parsed the argument as a missing-argument error, soTrace,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 carriescanEvaluate: boolean—truewhen the operator's definition has an evaluation rule,falsefor a registered-but-inert head that only parses/serializes (e.g.To,Tilde). Together with anundefinedreturn (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) reportfalse; query the canonical form.
Special Functions
- New operators:
SinhIntegralandCoshIntegral— 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 underevaluate();.N()owns the numeric path. ErfandErfievaluate 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 toBesselJ(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, sod/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 xfires. The upstream entrya172c7published an empty assumption interval (OpenClosedInterval(0, −1/e)); the corrected bandx ∈ (0, 1/e]was fixed in the corpus fork (submitted upstream), and the recompiled identities artifact now carries the rule: withloadIdentities(ce)andassume(0 < x ≤ 1/4),simplify(W(x·ln x, −1))returnsln x. - Fungrim identities: the polygamma family is live (+28 rules, artifact
1,442). The corpus' 2-argument
DigammaFunction(z, m)(the order-mpolygamma) now translates to CE's nativePolyGamma(m, z)instead of a compat-shadowed 2-argDigamma, 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 literalSetthat CE read as a two-element enumeration — producing wrong scalars where one was consulted (Countof a set-builder returned its operand count). They now translate to the faithfulMap(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 withloadIdentities(ce),simplifyrewritesCount(\{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 formalSum/Product:Interpret(1 + 2 + \dots + n)→\sum_{k=1}^{n} k,Interpret(2 \cdot 4 \cdot \dots \cdot 2n)→\prod_{k=1}^{n} 2k, andInterpret(1 + 2 + \dots + 100)→ aSumthat evaluates to5050. Interpretation is an explicit opt-in — a plainevaluate()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 (so1 + 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 to385). 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, so1 + 2 + 4 + \dots + mstays 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 to143; Pell-number sums likewise (with a Binet-style body). The same evidence discipline applies — a recurrence of order L needs2L+1samples (or2Lwith 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.
ModandCongruentnow 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, so2^{3^{20}} \pmod{100}evaluates to52and2^{3^{20}} \equiv 52 \pmod{100}evaluates toTrue, where both used to stay inert. The floored-sign convention ofMod(the result follows the divisor) is preserved on the new path. - New
ModularInverse(a, m)returns the modular multiplicative inverse ofamodulom— the integerxin[0, m)witha·x ≡ 1 (mod m)— and stays symbolic whenaandmare not coprime. - Linear congruences and CRT systems solve.
solveon a linear congruence returns the parametric residue family with a fresh integer parametert ∈ ℤ(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.
Multiplynow applies the same digit-count budget asPowerwhen 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 symbolicPowerterm rather than computed eagerly (2 \cdot 3^{5000000}stays2 \cdot 3^{5000000}instead of building a multi-million-digitbigint). This also letsMod/Congruentreduce such products —2 \cdot 3^{5000000} \pmod 7evaluates to4— 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). LogIntegralnow has its standard notation.\operatorname{li}(x)parses toLogIntegraland 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 exactRationalthey represent (0.\overline{3}→["Rational", 1, 3],1.(2345)→["Rational", 12344, 9999]) instead of a truncated decimal float carrying a repeating-decimal marker. Normaccepts point-likeTuples.\|(-3, 4)\|now evaluates to5instead of leaving the expression inert.- Double-factorial symbolic reductions. Under
simplify(),(2n)!!reduces to2^n \cdot n!and(2n+1)!!reduces to\frac{(2n+1)!}{2^n \cdot n!}whennis 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 = cand\arctan x = creturn the exact root (\arcsin x = \frac12→\sin\frac12), with out-of-range constants correctly rejected (\arctan x = 2has no solution —2is outside arctan's range).\sinh x = cand\tanh x = creturn their single root, and\cosh x = creturns both roots\pm\operatorname{arcosh}(c). - Exponential-symmetric equations are recognized.
e^x \pm e^{-x}harmonizes to2\cosh x/2\sinh xbefore solving, soe^x + e^{-x} = 4returns both roots\pm\operatorname{arcosh}(2). - Two-absolute-value equations solve.
a\,\lvert f(x)\rvert = b\,\lvert g(x)\rvertis squared intoa^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. LambertWgains the real lower branch W₋₁. The 2-argument form["LambertW", z, k]selects the branch (kis0or-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 theidentitiesbundle), equations reducible toWsolve 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 likex + 2^x = 0→-\operatorname{W}(\ln 2)/\ln 2. Exact rational, float, and integer right-hand sides are all handled. The identities library also simplifiesW(x e^x, -1) \to xunderassume(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 forP(x)\sec^2(ax+b)with any polynomialP(the recursion reduces the polynomial degree). - Rational × sin/cos of a linear argument reduces to Si/Ci.
\int \frac{\sin x}{x+1}\,dxreturns 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 timesRoot(1, n)(e.g. Wester 28's2^{1/3}expressions now stay all-exact underevaluate()). - Pythagorean factoring in
simplify().\cos^3 x + \cos x\sin^2 x - \cos xnow simplifies to0: a sum with a shared factor times\cos^2 uand\sin^2 ucombines (g\cos^2 u + g\sin^2 u \to g), generalizing the bare\sin^2 x + \cos^2 x \to 1case. - 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)andPochhammer(a, k)expand for small literalkwith a symbolic first argument:Binomial(n, 3)evaluates to\frac{n(n-1)(n-2)}{6},Pochhammer(a, 3)toa(a+1)(a+2)(k ≤ 20).Pochhammeris 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
RowReduceis 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/3pivots 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
XandYdeclaredmatrix,2Y,XY,X - Yand3X + 2Yall type asmatrix(previouslyfinite_number, which made\det(XY)fail validation as anincompatible-typeerror).Traceof a matrix now types asnumber. All-scalar products are unchanged. Note: undeclared symbols in a matrix-expecting argument (\det(A+2B)with freshA,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 to1.5 \text{ ft}(previously the inscrutable1.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}stays6 \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 USgal) 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 to4. - Currency: dollars and cents. A new currency dimension backs the
USDandcentunits (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 to60 \text{ mi/h}— spaces inside\text{...}unit annotations are preserved andperreads 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_2or2748_{16}, now parses to the numericBaseForm(value, base)head (10111_2→["BaseForm", 23, 2]), so arithmetic on based numerals works:1011_2 \cdot 101_2evaluates to55, and11_8 - 3_8 = 6_8evaluates toTrue. The guard is strict — every digit must be valid for the base (19_2stays an inertSubscript), subscripted symbols (x_2) are unchanged, and values larger than 2⁵³ stay exact. TheBaseFormLaTeX serializer was also fixed (it emitted an unbalanced parenthesis) and now round-trips:BaseForm(23, 2)serializes as10111_{2}. A numeral with a symbol subscript base, e.g.161_bor161_{b}, now parses toBaseFormof 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_bevaluates to2b² + 9b + 5) and the numeral round-trips back to161_{b}. Base equations solve:161_b + 134_b = 315_breduces tob² − 8b = 0and solves tob = 8(andb = 0). - Sequence-braces notation.
\{a_n\}_{n=1}^{\infty}now parses to the new inertIndexedSequence(term, index, lower, upper)head instead of anincompatible-typeerror. 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 underevaluate()andsimplify()and round-trips through LaTeX. Bare\{a_n\}remains aSet, 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
AddorMultiplycontaining an ellipsis (\dots, theContinuationPlaceholdersymbol) is a notational pattern, not an arithmetic one: it now keeps its operands in source order with their structure intact, and is returned unchanged byevaluate(),N()andsimplify(). Previously1 + 2 + \dots + ncanonicalized ton + 3 + \ldots— folding the sample terms and destroying the pattern — and2 \cdot 4 \cdot \dots \cdot 2nfolded to16 \cdot \ldots \cdot n, tearing the coefficient out of the2nanchor. Such products also round-trip through LaTeX now (an explicit\timesis 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 as13(also1^{\text{st}},k^\text{th},\mboxvariants). Only an exact ordinal suffix (st/nd/rd/th, case-insensitive) is dropped; other superscripts are unchanged. - Empty scripts are dropped:
x^{}andx_{}now parse asxinstead of producing an error. {,}thousands separator: the LaTeX thin-separator idiom1{,}000now parses as the number1000. Only between digits, and a configureddecimalSeparator: '{,}'(European convention) takes precedence —3{,}14still parses as3.14in that mode.\cancel,\bcancel,\xcancelunwrap to their body, and\cancelto{4}{72}parses to the replacement value4— 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 inNot(…).- Standalone
\pmod{7}now places the modulus as the second argument ofMod(previously the operands were flipped). (2n)!!stays symbolic:Factorial2accepts symbolic arguments (its signature was integer-only and rejected2nwith anincompatible-typeerror); numeric double factorials are unchanged (8!! = 384).- Primed variables type-check as arguments:
\sin a'now parses toSin(Prime(a))instead of a type error —Primemirrors 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/Ddevolve to variables in all argument positions:N \equiv 1 \pmod know parses as a congruence over the variableN(previously the standard-libraryNoperator'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 nwith an elided left-hand side recovers with a missing-operand placeholder. - Empty subscripts on multi-letter symbols are dropped:
\alpha_{}parses asalpha(completing the earlierx_{}/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 like9\text{ to }80is untouched. Noton(s)alias (a US short ton is not the metric tonnet— 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 forcond: 1evaluated first-match, so the expression is defined where any condition holds. (Previously the condition was aTuple, 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 meanscond: 1. (Previously thecond:valpairs were parsed as aWhengate for the body — inverted semantics.) Whennow masks correctly on theinterval-jscompile 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.Whennow 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. ScalarjavascriptandglslWhenemission is unchanged.
Pipelines and Held Operands
- Hold operators reduce transformer heads.
Solve,Integrate, andLimithold 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 wasSimplify), and\int \operatorname{Simplify}(x^2)\,dx/\limof 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}) computedSolve(expr, expr)→[0]where the infix spelling returned[-1].Solvere-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 undecidableEqualsurvives 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.
EqualandNotEqualwith an undecidable comparison now stay inert underevaluate():x^2 = 4evaluates to itself instead ofFalse(andx \ne 4to itself instead ofTrue). This matches the inequality operators —x^2 < 4already 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 holdingx^2 = 4evaluated toFalseat storage time, breaking every answer-referencingSolvepipe downstream. IfandWhichstay unevaluated on an undecided condition. A condition that is boolean-typed but not yet decidable (e.g.x = 4with a freex) leaves the conditional inert — it may become decidable once the variables are bound — instead of throwingCondition must evaluate to "True" or "False"(or, previously forEqualconditions, 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 throwsRangeError: The number NaN cannot be converted to a BigInton a bignum-precision engine. A bare number — not part of the documentedDisplayDigitsforms, but the exact shape of a mechanicalfractionalDigits: n→digits: nmigration — 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 specifieddeprecation warning. The serializer re-entered the publictoMathJson()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..latexon a dictionary-typed symbol with no value no longer overflows the stack; it serializes as the symbol. (.latexon 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.
| Expression | CE (current) | CE 0.70.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 18 | 12 | 281 | 275 | 6.2 |
\sin 1 | 34 | 36 | 353 | 945 | 7.1 |
\cos 1 | 40 | 41 | 351 | 1,315 | 11 |
\ln 2 | 27 | 24 | 598 | 8,195 | 5.8 |
e^{\pi} | 20 | 22 | 376 | 8,984 | 7.5 |
\zeta(3) | 2,715 | 2,787 | 494 | — | 151 |
\Gamma(\tfrac13) | 1,452 | 1,424 | 4,843 | — | 267 |
\psi(\tfrac13) | 1,269 | 1,241 | 3,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.
| Operation | CE (current) | CE + R/F | CE 0.70.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 7.4× | 3.0× | 5.0× | 0.6× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 8.3× | 1.2× | 7.7× | 0.08× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 4.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\,x | 71× | 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)^x | 6.3× | 4.2× | 6.0× | 2.1× | — | 1× |
\int_1^2\tfrac1x\,dx | 5213× | 6049× | 5202× | 82× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 332× | 106× | 279× | 2.3× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.2× | 0.2× | 0.2× | 0.06× | — | 1× |
x^3-x-1=0 | 1.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 Engine0.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). Withce.angularUnit = 'deg',compile('\\sin(x)')emitsMath.sin(0.017453… * x)sorun({x: 90})returns 1, matchingevaluate()— 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,cschandarsinh…artanhno longer convert under a non-radianangularUnit. Previously in degree mode\sinh(1)evaluated to\sinh(\pi/180) \approx 0.0175instead of1.1752. -
Exact inverse-trigonometric values are returned in the current angular unit. In degree mode
\arcsin(1)now evaluates to the exact integer90(previously the exact radian value\pi/2, disagreeing with.N(), which returned 90). Similarly100ingradmode and the exact rational1/4inturnmode; radian mode still returns\pi/2. -
Arctan2honorsangularUnit, consistently withArctan(it previously always returned radians): in degree modeArctan2(1, 1)evaluates to the exact45, with the quadrant corrections applied in the current unit (Arctan2(1, -1)→135).Symbolic calculus (
D,Integrate) remains radian-based regardless ofangularUnit(no\pi/180chain-rule factor); this is a known limitation.
Step-by-Step Explanations
-
explain('D')handles higher-order and mixed partial derivatives. A neworderoption requests then-th derivative (ce.parse('x \\sin x').explain('D', { variable: 'x', order: 2 })), and a receiver that is itself aDexpression — including mixed partials such asD(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. AListorAndof equations is traced through the same solverssolve()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). AnOrof univariate equations is solved case by case (solve.case) with the roots merged. The solutions are identical tosolve()— the trace is a pure observation channel. Systems of inequalities and mixed systems are not traced and throw a precise error. To support systems, thevariableexplain 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 exportsparseCortex()(Cortex text → MathJSON),serializeCortex()(MathJSON → Cortex text), andexecuteCortex()(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/2if (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(alsox \triangleright f,x \vartriangleright f,x ⊳ f, or the plain-text shortcutx |> f) parses tof(x). A\squaretopic 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)isSolve(x^2 = 4, x). Stages chain left to right (4 \rhd \sqrt \rhd \lnisln(√4)), a bare function command such as\ln,\lbor\sqrtacts as a function reference (12 \rhd \lnisln(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,Seriesand the polynomial operators may now be omitted. It defaults to the input's single free variable, or toxwhen there are several free variables and one of them isx; with no inferable default the expression stays unevaluated. This enables point-free pipelines such asx^2 = 4 \rhd \operatorname{Solve}orx^2 \rhd \operatorname{D}. Applies toSolve,D,Series,PolynomialDegree,CoefficientList,PolynomialRoots,Discriminant,PolynomialQuotient,PolynomialRemainder,PolynomialGCD,Resultant,Cancel,PartialFractionandApart(Factoralready 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\textupparse their argument as a text run and produce anAnnotatedexpression with the matching style (\textbf{Sizes}→["Annotated", "'Sizes'", {dict: {fontWeight: "bold"}}]) that round-trips back to the same LaTeX.\textrmand\mboxparse like\text.\bold,\boldsymbol, and\bmare synonyms of\mathbf(\bold{v}→ the symbolv_bold). -
Vector-norm bars. The
\|command is now recognized as a norm delimiter everywhere\Vertis:\|\mathbf{a}\|,\left\| b \right\|, and\|a\|^2all parse toNorm. -
TeX-primitive binomial. The infix
{n \choose k}form parses toBinomial(n, k), joining the already supported\binom,\dbinomand\tbinom. -
Bare mod annotations.
x \pmod nwith no preceding\equivparses asMod(x, n)(-811 \pmod{24}→["Mod", -811, 24]), matching the existing\bmodbehavior. Congruence chains followed by an implication now parse correctly:a+1 \equiv 4 \pmod 7 \implies a \equiv 3 \pmod 7isImplies(Congruent(…), Congruent(…))(the congruence previously disintegrated when\impliesfollowed the modulus).\equivnow 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 amissingerror.
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 throwsRangeError: Maximum call stack size exceeded. The pretty-JSONMultiplyserializer round-trips throughProduct.asRationalExpression(), and the tuple-aware branch ofcanonicalDividereturned an inertDivide(expr, 1)instead of stripping the trivial divisor, sending the serializer into infinite recursion. Trivial/1and/-1divisors of tuple-typed expressions are now reduced. -
Juxtaposing a scalar with a tuple-typed symbol now means scaling, not tuple construction: with
zdeclaredtuple<number, number>,3zparses to["Multiply", 3, "z"](previously a spurious["Tuple", 3, "z"]). Literal tuples (3(1,2)) were already handled; heterogeneous tuples such astuple<string, number>still group as aTuple. -
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 withevaluate(). -
\operatorname{csch}(x)now parses to theCschfunction (previously a free symbol namedcsch, silently turning the expression into an implicit multiplication), joining the existing\cschcommand and matching\operatorname{sech}. -
Constructing many
ComputeEngineinstances 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 anew WeakRef(...), and the ECMAScript kept-objects rule pins eachWeakReftarget 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
\lnor\log— with no argument, as in the pipeline12 \triangleright \ln— now parses to the function symbol ("Ln","Log"), consistent with\cos,\lgand\lb. It previously parsed to an empty function application, so piping a value into it produced amissingerror instead of applying the function:ce.parse('12 \\triangleright \\ln').evaluate()now returns2\ln 2 + \ln 3. The bare symbols also serialize back to\ln,\logand\lg(previously\ln()). -
A bare
\lb(binary log) now parses to theLbfunction symbol, so12 \triangleright \lbcomputes\log_2 12. It previously parsed toLog, silently computing the base-10 logarithm instead. -
A log with a base but no argument (
\log_2) now parses with the pipeline topic marker\squarestanding in for the argument:8 \triangleright \log_2fills the hole and computes\log_2 8 = 3(composing with inverse superscripts too:9 \triangleright \log_3^{-1}gives3^9), and a standalone\log_2displays 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^2computes\cos^2 x,12 \triangleright \ln^{-1}computese^{12}, and a standalone\cos^2displays as\cos(\square)^2. These previously produced aPowerof 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)
DSolvenow handles four classical first-order classes:- Separable equations return an implicit solution when no explicit form is
available:
y' = x/ygives\frac12 y(x)^2 = \frac12 x^2 + c_1. - Bernoulli equations
y' = p(x)\,y + q(x)\,y^nreduce via thev = y^{1-n}substitution and return explicit solutions. - Homogeneous equations of the form
y' = F(y/x)solve by thev = y/xsubstitution:y' = 1 + y/xgivesy(x)/x = \ln x + c_1. - Exact equations
M(x,y) + N(x,y)\,y' = 0return the implicit potential:2xy + y^2 + (x^2 + 2xy)\,y' = 0givesx^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 equationy' = x + y^2) stay inert. - Separable equations return an implicit solution when no explicit form is
available:
-
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)returnsy(x) = \sin x. Derivative conditions are recognized in both theApply(Derivative(y, 1), x0)and flatD(y(x0), x)forms. Conditions also apply to supported implicit solutions (y' = x/ywithy(0) = 1gives\frac12 y(x)^2 = \frac12 x^2 + \frac12), and free parameters survive:y' = kx/ywithy(0) = 2gives\frac12 y(x)^2 = \frac12 k x^2 + 2withkuntouched. 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^xgivesc_1 e^x + c_2 e^{-x} + \frac12 x e^x, andy''' - y = \sin xand resonanty'' + y = \sin xboth 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. -
NDSolveintegrates 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
RSolveoperator. (contributed by KingArth0r)RSolve(equation, a, n)solves linear homogeneous constant-coefficient recurrences via the characteristic polynomial: geometric (a_{n+1} = 2a_ngivesa(n) = c_1\,2^n), Fibonacci-style, repeated roots withn^k r^nmodes (a_{n+2} + a_n = 2a_{n+1}givesa(n) = c_1 + c_2\,n), and complex roots (a_{n+2} = -a_ngivesa(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)givesa(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 byesm/,esm-min/,umd/,umd-min/, and the unchangedtypes/. 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 exampledist/compute-engine.min.esm.jsis nowdist/esm-min/compute-engine.js. Consumers importing via the bare package specifier (@cortex-js/compute-engineand its sub-paths such as@cortex-js/compute-engine/identities) are unaffected — the packageexportsmap absorbs the move. Only deep imports that reach into…/dist/…directly, and pinned CDN URLs, need to be updated. Eachesm*/directory is now fully self-contained, with its ownchunks/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/, anddist/types/only. The non-minifiedesm/andumd/directories — about 60% of the unpacked package, and never referenced by theexportsmap — are now build-only artifacts:npm run buildstill 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.tsemission 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 nametypescriptstays 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\uXXXXescapes).
0.69.1 2026-07-08
Issues Resolved
- #318 Type declarations now resolve correctly in projects using
"module": "nodenext"/"node16". The published.d.tsfiles used extensionless relative imports, which producedTS2834errors (or collapsed every imported type toanywithskipLibCheck). The build now rewrites the emitted declarations with explicit.jsextensions and validates them against anodenextconsumer as part of every release build.
0.69.0 2026-07-08
Breaking Changes
-
\pmnow parses to aMeasurement, notPlusMinus.a \pm bparses to["Measurement", a, b]— a value with an uncertainty (see below) — replacing the previousPlusMinushead 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 explicitListof the branches, and a numeric integral that reports an error estimate now returns["Measurement", estimate, error]instead of aPlusMinustuple. Prefix\pm bparses to["Measurement", 0, b]. -
Loopno longer produces a list — comprehensions moved to the newComprehensionoperator.["Loop", body, ["Element", x, coll], …]is now an imperative for-each evaluated for effect: its value isNothing(or the value carried by aBreak/Return), and it no longer collects the body values into aList. The trailing-forcomprehension syntax (x^2 \operatorname{for} x = [1...10]) now parses to["Comprehension", body, ["Element", …], …], which returns exactly what the collectingLoopused 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: useMap, or anElementclause; a non-Elementiterator argument is now an error. -
scalar + pointis 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] = 4previously evaluated toFalse(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
Measurementtype — values with a propagated uncertainty.Measurement(value, error)(writtenvalue \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 (UnitConvertof(5.1 \pm 0.2)\,\mathrm{cm}tom→(0.0510 \pm 0.0020)\,\mathrm{m}). The bare form5.1 \pm 0.2\,\mathrm{cm}(no parentheses) parses to the same thing: a unit on only one operand of\pmscopes 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 thedigitsserialization 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 thesimplifyworkaround.
- Algebraic and elementary operations propagate the error:
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 · tupleis an error (no implicit dot product — useDot), andscalar + tupleis 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/.zmember syntax, which parses toFirst/Second/etc. (P.x→["First", "P"]). Component access on a point literal ((1,2).x→1) also works. -
Color functions broadcast over lists, so
rgbandhsvapplied 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 ofLwhere the condition holds — the Desmos list-filtering notation. The condition may reference the list itself (L[L>0]), another list (L[d=4]wheredis a list), or compute a positional mask from aRange(L[|[1...\operatorname{length}(L)]-i|>0]removes thei-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] > 0evaluates to["List", "False", "True", "False"], typedlist<boolean>. Scalar and symbolic comparisons are unchanged (x > 0stays 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 typedlist<finite_number>(previously the scalarfinite_number, contradicting the value), and[1,2] \cdot 2/[1,2] + xreportvector<2>rather than a scalar or anumber | vector<2>union. Code that inspects.typebefore evaluating no longer needs to special-case list-broadcast expressions. -
Whenbroadcasts 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 withx = 2,x\{x \le [1,2,3]\}evaluates to[Undefined, 2, 2](one masked branch per element: the value where the element condition isTrue,UndefinedwhereFalse, a heldWhenwhere 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 tolist<…>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 bareA; it now parses to["At", "A", 1]likeA[1]always did. Indexing also works on parenthesized groups and function applications:(3,4)[1]andf(x)[i]parse toAtexpressions. -
Numbers with a leading or trailing decimal dot parse correctly.
.85xparses as0.85 x, and a trailing-dot literal inside delimiters ((1., 2)) is accepted. -
Scaling a list/vector by juxtaposition is a
Multiply, not aTuple. 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 toMultiply(element-wise scaling). Previously such juxtapositions produced a spuriousTuple, which raised anincompatible-typeerror 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 aWhenwith 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
Polygonoperator.\operatorname{polygon}((0,0),(1,0),(0,1))parses to["Polygon", ...], an opaque geometric primitive likeTriangleandSegment, for consumers that render it. -
histogram,pdf,cdf,length, andnCrparse toHistogram,PDF,CDF,Length, andChoose. The lowercase\operatorname{...}forms used by Desmos are now aliases of the existing operators. The member form.length(S.\operatorname{length}) also maps toLength, joining.count,.max,.min,.total, and the.x/.y/.zcomponent accessors.HistogramandBinCountsaccept 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
digitsserialization option for significant-figures and decimal-place display control. Available onexpr.toLatex(),expr.toMathJson(), and honored byexpr.toString(),digitscontrols 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 tonsignificant figures (ce.parse("\\pi").N().toLatex({ digits: { significant: 3 } })→3.14). Rounding is independent of notation (1500at two significant figures stays1500in fixed notation; usenotation: "scientific"for1.5 \cdot 10^{3}), and exact integers, rationals, and radicals are shown in full — only inexact values are rounded.digits: { fractional: n }showsndigits after the decimal point (toFixedsemantics), anddigits: "auto"/"max"behave as before.- The
fractionalDigitsoption is deprecated in favor ofdigits. It continues to work (a numericnis equivalent todigits: { fractional: n }); if both are provided,digitswins.
-
The pipeline operator
|>supports a topic marker and a prefix form. A\squarein 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 toSolve(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
-
Declarenow 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 toNothing). This matches the documented signature; earlier the value operand was silently dropped. The one- and two-operand forms are unchanged. A value-carryingDeclarealso compiles correctly (the initializer is emitted for the JavaScript and GLSL targets), not just when evaluated. -
Declarecan attach definition attributes via a trailing dictionary, including declaring constants. An optional finalDictionaryoperand carries any oftype,value,constant, andholdUntil, mirroring the JavaScriptce.declare(name, def)API. For example,["Declare", "c", "real", 299792458, ["Dictionary", ["KeyValuePair", "constant", "True"]]]declares an immutable constant (a laterAssignto it is rejected), andholdUntilcontrols when the symbol's value is substituted (as for built-in constants such asPi). A positionaltype/valuetakes precedence over the same key in the dictionary. This gives MathJSON a representation for constant declarations (e.g. the target for aconstkeyword in a surface language).
Control Flow
-
Loopis 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 byce.iterationLimitand the evaluation deadline. Previously this form — documented aswhile(true)— evaluated the body only once. It compiles towhile (true) { … }in JavaScript, andLoopwithElementclauses compiles to plainfor/for…ofstatement loops with no result array. -
New
Comprehensionoperator: value-producing list comprehensions.["Comprehension", body, ["Element", x, xs], …]evaluatesbodyfor each combination of one or moreElementclauses and collects the results into aList. 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 toMap(xs, x ↦ body); unlikeMap(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). -
BreakandContinueare 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
Blockstatement results. ABreak,Continue, orReturnproduced by a statement's result — e.g.["If", cond, ["Break"]]— now short-circuits the enclosingBlockand 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 conditionalBreakinside a block was silently discarded and the loop ran to the iteration limit. Consequences: thewhile-loop lowering["Loop", ["Block", ["If", cond, ["Break"]], …body]]now terminates correctly, and aBlockwhose value is aReturnevaluates to the["Return", value]expression itself (unwrapped at the function application boundary), where it previously unwrapped eagerly. -
Ifwithout an else branch is fixed.["If", cond, then]— the documented two-operand form — failed to canonicalize (throwingCannot read properties of undefined) and was left inert. It now canonicalizes and evaluates toNothingwhen the condition is false. -
Nested scopes now see the enclosing block's variables (lexical scoping fix). A
Block,Ifbranch, orLoopbody nested inside aBlockresolved 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 symbolickinstead of7, awhile-style["Loop", ["Block", ["If", cond, …], …]]threwCondition must evaluate to "True" or "False", and anElement-clause loop whose body is aBlockleft the loop variable symbolic (Loop(Block(Assign(s, s + n)), Element(n, Range(1, 5)))produced5ninstead of accumulating15). Nested scopes now resolve enclosing block locals, loop variables, and — inside a function body — the function's parameters and locals correctly, sowhile/forlowerings with block bodies evaluate as expected. -
Re-evaluating a program with
Declarestatements no longer throws. Evaluating the sameBlockexpression more than once — or aDeclareinside a loop body, which re-executes every iteration — threwThe symbol "…" is already declared in this scopeon the second entry. ADeclarestatement now resets the binding it created on a previous run of the same scope. Genuine conflicts (redeclaring a function parameter, orce.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.
| Expression | CE 0.69.0 | CE 0.66.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 5.9 | 7.9 | 176 | 104 | 3.9 |
\sin 1 | 20 | 20 | 222 | 442 | 5.2 |
\cos 1 | 20 | 20 | 224 | 455 | 7.1 |
\ln 2 | 13 | 81 | 339 | 4,315 | 3.8 |
e^{\pi} | 12 | 23 | 213 | 4,787 | 4.0 |
\zeta(3) | 1,542 | 3,395 | 268 | — | 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.
| Operation | CE 0.69.0 | CE + R/F | CE 0.66.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 6.8× | 3.1× | 7.5× | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 11× | 1.7× | 10.0× | 0.08× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 6.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\,x | 98× | 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)^x | 9.7× | 6.0× | 5.1× | 2.1× | — | 1× |
\int_1^2\tfrac1x\,dx | 7429× | 7782× | 7935× | 90× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 459× | 153× | 586× | 2.5× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.3× | 0.2× | 0.1× | 0.06× | — | 1× |
x^3-x-1=0 | 1.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 Engine0.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.jsand the correspondingintegration-rulesbundles are now built with code splitting, so the engine core is emitted once into achunks/chunk-*.jsfile that both entry points import (this fixesinstanceoffailures when the integration-rules plugin is loaded alongside the main library, which previously carried its own duplicate copy of the engine). If you copycompute-engine.min.esm.jsout of the package as a standalone file — for example to vendor it or serve it from your own static assets — you must now copy thechunks/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.cjsbuilds 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.jsplus thechunks/directory and omitintegration-rules.*(the Rubi corpus) andidentities.*(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 firstimportstatement. 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 CDis the parallelism relation, consistent with\perp→Perpendicular. Use\loror\veefor disjunction (unchanged). -
\rightarrownow parses to the mapping arrowTo, notImplies.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\Longrightarrowfor implication (unchanged).
New Operators
-
Series,BigO, andNormalprovide symbolic series expansion.Series(f, x, x0, n)returns the Taylor expansion offinxaboutx0(defaultx0 = 0) up to and including the powern(defaultn = 5), plus an explicit remainder term.x0may be±∞for an asymptotic expansion in powers of1/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 undeclaredfyields the textbook formf(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, serializedO\left(u\right)and parsed from\mathcal{O}(u)and\operatorname{O}(u). It is inert underevaluate/simplify; a numeric approximation (.N()) of any expression containing it isNaN.Normal(expr)strips theBigOterms, yielding the compilable/plottable truncated polynomial:Normal(Series(\sin x, x))→x - \tfrac{x^3}{6} + \tfrac{x^5}{120}.
-
TrigExpand,TrigToExp, andTrigReducerewrite trigonometric and hyperbolic expressions. These are transformation verbs in the spirit ofExpand/Factorand preserve exactness.TrigExpandexpands functions of sums and integer multiples of angles:TrigExpand(\sin(a+b))→\sin a\cos b + \cos a\sin bandTrigExpand(\cos(2x))→\cos^2 x - \sin^2 x(hyperbolic analogs, and\sec/\csc/\cotas reciprocals of the expanded\cos/\sin, are also handled).TrigToExprewrites trigonometric and hyperbolic functions in terms of the complex exponential, exactly:TrigToExp(\sin x)→-\tfrac{i}{2}e^{ix} + \tfrac{i}{2}e^{-ix}.TrigReduceis the inverse ofTrigExpand, rewriting products and integer powers as functions of multiple angles:TrigReduce(\sin^2 x)→\tfrac{1 - \cos 2x}{2}andTrigReduce(\sin x\cos x)→\tfrac{\sin 2x}{2}.
-
Probability distributions:
NormalDistribution,BinomialDistribution,PoissonDistribution,UniformDistribution,ExponentialDistribution, consumed by the genericPDF,CDF, andQuantileoperators. A distribution is a first-class value — assign it, pass it around, query it:PDF(dist, x),CDF(dist, x)andQuantile(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
PDFis the probability mass function, andQuantile(the leastkwith\operatorname{CDF}(k) \ge p) is computed by exact search:Quantile(PoissonDistribution(9), 0.95)→14. Mean,Variance, andStandardDeviationnow 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), andExponentialDistribution(\lambda)the rate — the Mathematica and scipy conventions.
-
GammaRegularizedandBetaRegularized— the regularized incomplete gamma and beta functions.GammaRegularized(a, z)isQ(a, z) = \Gamma(a, z)/\Gamma(a)andBetaRegularized(x, a, b)isI_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,PopulationCovarianceandCorrelationmeasure 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])→\tfrac32and the Pearson correlation of the same data is\tfrac{\sqrt{15}}{5}, exactly.Covarianceuses the sample (n-1) convention,PopulationCovariancethe population (n) convention, matchingVariance/PopulationVariance. Parse aliases:\operatorname{cov}and\operatorname{corr}. Both compile to JavaScript and Python (np.cov/np.corrcoef). -
LinearRegressionandPolynomialFitcompute least-squares fits.LinearRegression(xs, ys)(or a collection of points) evaluates to(intercept, slope), andPolynomialFit(data, degree)to the list of coefficients, constant term first. Exact data yields exact coefficients: points lying on1 + x^2fit 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. -
Quantilecomputes empirical quantiles of data.Quantile(collection, p)interpolates the sorted data so thatQuantile(xs, 1/4),Quantile(xs, 1/2)andQuantile(xs, 3/4)agree exactly withQuartilesandMedian(Moore–McCabe convention), with generalpinterpolated through the order statistics in rank space. (Combined with the distribution form above,Quantilecovers both the theoretical and the empirical case.) -
DividesandNotDividesexpress divisibility.a \mid bparses toDivides(a, b)andp \nmid abtoNotDivides(...); 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^\circcomposes in arithmetic. -
\simparses to the generic similarity relationTilde. It covers triangle similarity (\triangle ABC \sim \triangle DEF), asymptotic equivalence, and "is distributed as" (X \sim N(0, 1));\nsimnegates it, and\simeqnow maps to the existingTildeEqualhead (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 machineid(the localization key for consumers), and a default English description;explain().resultis always the same valuesimplify()returns.ce.parse('\\frac{x^2-1}{x-1}').explain()yields one step, "Cancel the common factors", ending atx + 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 side2x-4=0→ Isolate the unknownx=2. The trace covers the solver's algorithmic phases (clearing denominators, squaring both sides, substitutions likeu = eˣwith back-substitution, zero-product factoring, the quadratic formula, checking candidates and rejecting extraneous roots) and the root-template rules, which now carry stablesolve.*ids.explain('solve').resultis aListof the same rootssolve()returns; the unknown is inferred or passed viaoptions.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 inertD(…)terms, which resolve step by step:D(x·sin x, x)→ Apply the product rulex·D(sin x, x) + sin x→ Differentiate using a known derivativex·cos x + sin x. The variable is inferred when unambiguous (or passed viaoptions.variable), and the result always matches evaluatingD(expr, variable).
- The step chain is curated by default (driver bookkeeping is filtered out);
pass
Solving
-
Solveaccepts 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,Solvefalls 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 inSumindexing 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)]— aListofTuples 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,
Solverecognizes linear equations in any number of unknowns and Pell-family equationsx^2 - Dy^2 = N(including the elliptic casex^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 the10^{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 —Solvereturns the parametric family:Solve(3n+4m=7, n, m)→[(4t-7,\; -3t+7)]with the fresh parametertranging 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 1alone) 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 = 1over[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. Afterassume(n > 0),Solve(n^2 = 16, n)(andexpr.solve("n")) returns[4]instead of[4, -4];assume(n \in 1..10), inequality, and\neassumptions 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!)^2crashed withThe 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 decimal5. -
Congruences parse and evaluate.
a \equiv b \pmod{n}(also\bmod, the parenthesized(\bmod n), and the ASCII formn ≡ 1 (mod 3)) parse toCongruent(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}(alsoalign,gather,split,multline,eqnarrayand their starred variants) parses to aListof the row expressions — the same convention as\begin{cases}, accepted bysolve(). Alignment markers are transparent:x &= yisx = 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 Bparses asSetMinus(the common alternative spelling of\setminus); a standalone quantified condition\forall n \ge 1parses instead of erroring;\underbracemirrors\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-typeerror. This fixes(A \setminus B) \cup (B \setminus A)— whereBwas 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-rulesplugin shares code with the main library. The ESM builds ofcompute-engineand the opt-in@cortex-js/compute-engine/integration-rulesentry 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-bundleinstanceoffailures 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
strictoption.simplify(),evaluate(),N(),expand(),expandAll(),factor(),solve(), andcompile()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^23isx^{23}(not3x^2), andx_23/a_12are 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 xnow means\arcsin x(the inverse function), not1/\sin x(matching strict\sin^{-1});sin^-2 xstays1/\sin^2 x.atan2(1, 2)parses asArctan2(1, 2), andacot/asec/acscare 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.
DSolvenow clusters numeric characteristic roots by multiplicity:y'''' + 2y'' + y = 0gives(c_1 + c_2 x)\cos x + (c_3 + c_4 x)\sin xinstead of a degenerate basis with spuriouse^{\varepsilon x}factors, and repeated real roots keep theirx 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 internalErrornode; 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^xnow returnsc_1 e^x + c_2 e^{-x} + \frac12 x e^x - \frac14 e^x, andy'' + y = e^xreturnsc_1 \cos x + c_2 \sin x + \frac12 e^x, instead of the equation unevaluated. Solutions are returned in collected form (noe^a \cdot e^bproducts orA\sin^2 u + A\cos^2 upairs). -
Parsed LaTeX input works end-to-end.
ce.parse("y''(x)+y(x)=0")no longer canonicalizes the derivative of an undeclared function into anErrornode: a derivative now reports a numeric result type, so prime/dot-notation equations flow fromparse()throughDSolve(\dot x + \ddot xexpressions are likewise no longer corrupted). The implicit first-order formApply(Derivative(y), x)is also recognized (order defaults to 1).
Evaluation
-
Betais 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\inftyinstead of a silently wrong finite value (\mathrm{B}(-1,2)previously returned-2.97\times10^{49}). -
Multiplication by infinity respects sign information.
x \cdot \inftystays symbolic when the sign ofxis unknown, evaluates to-\inftywhenxis known negative, and toNaNwhenxis zero — it no longer collapses to+\inftyunconditionally. -
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. -
Sumreports incompatible elements. Summing a collection containing a string returns a typed error instead of a silentNaN. -
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. Previouslyevaluate()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} kwith an unboundnevaluated to50\,015\,001— the sum truncated at an internal iteration cap of10\,001— under bothevaluate()and.N(). It now stays symbolic (simplify()still produces the closed form\tfrac{n^2+n}{2}). -
Expandcomputes constant powers.Expand((2+3i)^{1000})returns the exact 557-digit Gaussian integer (matching SymPy'sexpand()), andExpand(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}) reportedisInfinitytrue, serialized as\tilde\inftyin plain text, and had aNaNbignumIm— all artifacts of routing through the machine-float projection. It now types asfinite_complex, prints its full digits, andbignumImis 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 exact0(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 2and\log_8 10stay symbolic). -
Xorcancels repeated operands.a \oplus a = \mathrm{False}, soXor(x, y, y)evaluates tox; cancellation composes with the existingTrue/Falsefolding.
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
conditionfunction returning a non-boolean (e.g. the boxed symbolFalse, which is a truthy JavaScript object) no longer fires the rule; a one-time console warning identifies the malformed condition. Returning the boxed symbolTrueis accepted. -
eandiwork in string rules. Rules such as'e^2 -> 7'now match: the constants are resolved toExponentialEand 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/__aas 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)^3evaluates to-2+2i,(1+i)^{-2}to-\frac{i}{2},\frac{1}{1+i}to\frac{1-i}{2},\sqrt{3+4i}to2+i, and\sqrt{-4}to2i. 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 as2^{-2} = \frac14, and powers of Gaussian integers such as(1+i)^2evaluate exactly. Very large exact integers are no longer rounded when used byIsPrime,IsOdd,IsEven,FactorInteger,Mod, orDigitSum. -
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()andN(). Exact negative arguments stay symbolic underevaluate()and produce the principal complex value underN(). Logarithms with a complex argument and explicit base now agree betweenevaluate()andN(). -
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 exactly4, odd roots of negative numbers keep the real-root convention, andN(\sqrt{4y})now returns2\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 respecttimeLimit.
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 to2x. -
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 toDand 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 asx^{1/x},\frac{d}{dx}\operatorname{Mod}(x,5)gives1almost everywhere, andD(\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 defaultsimplify()path. Pythagorean identities such as\sin^2 x+\cos^2 xalso simplify inside larger sums. -
Simplification is more exact and branch-aware. Combining powers keeps exact exponents, for example
x \cdot x^{\sqrt2}becomesx^{1+\sqrt2}. The simplification of\ln(x^2)now produces2\ln(|x|)for realx, 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|,Arctan2preserves the correct quadrant, rule conditions such asx \ne 0require 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)
DSolvenow solves linear constant-coefficient homogeneous equations of any order via the characteristic polynomial — distinct real, repeated, and complex roots — for exampley''(x) = y(x)→[y(x) = c_1·e^x + c_2·e^{-x}]andy''(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 namedc_1,c_2, … (fresh names are chosen if those are already in use). Correspondingly,NDSolvenow solves explicit higher-order initial value problemsy⁽ⁿ⁾(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
-
\binomis supported.\binom{n}{k},\dbinom{n}{k}, and\tbinom{n}{k}parse toBinomial(n, k), andBinomialserializes back to\binom. -
LaTeX parsing accepts more common notation.
N(...)andD(...)parse as numeric evaluation and differentiation outside quantifier scopes, superscripts on\log,\ln,\lg, and\expbind to the applied function, and==,!=, chained\ne, mixed-direction inequality chains, parenthesized relations, and double negation such asx--ynow parse with the expected meaning. -
Lenient parsing is more useful. In lenient mode, digit suffixes parse as subscripts (
x2asx_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 asPositiveIntegers. -
The public string helpers accept their documented syntax.
simplify(),evaluate(),N(),expand(),expandAll(),factor(),solve(), andcompile()now parse string input in non-strict mode, so expressions such assqrt(5),sin(alpha), andx**2work as documented. -
verify()andassume()accept strings.ce.verify('x > 0')andce.assume('$x > 0$')parse the predicate and report clear errors for unparseable input. -
MathJSON
.jsonserialization 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.
realis now a subtype ofcomplex, so real-typed symbols satisfy complex-typed signatures and guards. Union types flatten and canonicalize their member order, and type negation now distinguishesneverfromnothing. -
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, andk/0no 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, andforget()clears values introduced byassume('x = 5')while preserving values set withassign(). -
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.
isSameis now an equivalence relation,isEqualreturnsundefinedfor 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, andSetMinusproduce correct finite-set results,Reverse([1,2,3])returns[3,2,1],Quartilesconsistently uses the Moore-McCabe convention, and single-argument\operatorname{KroneckerDelta}(0)returns1.
Compilation
-
Compiled JavaScript, Python, GLSL, WGSL, and interval output now match the interpreter more closely. Equality uses the engine tolerance,
ModandRemainderuse consistent conventions, chained relations evaluate middle operands once, dynamic0^0returnsNaN, non-booleanWhichandWhenconditions 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
CancellationErrorwhentimeLimitor 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, sox \cdot x^{\sqrt2}becomesx^{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 corrupts999999999999999, NaN has a deterministic place in canonical ordering, and high-precisiontoString(),.json, andtoFixed()avoid long stalls on enormous exponents. -
Substitution and collection operations are more complete.
subs()now reaches into lists and tensors, for exampleMedian([a,b,c]).subs({a: 1}). Finite set operations such asIntersection({1,2}, {2})andSymmetricDifferencenow evaluate correctly, andReverse([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, andMap([1,2,3], "nf")is rejected. In non-strict mode, missing required arguments such asSqrt()orPower(2)no longer crash. -
Rule replacement is safer. Rule guards such as
x \ne 0must now be provable before they match, wildcard conditions such as:notzerono 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.
ChooseandBinomialnow share standard conventions, includingChoose(2,3) = 0and negative upper indices such asBinomial(-2,3) = -4.Argument(1+i)evaluates to\pi/4, severalDigammaspecial values simplify when the Fungrim pack is loaded, and integer-domain functions such asFibonacci(+Infinity)andMoebiusMu(Infinity)stay symbolic instead of throwing. -
Modular arithmetic is consistent.
Modis floored everywhere, soMod(-7, 3)returns2, whileRemainderuses round-to-nearest semantics. Exact rational inputs stay exact, for exampleMod(\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 asMP - PMno longer collapses to0for declared matrix symbols. -
Parsing rejects or preserves ambiguous forms more reliably.
x^2^3is now a parse error instead of an unintended list power,Sequence(1,2)no longer serializes as1 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^0and non-finite values are consistent across paths.evaluate(),N(), and compiled JavaScript now agree that0^0isNaN. Trigonometric poles such asN(\cot \pi)andN(\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):
¹ math.jsop CE 0.67.0 CE 0.66.0 math.js¹ Mathematica² add75 152 278 1,023 sub91 167 329 1,212 mul202 319 7,984 1,025 div501 1,748 11,890 1,366 cmp29 198 61 984 sqrt3,163 4,018 54,696 1,055 exp4,795 8,698 728,876 1,682 ln5,887 32,398 670,206 1,353 cos6,914 7,467 1,666,292 2,059 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 frombenchmarks/big-decimal/ops-results.json; reproduce withnode benchmarks/big-decimal/run-ops.mjsandwolframscript -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):
🟡 = value-correct but not fully simplified. — = not supported. SymPy 1.14 viacase CE 0.67.0 CE 0.66.0 math.js SymPy Mathematica simplify √(3+2√2)0.07 0.09 🟡 0.92 🟡 3.56 3.28 simplify √6·x + √2·x0.16 0.19 1.13 5.69 18.0 simplify (x²−1)/(x−1)0.10 0.15 🟡 0.99 8.53 0.17 d/dx √(1−x²)0.22 0.21 2.13 5.70 0.008 d/dx xˣ0.04 0.04 1.83 1.80 0.005 ∫ x eˣ dx0.08 0.09 — 6.53 0.57 ∫ x/(x²+1) dx0.16 0.18 — 7.23 0.60 lim sin(x)/x0.03 0.04 — 0.62 1.93 lim (1+1/x)ˣ0.55 1.13 — 2.76 5.81 solve x⁴+x²−1 = 01.88 4.65 — 8.56 0.55 solve x³−x−1 = 00.11 1.18 — 5.73 0.23 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
Factorhandles common square-pattern cases more efficiently.
0.66.0 2026-06-28
New Features
-
Multiplynow operates on vectors and matrices. Previously a product with any list/matrix operand was left unevaluated — even2 * [1, 2, 3].Multiply(i.e.*,\cdot,\times, and implicit products) now follows matrix-product / scalar-scaling semantics, matchingAdd'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 formatrix·vectorvsvector·matrix), andvector·vectorreduces to the dot product. This reuses the existingMatrixMultiplyimplementation.
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. - Scalar × tensor scales every element:
-
Hadamard (element-wise) product
\odot. A newHadamardProductoperator, 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 anincompatible-dimensionserror. 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 toAnd(5 \le 7, b \lt 7), droppingbfrom the first link (so3 \le 2 \lt 7wrongly evaluated toTrue). It now canonicalizes toAnd(5 \le b, b \lt 7). Uniform chains (5 \le b \le 7) and the already-correcta \lt b \le cform 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 stayingsin(π²)(and likewisecos(√2), etc.). These now stay symbolic underevaluate(); an inexact (float) argument such assin(2.5)still numericizes. -
An exact real added to the imaginary unit keeps its exact real part.
\frac12 + ievaluated to0.5 + i, and\frac34\sqrt3 + ito1.299… + i— the exact real part was floatified when folded withi. 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
float64element 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√2entries decayed to decimals. Exact entries now use theexpressionelement type and stay exact; inexact (machine/decimal) values continue to usefloat64. -
A^nis now the matrix power for an integer exponent. A power of a matrix was element-wise for non-negative exponents (A^2squared each entry,A^0gave a matrix of ones) yetA^{-1}already returned the inverse, and\begin{pmatrix}…\end{pmatrix}^2did not evaluate at all.A^nis now the matrix power — repeated matrix multiplication — consistent with*being the matrix product:A^2 = A·A,A^0is the identity,A^{-1}the inverse, andA^{-n} = (A^n)^{-1}. A non-square base reportsexpected-square-matrix. (Also fixesMatrixPower(A, n)forn < -1, which previously collapsed toA^{-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|whereABis 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/Multiplykeep 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 aTupleinstead of a product, because theMatrix(…)wrapper is not reported as an indexed collection. The invisible (implicit) operator now treats matrix operands as multiplication, consistent with*/\cdot/\times. -
Negate(and henceSubtract) 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))fromA B - A B— was left undistributed, so the followingAdd/Subtractmisclassified it as a scalar and broadcast it over the other matrix, yielding a bogus higher-rank result. Matrix subtraction (e.g. the commutatorAB - BA) now evaluates correctly. -
A
\textcolorwrapping a bare operator now parses as that operator. Input such asx \textcolor{red}{=} ypreviously failed — the=could not be parsed as a standalone group, producing aTuplearound anexpected-closing-delimitererror. The color command is now transparent in operator position, sox \textcolor{red}{=} yparses asEqual(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 anAnnotated. -
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\leftout as anunexpected-commanderror; it now parses the same as\sin\left(x\right)(→Sin(x)). The null open form (\left.…\right|, used byEvaluateAt) and ordinary two-sided delimiters are unchanged. -
Summation/product indices written as a
\lerange 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 expectedLimits, so the indexiis bound by the sum instead of falling through to the imaginary unit. The example above now evaluates to385rather than staying symbolic withi → Complex(0, 1). This mirrors the existing handling ofi \ge 1andi = 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 formy'(x) + p(x)·y(x) = q(x). It returns aListof solutions, each anEqualexpression fory(x), introducing an integration constantC(a fresh name is chosen ifCis already in use). For example,DSolve(y'(x) = y(x), y, x)→[y(x) = C·e^x]andDSolve(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 problemsy'(x) = f(x, y),y(x0) = y0, using a fixed-step fourth-order Runge–Kutta (RK4) method. It returns aListof[x, y]sample pairs over the interval given bylimits(aLimitsorTupleof(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 withErf).
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} 0Unlike
\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 acasesenvironment.A new
keywordStyleserialization option —"text"(default),"keyword", or"operatorname"— selects which spelling is emitted when serializingIf,Loop,Break,Continue, andReturnback 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-theorylibrary. 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 existingSigma0/Sigma1).IsSquareFree(n)— whethernis square-free.IsPerfectPower(n)— whethern = a^bfor integersa,b ≥ 2.
Primes:
-
NthPrime(n)— the nth prime (1-based):NthPrime(10)→ 29. (Mathematica names thisPrime, but in the Compute EnginePrimedenotes derivative notation, so the prime-number function isNthPrime.) -
NextPrime(n)/NextPrime(n, k)— the smallest prime greater thann; withk, the kth prime aftern(or the |k|th before it whenk < 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
nand switches to Miller–Rabin above 2³² (deterministic for the supported range), soNextPrimeandRandomPrimeare fast even for very large arguments.
Modular arithmetic & GCD:
PowerMod(a, b, m)— modular exponentiationa^b mod m; a negativebuses the modular inverse (undefined whenaandmare 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 ofamodulon;PrimitiveRoot(n)— the smallest primitive root modn.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 ofnin a given base, and its inverse.DigitCount(n, base?, digit?)— digit-occurrence counts;DigitSum(n, base?)— the digit sum.
-
IsPrimeis 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(andIsComposite) now route through a single deterministic Miller–Rabin implementation shared with the number-theory library, so e.g.IsPrime(2^61 - 1)correctly returnsTrue. (The previous duplicate Miller–Rabin code, which used random bases and overflowed for large inputs, has been removed.) Relatedly, the internaltoIntegerhelper now returnsnullinstead 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, andBellNumberno 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)returned6!!). 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. TheNfunction (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 productA·A·…, with negative powers using the inverse). Distinct from["Power", m, n], which threads element-wise.CharacteristicPolynomial(m, x?)— the monic characteristic polynomialdet(x·I − A)(variable defaults tox):[[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 returningTrue/False.
Resolved Issues
["N", expr]now numerically evaluates its operand. TheNoperator holds its argument unevaluated and previously called.N()on the still unbound operand — a no-op for symbolic constants — so["N", "Pi"]returnedPiunchanged (and["N", ["Sqrt", 2]]returnedSqrt(2)) instead of a numeric value. The operand is now bound before evaluation, making["N", expr]equivalent toexpr.N().
0.63.0 2026-06-26
New Features
-
LaTeX parse errors carry their source location. (contributed by zojize) The
Errorexpressions produced by the LaTeX parser now include asourceOffsets: [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 newParser.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 anErrornode is now emitted in object form ({ fn: ["Error", …], sourceOffsets }) rather than the bare["Error", …]array whenever a range is available — a consumer matchingexpr[0] === "Error"should also handleexpr.fn?.[0] === "Error". Through the boxed path (ce.parse(latex).toMathJson()), source offsets are opt-in metadata likelatexandwikidata: included withmetadata: ['sourceOffsets']ormetadata: '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 exponentk ≥ 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 byprettify: false. -
Double-quoted string literals in LaTeX.
"hello"now parses to a string (previously"was anunexpected-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{…}/\charremains 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 yieldsNothing. PreviouslyAtwas restricted to indexed (positional) collections and rejected dictionaries with anincompatible-typeerror; its value type is nowindexed_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/.realare key lookups, notFirst/Realcomponent access). Positional indexing of indexed collections is unchanged. -
BoxedExpression.referencedFunctionsandBoxedExpression.references. Two accessors aimed at dependency graphs (e.g. notebooks). The operator head of a function application — thefinf(x)org(x) := f(x) + 1— is not a symbol of the expression, so it appears in neithersymbolsnorfreeVariables;referencedFunctionsrecovers those applied user-function names (excluding built-in operators, constants, and names bound by an enclosing scope, using the same predicatefreeVariablesapplies to ordinary symbols).referencesis the complete in-edge set —freeVariables∪referencedFunctions, minusdefines— so it pairs withdefines(the out-edges) to build a use/def graph in one call. Subtractingdefinesdrops self-references, so a recursiveg(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 variableaina + 1, a called functionfinf(x)), recording an inferred binding. Callingce.declare(name, …)for such a name now refines that inferred binding rather than throwing"… already declared in this scope"— which is exactly what theinferredflag 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
canonicalandstructuraloptions are now honored byparse(),expr(), andfunction(). These methods only consulted theformoption when deciding how to box their result, so the documentedcanonical/structuralshortcuts were silently ignored:ce.parse(latex, { canonical: false })returned a canonical expression (and, as a side effect of canonicalization, auto-declared its symbols), andce.function('Power', ops, { structural: true })returned canonicalRootinstead of a structuralPower. The keys now resolve the same wayformdoes, with an explicitformtaking 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
indexStyleserialization option for collection indexing. TheAtoperator (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 viace.latexOptions.indexStyleor per-call viaexpr.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 asv_1(orv[1]withindexStyle: 'bracket'), both of which parse back toAt. -
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_1serializes toa_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.addhad 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.sumfolds those starting from a zero accumulator, and the very first0 + 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
Derivativeoperator 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 anx^(2ʳ)-scale denominator — e.g. the 75th derivative ofsin(x)/xcame back overx^(2⁷⁵). The result was mathematically exact (the integer coefficients are computed exactly), but the enormous exponent made it unusable and overflowed toNaNwhen evaluated at a point.Derivativeof 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-glslis now outward-rounded, making it a sound standalone exclusion oracle infloat32(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 theinterval-js(float64) result — a spuriously narrow interval can exclude a box the curve actually passes through. Every inexact operation now widens its result outward (lotoward −∞,hitoward +∞) 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 forPower(x^nwithn ≥ 3, and fractional powers such as the astroidx^{2/3}). Crucially, the cell box thatcompileExclusionShader'smain()builds is itself outward-rounded (via the new_iv_widen_box): the float32mixthat 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 themixerror — 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; theempty(lo > hi) /entire(±IV_INF) encodings, the finiteIV_INFsentinel, the per-op clamp, and exact empty-propagation are all preserved.Sin/Cosremain best-effort (see below). -
freeVariables/unknownsno longer report the bound variables ofFunctionliterals and integrals. A function literal leaked its own parameters, andIntegrate/Limitleaked their variable — e.g.freeVariablesoff(x) := x^2 + bwrongly included the parameterx, 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/Productwere already correct, andsymbolsis 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
CancellationErrorinstead of a nativeRangeError. A recursive definition with no reachable base case (e.g.f(x) := f(x-1) + 1) previously overflowed the JavaScript call stack with an uninformativeRangeError.recursionLimit— previously defined but never enforced — is now applied to user-function application: exceeding it throws aCancellationErrorwithcause: 'recursion-depth-exceeded', consistent with howtimeLimitanditerationLimitare surfaced. The defaultrecursionLimitis now 256 (was a nominal, unenforced 1024), chosen to fire below the native stack limit on typical engines; raisece.recursionLimitfor 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 catchesRangeErroras a backstop.) -
Integratebinds only the integration variable in its canonical integrand.∫ a·sin(x) dxpreviously canonicalized toIntegrate(Function(body, a, x), …), listing the free coefficientaas a spurious integrand parameter; it is nowIntegrate(Function(body, x), …). Introspecting the integrand (expr.op1) therefore reportsaas 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 \, dypreviously attached all the trailing differentials to the innermost integral, leaving the outer integrals with aNothingintegration variable — so the expression could not evaluate. Each\intnow consumes only its own differential (the innermostdxpairs with the innermost\int, the nextdywith the next), producing a properly nestedIntegratewhere every level carries its own variable and limits (\iint/\iiintstill 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 symbolicf(b) - f(a)was previously left as an unevaluatedEvaluateAt), 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\ointundefinedfor any\ointwith a region (its limit is a 3-elementTuple, serialized to MathJSON asTriple, which the serializer did not recognize) is also fixed:\oint_V f(s)\,dsnow serializes as\oint_{V}\!f(s)\, \mathrm{d}s. -
1^xsimplifies to1for any finite exponent. A symbolic or function exponent (e.g.1^{n+1},1^{\sin x}) previously leftPower(1, x)un-reduced because the canonicalizer bailed before its base-1 rule.1^x → 1now (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 epsilonsIV_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 usingcompileExclusionShader) outward-rounds it by calling_iv_widen_box(vec2(lo, hi), extent)per axis, whereextentis 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 linef = x. GLSL ESSin/Coscarry an absolute, implementation-defined error (≈2⁻¹¹ in the worst case; macOS ANGLE→Metal differs) that no relative pad can cover. A newtrigAbsPadoption (default0, off) oncompile(),IntervalGLSLTarget.compileExclusionShader(), and the newIntervalGLSLTarget.getPreamble()adds an absoluteSin/Cospad, 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-levelAssign/Declare(aina := 3,finf(x) := …), recursing throughBlock. It complementsfreeVariables(the symbols an expression references) — together they let tooling build a definition/use dependency graph, withreferences = freeVariablesminusdefines. -
ComputeEngine.appliedNonFunctions(latex). Returns the symbols written in function-application syntaxf(…)inlatexthat 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 thatf(x)was read asf·x.
0.61.0 2026-06-17
New Features
interval-glslcompilation target (preview). A GPU compilation target that evaluates an expression with interval arithmetic in GLSL — each value is avec2 (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 viainterval-js. (Reinstates theinterval-glsltarget removed in 0.52, with a simplervec2-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'sunsupportedfield, so a caller can fall back to another target per-expression. Values use a finite ±∞ sentinel and alo > hiencoding for the empty (domain-undefined) interval, propagated through every operation; domain-restricted functions (sqrt/ln/asin/rationalpowof an out-of-domain argument) yieldempty, and a pole (zero-spanning denominator,tanasymptote) yields the full range. Parity with theinterval-jstarget is verified against a shared corpus.IntervalGLSLTarget.compileExclusionShader()emits a complete, self-contained fragment shader (preamble + an_implicitinterval evaluator + a referencemainthat 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. 2iapplied to5returned2i(the imaginary unit doubled) instead of10. 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 (ioutside a parameter is still the imaginary unit), and closure capture is preserved (λi. λz. (z + i)capturesicorrectly). -
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 barea— an undeclared GLSL identifier (a shader that silently fails to compile) or a bare JS global (aReferenceErrorwhen the compiled function is called) — even though the symbol is omitted fromexpr.unknownsand folded byevaluate(). The value is now folded into the generated code (sin(a·x)→sin(1.5 * x)), makingcompile(),evaluate(), andunknownsconsistent. This also folds user-declared constants (ce.declare("c", { value: 3 })), and applies on the direct-targetcompile(expr, { target })path as well. A symbol supplied through thecompile()varsoption 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"))), foldingbinto a larger expression had two bugs: the compound value was spliced in without parentheses, sob · xcompiled toc + 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 symbolc, hidden behindb's value and therefore absent fromexpr.unknowns, was emitted as a bare global (ReferenceErroron 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 (_.con the JS / interval-JS targets; a uniform on GPU) and is reported in the result'sfreeSymbols. -
GPU compilation rejects non-finite numbers instead of emitting a non-compilable shader. GLSL and WGSL have no infinity or NaN literals, but
compile()emittedInfinity.0/NaN.0for a±∞orNaNvalue (e.g. from a literal\inftyor a constant-folded1/0) and reportedsuccess: 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 freecompile()falls back tosuccess: falsewith a diagnostic), consistent with how other GPU-unsupported constructs are handled. The JavaScript target is unchanged (Infinity/NaNare valid there). -
The JavaScript compilation target now lowers the exponential, trigonometric, and logarithmic integrals.
SinIntegral(Si),CosIntegral(Ci),ExpIntegralEi(Ei), andLogIntegral(li) compile to_SYSruntime helpers, matching the existing support forErf,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 dxfrom its closed form — no longer throwsUnknown operatorand 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, andChoosecompile to_SYSruntime helpers. Like the integral functions above, these are closed formsevaluate()/.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.EllipticEandEllipticPikeep their arity-overloaded complete/incomplete forms, andAGMaccepts the one-argumentAGM(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. ACompilationResultcarries 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/Loopindices,Blocklocals) are excluded, andvars-mapped symbols are always included. Unlikeexpr.unknowns, it also surfaces a free symbol reachable only through a folded value (e.g.bassignedc + 1exposesc). 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 failedcompile()this is populated alongside a human-readableerror, so an unlowerable operator (e.g.SinIntegralon the GLSL target) surfaces assuccess: falsewith a machine-readable list rather than only a thrown exception.
Built-in targets populate
freeSymbols(and an emptyunsupported) on every successful compile. The directgetCompilationTarget(name).compile(expr)path still throws on a genuinely unsupported operator (so the engine-levelcompile()can fall back to interpretation); theunsupported/errorfields are how the engine-levelcompile()reports that condition without a throw.
0.60.0 2026-06-16
Behavior Changes
-
isFiniteis now known for finite symbolic constants. Expressions such as√π,1/π, andπ^πreportexpr.isFinite === true(previouslyundefined), because finiteness is propagated throughSqrt,Root,Power, andDivideof finite operands. Cases that are genuinely indeterminate (e.g.1/xfor an unconstrainedx) still reportundefined. -
Exact transcendental expressions now remain symbolic under
evaluate(). For example,ln(2)remainsln(2)instead of becoming0.693…. Use.N()or{ numericApproximation: true }when a numeric approximation is wanted. Inexact inputs still evaluate numerically, and known exact values such ascos(π) = -1andarctan(1) = π/4still 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 toaⁿᵐbased solely on an odd inner exponent. This combine was unsound on the principal branch: whena < 0andmis not an integer, the two sides differ by a phase. For example(x³)^{1/2}now stays√(x³)(which is8iatx = -4) instead of becoming the inequivalentx^{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} = xstill 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 thelogand subtraction variants) is only valid on the principal branch; for arguments on the negative real axis the two sides differ by a multiple of2πi. For exampleln(-2) + ln(-3)no longer simplifies to the inequivalentln(6)(its true value isln(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 underevaluate()for a symbolic angle. Euler's formulae^{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} = yare unchanged); for a symbolic angle,e^{ix}stayse^{ix}— a basis change is not an evaluation, and it no longer differs from the previous inconsistency where(e^{ix})²expanded whilee^{ix}did not. Convert to trigonometric form on demand with the new strategyexpr.simplify({ strategy: 'trig' }). -
N()at a known pole now returnsComplexInfinityinstead ofNaN. When a function is evaluated numerically at a pole recorded in the new analytic-property metadata store (see Special Functions), the result isComplexInfinityrather thanNaNor an unevaluated expression — for exampleDigamma(0).N()andDigamma(-2).N(). Functions whose kernels already returned an infinity at their poles (such asGamma) 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.
| Expression | CE 0.60.0 | CE 0.59.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 15 | 20 | 174 | 107 | 3.9 |
\sin 1 | 25 | 61 | 220 | 429 | 5.2 |
\cos 1 | 24 | 60 | 222 | 455 | 7.1 |
\ln 2 | 87 | 302 | 339 | 4,374 | 3.7 |
e^{\pi} | 31 | 398 | 214 | 4,771 | 4.6 |
\zeta(3) | 3,419 | — | 264 | — | 49 |
\Gamma(\tfrac13) | 1,867 | 427,938 | 341 | — | 212 |
\psi(\tfrac13) | 1,689 | 404,300 | 2,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.
| Operation | CE 0.60.0 | CE + R/F | CE 0.59.0 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 1.5× | 3.7× | — | 0.5× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 2.5× | 2.6× | — | 0.09× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 2.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\,x | 28× | 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)^x | 1.6× | 1.6× | — | 2.1× | — | 1× |
\int_1^2\tfrac1x\,dx | 1996× | 1907× | — | 92× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 106× | 428× | — | 2.5× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.07× | 0.08× | — | 0.06× | — | 1× |
x^3-x-1=0 | 0.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.
mpmath. Reproduce:
npm run build production && ./venv/bin/python3 benchmarks/gen_cases.py && node benchmarks/report.mjs && node benchmarks/report_changelog.mjs.Calculus
-
Limitcan now return exact symbolic results. This includes direct substitution, indeterminate quotients, rational functions at infinity, dominant-term analysis, and exponential forms. Examples includelim(x→0) sin(x)/x = 1,lim(x→∞) (1+1/x)^x = e, andlim(x→∞) arctan(x) = π/2. Limits that cannot be determined reliably fall back to numeric evaluation or remain unevaluated.NLimitremains 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
ErfandErfi - 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|/2and∫|ax+b| dx = (ax+b)|ax+b|/(2a)(valid for allx)
- Gaussian integrals and quadratic exponentials using
-
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 offatx = 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, andResidue(cot(x), x, 0) → 1. Residues ofGamma,Digamma, andZetaat their poles use closed forms gated by the analytic-property store, e.g.Residue(Gamma(x), x, -2) → 1/2andResidue(Zeta(s), s, 1) → 1— including in a product or quotient with an analytic cofactor, such asResidue(Gamma(x)/(x-5), x, -2) → -1/14.
Algebra and Solving
-
solvehandles equations between two different inverse-trigonometric functions by applyingtanto both sides to clear them, then solving the resulting algebraic equation. For examplearcsin(x) = arctan(x) → 0andarccos(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
Solveoperator.Solve(equation, unknown)returns the list of solutions of an equation for an unknown, using the same solver as theexpr.solve()method — for example["Solve", ["Equal", "x^2", 1], "x"]returns["List", 1, -1]. The equation may be anEqualexpression or a bare expression read as= 0; the arguments are held, so the equation is no longer prematurely reduced to a boolean. -
solvenow 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. -
solvehandles 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) = 0solves 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 substitutingu = x²(orx³, …), so the roots are exact radicals (±√((√5−1)/2)) instead of numeric approximations. -
solvehandles equations that are polynomials in a single nonlinear generator, by substitutingu = g(x)for a logarithmic, exponential, trigonometric, or radical generatorg, solving foru, and inverting. For example(ln x)² = 4 → e², e⁻²,e^{2x} − 3eˣ + 2 = 0 → 0, ln 2, and√(ln x) = ln√x → 1, e⁴. -
solvefactors a zero product. When an equation is a product whose factors each involve the unknown — such asln(x)·(x − 1) = 0, or an already-factored(x + 1)·cos³(3x) = 0— its roots are the union of the roots of each factor. -
GCDnow finds common polynomial factors for univariate and multivariate polynomials. Integer operands retain their existing behavior; usePolynomialGCD()when an explicit polynomial result of1is 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) → 0andResultant(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 publicfactor()function once again factors expressions such asx²+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, andEllipticPi - The arithmetic-geometric mean
AGM Hypergeometric2F1,Hypergeometric1F1, andAppellF1- Jacobi theta functions and the Dedekind eta function
Erfi,SinIntegral,CosIntegral,ExpIntegralEi, andLogIntegral
- Complete and incomplete elliptic integrals:
-
Gammanow 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 orderss(Gamma(-4, 2),Gamma(1/2, -1)), and honors the exactness contract: it stays symbolic underevaluate()and reducesΓ(s, 0)to the ordinaryΓ(s). Use.N()for a numeric value. The one-argumentΓ(z)is unchanged. -
Hypergeometric2F1now supports analytic continuation across most of the complex plane, rather than being limited to its defining power series. -
ZetaandGammanow honor the requested precision. At highce.precision, numeric evaluation ofZeta,Gamma,GammaLn,Beta,Digamma,Trigamma, andPolyGammapreviously stalled near machine precision (e.g.Zeta(3)was correct to only ~16 digits regardless of precision). They now return the full requested precision —Zetauses 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 higherce.precisionit silently stopped at ~858 correct digits (making identities such asDigamma(1) = -γappear wrong past that point). It is now computed on demand to the full working precision. -
Gammaand 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 inequalityIm(τ) > 0, so they apply once youassume(Im(τ) > 0)(previously an opaqueτ ∈ HHset membership was required). A new LaTeX shorthand,\mathbb{C}^+(also\C^+), denotes the open upper half-plane:z \in \mathbb{C}^+canonicalizes toIm(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 weights ≥ 2gets a numeric kernel (Lambert-series q-expansion in the upper half-plane), joiningJacobiTheta/DedekindEta. For exampleEisensteinE(4, i).N()is1.45576…,EisensteinE(2, i).N()is3/π, andEisensteinE(6, i).N()is0(an elliptic fixed point). Exact arguments stay symbolic underevaluate(); the kernel requiresIm(τ) > 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 examplece.functionProperties('Gamma')?.polesis the setNonPositiveIntegers. Convenience accessors (poles,zeros,branchCuts,holomorphicDomain, …) return the unconditional record of each kind; parametric records (such as residues that depend on parameters) are available viaentries. This also powers pole-awareN()(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 digitsExp(x).N()is about three times faster, and a repeated base such as2^xor10^xabout 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 returnsNaN. Previously only unit fractions worked (they route throughSqrt/Root);(-4)^{3/2},(-8)^{2/3}, and similar fell through toMath.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 withSqrt(-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. Whenx^{p/q}has a real base and itsq-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 matchingN(). Non-perfect powers (2^{2/3}) and the negative even-root branch ((-4)^{3/2}, complex) stay symbolic underevaluate(). -
N()now fully evaluates applied functions and constants such ase,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, andCountnow operate on matrix rows consistently. For example,Count(matrix)returns the number of rows. -
Joinnow 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.timeLimitmore consistently. Operations that cannot finish in time either throwCancellationErroror 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, and1/√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; usece.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) to0when 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. -
Factoris 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)) dxand∫1/(x(1+x²)²) dx, which previously returned an unevaluated integral. -
Nested powers serialize to LaTeX and round-trip correctly. A
Powerwhose base is itself aPower— i.e.(aᵇ)ᶜ— was serialized asa^{bᶜ}, which re-parses asa^(bᶜ), a different expression. It now serializes as{aᵇ}^ᶜ, so e.g.(x³)^{2/5}round-trips instead of becomingx^{3^{2/5}}. -
GLSL/WGSL compilation no longer declares
int/i32for aBlock's local bindings. An integer-valued local (e.g.["Assign", "r", 3]) was declared asint 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 asfloat/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 asvec2/vec2f. -
Loopnow compiles to JavaScript that returns its collected values. A value loop such asLoop(i², Element(i, Range(1, 5)))compiled to afor-loop IIFE with noreturn, so it evaluated toundefinedat 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 useBreak/Continue/Returnare unchanged. -
Integratenow compiles to JavaScript that returns a numeric estimate. For the common\int x^2 dxparse shape (where the integrand is aFunctionexpression), the integrand was wrapped in a double lambda ((x) => ((x) => x*x)), so the Monte-Carlo estimator never called the inner function and returnedNaN; 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.5integrate 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 requestedform, or the form produced by the rule, applies to replaced subexpressions. Call.canonicalon the result to restore the previous behavior. -
Fixed-size numeric collections now infer dimensioned types. For example,
[1, 2, 3]is nowvector<3>instead oflist<number>, and a 3×3 numeric collection ismatrix<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.formcontrols the form of replacement expressions:'canonical','structural','raw', or a specific canonical transform. The previouscanonicaloption is deprecated and remains available as an alias for this release.ReplaceOptions.directionselects left-to-right or right-to-left traversal for order-sensitive rules.- Custom rules can now match user-defined function operators in
replace()andsimplify({ rules }).
-
Improved algebra:
solve()now handles quadratics with symbolic coefficients, includingx^2 - a x + 1 = 0and the generala x^2 + b x + c = 0. (#300)Factorinfers the variable of a univariate polynomial and preserves extracted numeric content:Factor(x^2 + 5x + 6)returns(x+2)(x+3), andFactor(6x + 9)returns3(2x + 3). (#309)
-
Parsing improvements:
- Two-argument
\arctan(y, x)and\tan^{-1}(y, x)now parse asArctan2. - 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.
- Two-argument
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 asPower(10, -100).N()are no longer rounded to zero. - Exact
floor(),ceil(), andround()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
Arctan2quadrants,ln(Root(a, b)), non-integer logarithm bases, exact radicals such assqrt(8), and division of Gaussian integers.
- Corrected complex powers, reciprocals, roots, and logarithms, including
-
Special functions and statistics:
- Added complex
GammaandGammaLnevaluation. Gamma and factorial poles at non-positive integers now returnComplexInfinity, while factorials of positive non-integers evaluate throughGamma(x + 1). - Improved
Erf/Erfcto machine precision and corrected small-argumentgammaln(). - Corrected
GCD,LCM,Congruent,Subfactorial, negative-indexFibonacci,IsOctahedral,Multinomial, andBellNumber. - Corrected skewness, kurtosis, interquartile range, histogram/bin endpoints, and exact combinatorial calculations.
- Added complex
-
Simplification, comparison, and assumptions:
- Indeterminate comparisons now remain unknown instead of becoming
false; this also improves sign inference,Boole, andKroneckerDelta. - 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.
- Indeterminate comparisons now remain unknown instead of becoming
-
Collections, matrices, and tensors:
- Corrected
Rest,Slice,Drop,Cycle,Position,SetFrom,TupleFrom,Filter,Zip, and compiledReducebehavior. - 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, andisTriangularpredicates. - Incompatible tensor broadcasts now throw instead of producing invalid data;
diagonal()respects its axis arguments, and mixed real/complex dtype joins preserve precision.
- Corrected
-
Types and serialization:
- Dimensioned list and matrix type strings now parse and round-trip, including
unknown dimensions, spaces, parenthesized element types, and single
^Ndimensions. - Corrected union reduction,
neversubtyping, narrowing of disjoint types, barematrixhandling, 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.
- Dimensioned list and matrix type strings now parse and round-trip, including
unknown dimensions, spaces, parenthesized element types, and single
-
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^2instead of32^2. (#302) - Re-declaring a parser symbol with the same type no longer reports a conflict.
- Corrected scaled/big delimiters, nested
-
Compilation:
- Corrected JavaScript compilation of symbolic
Range, compound-bounded intervalSum/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, andIf/Which/When. - Corrected symbolic derivatives of
ArcsecandArccsc.
- Corrected JavaScript compilation of symbolic
-
Interval arithmetic:
- Restored conservative enclosures for multiplication involving zero and
infinity, negative-modulus
mod,clamp,binomial,gcd,lcm,gamma,gammaln,sinc, and Fresnel integrals.
- Restored conservative enclosures for multiplication involving zero and
infinity, negative-modulus
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 —Repeatnow accepts an optional integercountand evaluates to a finite list ofcountcopies ofvalue. The 1-argRepeat(value)keeps its existing infinite-sequence semantics. Materialization is gated byce.maxCollectionSize; larger values stay lazy (still accessible via.at()/ iterator). -
ce.maxCollectionSize— new configurable cap (default10_000) on the number of elements a collection may have when materialized into a concreteList. Assigning<= 0orInfinitydisables the cap (matchingiterationLimitandrecursionLimit). -
Sum(L)collection-reducer form —Sumnow accepts a single collection argument and reduces to the sum of its elements:["Sum", ["List", 1, 2, 3, 4, 5]] // ➔ 15. The big-op formSum(body, [i, a, b], …)is unchanged. TheSumhead is now preserved through canonicalization (previously rewritten toReduce(L, "Add", 0)), soL.\operatorname{total}round-trips cleanly withlatexOptions.dotNotation = true. The async path throwsCancellationErroron signal abort. -
Atextended with boolean-mask and integer-list indices —At(L, mask)wheremaskis a finite collection ofTrue/Falsereturns the elements ofLwhere the mask isTrue.At(L, indices)whereindicesis 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\mapstolambdas is scalar parameters, so most user functions broadcast by default. To opt out, declare an explicit list parameter type viace.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 inspectingexpr.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 theTrue/Falsesymbols (previously fell through toUndefined). -
Lengthoperator 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 (previouslyundefined).Complexboxing is unchanged —["Complex", re, im]still produces aBoxedNumber. -
ce.symbolInfo(name)— new public API parallel toce.operatorInfo(), for introspecting constants and declared variables. Returns{ kind: 'constant' | 'variable', type: BoxedType }for symbols likePi,True,ExponentialE,ImaginaryUnit. Returnsundefinedfor unknown names and for operator heads. AddedSymbolInfotype to the public type surface.- Note:
Infinityis registered asPositiveInfinity/NegativeInfinity;Undefinedhas no value definition.
- Note:
-
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 callce.declare()with normalized names before parsing referencing rows. -
First/Second/Thirdcompile entries — component access (p.x,p.y,p.z) now compiles cleanly. JS uses[0]/[1]/[2]index access; GLSL/WGSL use.x/.y/.zswizzles, assuming the argument compiles to avec2/vec3/vec4. 5+-element tuples (which compile tofloat[N]arrays) aren't supported. -
RangeGPU compile entry —Range(lo, hi[, step])with compile-time-constant bounds emits an inlinefloat[N](...)(GLSL) orarray<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/MedianGPU compile entries — GLSL+WGSL parity with their JS counterparts.Varianceis inlined (no size limit).GCDuses a preamble function implementing the Euclidean algorithm.Medianis supported for list sizes 2–8; lists with 9+ elements throw.
-
RandomGPU compile with deterministic seed —Random(seed)in GLSL/WGSL compiles to a hash-based pseudorandom.Random()(no args) in GLSL falls back to agl_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
Randomis unchanged (stillMath.random, non-seeded). A seeded JS form will land in a future release.
- The fract-sin hash exhibits banding near
-
toSignedFunction()— new method onBoxedExpressionfor 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 normalizesGreaterEqualtoLessEqual(b, a)(and similarlyGreatertoLess), so callers will typically see theLess/LessEqualoperator on parsed expressions — the signed-function semantics are preserved. -
BoxedExpression.getInterval(symbol)— new method for extracting domain bounds from restriction expressions. ReturnsIntervalBoundswithlower/upper/lowerStrict/upperStrictforWhen(e, cond),And(c1, c2, …), and bare comparison expressions; returnsundefinedfor unsupported shapes. Useful for 2D-plot domain derivation (e.g. clippingy = f(x)\{0 < x < 5\}to[0, 5]). AddedIntervalBoundstype to the public type surface. -
Compact piecewise
\{cond_1 : val_1, …, default\}— now parses toWhich(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-levelColon: comparison/boolean heads (Less,Greater,Equal,And,Or,Not, …) → piecewise branch; otherwise → set-builder. Normal set literals (\{1, 2, 3\}) and set-builder via\midare unchanged.
Fixed
-
Linspaceendpoint inclusion —Linspace(a, b, n)now producesnpoints evenly spanning[a, b]inclusive of both endpoints (matching NumPy, Julia, and MATLAB). Previously the last sample fell short ofb(e.g.Linspace(0, 1, 5)yielded0, 0.2, 0.4, 0.6, 0.8instead of0, 0.25, 0.5, 0.75, 1).Linspace(a, b, 1)is the degenerate case and returns justa. Thecontainscheck 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 numericBoxedTensors.
Known issues
-
JS
Loopcompile producesundefined— the imperativefor-loop IIFE generated forLoop(body, Element(i, Range(lo, hi)))has noreturnstatement, so the compiled function returnsundefinedrather than the list of body values. Tracked for a future release. -
JS
Integratecompile producesNaN— whenargs[0]is aFunctionexpression (the common\int x^2 dxparse shape),compileIntegrateproduces a double-lambda, so_SYS.integratereceives a function-returning function. Tracked for a future release.
0.57.0 2026-05-10
Added
-
verbatimopt-in fortoLatex()—expr.toLatex({ verbatim: true })returns the original LaTeX source captured at parse time when the expression was parsed withpreserveLatex: true. Falls back to normal re-serialization if no verbatim is available (e.g. for synthetic or transformed expressions). The default behavior ofexpr.latexandexpr.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(), andce._fn()produce fresh expressions withverbatimLatex === 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. Useform: 'structural'to skip canonical handlers when verbatim preservation matters.
- Verbatim is set only on the top-level boxed expression produced directly by
-
dotNotationserialization 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 viace.latexOptions.dotNotation = trueor per-callexpr.toLatex({ dotNotation: true }). Only applies to arity-1 forms; multi-operand forms (e.g.Sumwith an index range) keep their standard serialization.- Serializer-only. The flag lives in
SerializeLatexOptionsand 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]whetherdotNotationis on or off. The flag only decides which form the serializer emits.
- Serializer-only. The flag lives in
-
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.xparses as["First", 1](not a malformed decimal);1.5.xparses as["First", 1.5]. - Only
\operatorname{...}and bare-letter identifiers are recognized after..\mathrm{...}is not accepted (deliberately tight). Thirdis a new operator (parallelsFirst/Second) with signature(any) -> any.First/Secondwere widened from(collection) -> anyto(any) -> anyso component access on a non-collection (e.g.1.x) defers type-checking to evaluation; evaluation returns anErrorexpression for incompatible types.
- Recognized members and their AST mapping:
-
Restriction braces (
expr\{cond\}) — trailing brace predicates parse to a newWhenhead.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 aSet;<expr>\{cond\}parses as aWhenrestriction. 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)evaluatese;When(e, False)returnsUndefined; indeterminatecondholds the form. - Serializer round-trips to the stacked-brace form (not
\wedgeinside 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 existingRangehead.- Endpoint-only form:
[a...b]→["Range", a, b]. Triggers...,\ldots, and\dotsare all accepted. - Inferred-step form:
[a_0, a_1, ..., a_n]→["Range", a_0, a_n, step]wherestep = a_1 - a_0is inferred from the first sample pair. Intermediate samples are validated againsta_0 + k·stepwithince.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.2exactly but is accepted within tolerance). - Outside
[...]brackets,\ldots/\dots/...continue to parse as theContinuationPlaceholdersymbol. The trigger is bracket context.
- Endpoint-only form:
-
For-comprehensions (
(x, y) \operatorname{for} x=L_1, y=L_2) — theLoophead now accepts multipleElementclauses, evaluated as nested loops with later bindings seeing earlier ones in scope.Loop(body, Element(x, L_1), Element(y, L_2), ...)produces anindexed_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_2parses with bodyx + yand 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 } bodysyntax. Multi-Element comprehensions serialize to the\operatorname{for}form.
-
Rangetype is now dynamic — element type narrows based on the step argument: integer step (or no step) yieldsindexed_collection<integer>; non-integer step yieldsindexed_collection<number>. Previously the type was alwaysindexed_collection<integer>, which was incorrect for float-step ranges. -
Whenhead — new conditional-value operator.When(expr, cond)returnsexprwhencondis true,Undefinedwhencondis false, and holds whencondis indeterminate. Used by restriction-brace parsing (see above) but also usable directly. -
ce.operatorInfo(head)— new method onComputeEnginefor introspecting registered operator heads. Returns{ kind: 'function' | 'opaque', signature?: BoxedType }orundefined.'function'— head has anevaluatehandler or acollectionhandler (lazy producers likeRange,Linspace,Tuplework via the latter).'opaque'— head is declared with a signature but has neither (e.g.,Triangle,Sphere,GeometricVector).undefined— no operator definition (constants likePiand unknown heads).- Lets external tooling classify heads by capability without maintaining a parallel list of supported operators.
-
toleranceinParseLatexOptions— populated automatically fromce.tolerancewhen parsing throughce.parse(). Used by list-range sample validation; available to other parse handlers that need tolerance-aware comparison.
Fixed
LoopwithElementclause — single-ElementLoop(body, Element(i, range))previously did not produce a list of body evaluations (the iteration path forElementform had a bug). The new variadic evaluator correctly yields aListof body values for each iteration.
0.56.0 2026-03-10
Added
-
First-class color values — colors are now typed values with a dedicated
colorprimitive 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,AsOklchconvert 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.
- Constructor heads:
-
JavaScript compile-target support for color values — all color constructors, the
As*converters,ColorDelta, andDistanceare 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'svec3/vec4representation, 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, andGeometricVectorare 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).GeometricVectoris distinct from the existingVector(column-vector construction). -
Tohead registered —\toalready parsed to["To", a, b]but was classified asunsupported-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()andexpr.toLatex()call. Precedence (most-specific wins):LatexSyntaxinstance defaults <ce.latexOptions< per-call options. Previously, options likedecimalSeparatorcould only be changed per call post-construction (andexpr.latexcould not be customized at all).
Changed
Color('...')now returns anOklchhead instead of a 0–1 sRGBTuple. The string parser still accepts the same set of CSS-style inputs.ColorMixnow returns anOklchhead 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 4color-mix).ContrastingColornow returns anRgbhead (was: 0–1 sRGBTuple).Colormapnow returnsOklchheads — either aList(Oklch, ...)or a singleOklchfor position-sampling.ColorToStringwith'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)forColorDelta,ColorContrast,ColorMix,ContrastingColor,ColorToString,ColorToColorspace. TheAs*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 onBoxedFunctionwas effectively disabled (causing every.typeaccess to recurse through all operands), andparseEnclosurewas 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 injectedLatexSyntaxinstance'sdecimalSeparator—ce.parse()hardcodeddecimalSeparator: '.', silently overriding any value configured on aLatexSyntaxpassed via the constructor'slatexSyntaxoption. 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; onlymetadata: '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:
\limwith postfix operators —\lim_{x\to 0}\left(x\right)^xnow correctly parses asLimit(x^x)instead ofPower(Limit(x), x). The\limparser was usingparseArguments('implicit')which stripped the delimiters and left the^xunconsumed; it now usesparseExpressionso 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 produceAnnotatedexpressions that preserve the styling information and round-trip correctly through serialization. Added\scriptstyle/\scriptscriptstyleserialization support (previously only\displaystyleand\textstylewere handled). -
LaTeX parsing: set-builder notation —
\{x \in \R \mid x > 0\}now parses to["Set", expr, ["Condition", cond]]. Registered\midas an infix operator (Divides, precedence 160). The serializer round-trips set-builder notation correctly. -
LaTeX serialization:
Complement—["Complement", "A"]now serializes toA^\complementinstead of falling back to the generic function form. Removed stale@todocomments about a non-existent multi-argument case. -
LaTeX parsing: spacing commands —
\hspace{dim},\hspace*{dim},\hskip, and\kernare now consumed during parsing (previously caused "unexpected token" errors). These are treated as visual spacing and skipped. -
LaTeX serialization:
HorizontalSpacingmath classes — the 2-argument form["HorizontalSpacing", expr, "'bin'"]now serializes to\mathbin{expr}(and similarly forrel,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 aroundAbs,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
groupStyleto choose between bare delimiters,\left..\right, or\bigl..\bigrscaling. -
LaTeX parsing: Greek symbols in string groups —
\alpha,\beta, etc. inparseStringGroupContent()(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_uvand viewport uniforms instead of the shader template's single-precisionmix(), 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 droppedhints— thehintsoption (viewport center/radius) was accepted but silently not forwarded to the language target. Fixed incompile-expression.ts.
New Features
BigDecimalexport — 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.HighPrecisionCoordtype — new union type (number | string | { hi: number; lo: number }) for passing extended-precision viewport coordinates through the compile API. Theviewport.centeroption 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 anIntervalexpression. 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.,\rpareninstead 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 correctinvalid-symbolerror instead of cascading parse errors. Also fixedmatchPrefixedSymbolleaking 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 optionalhints: { viewport: { center, radius } }. The compiler auto-selects the precision strategy and returnsstaleWhenthresholds for cheap staleness checking by the plot engine. CompilationResultextensions — 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, andRoothandlers now fold numeric literals at compile time and eliminate identity values.x + yicompiles tovec2(x, y)instead ofvec2(x, 0.0) + (y * vec2(0.0, 1.0))2 + 3→5.0,x + 0→x,x * 1→x,x * 0→0.0Power(x, 2)→(x * x)for simple operands,pow(f(x), 2.0)for complex expressions to avoid duplicate computationPower(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)
isComplexValueduses expression type system instead of hard-coded operator list.- Integer arguments in GPU fractal functions emit as
200instead ofint(200.0). - Type-based optimizations — compilation handlers now use expression type
information for better code generation:
Floor/Ceil/Round/Truncateare no-ops when the operand is integer-typedAbsis a no-op when the operand is provably non-negativePower(x, 2)only expands to(x * x)for simple operands (symbols, literals) — function calls likePower(Sin(x), 2)usepow/Math.powto avoid duplicate evaluation- Integer
Modwith non-negative dividend uses plain%instead of the Euclidean double-mod formula - GPU variable declarations infer
i32/inttype for integer-typed locals
Resolved Issues
Abssignature: return type is nowrealinstead of propagating the input type (which incorrectly returnedcomplexfor complex inputs).- Compilation fallback: uses
pushScope/assignpattern instead of crashing when receiving a vars object.
New Features
MandelbrotandJuliaoperators 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 missingflush()call inparseTextRun.#/*parsed as valid symbols: Bare#and*tokens were incorrectly accepted as valid symbol names because they match the UnicodeEmojiproperty (keycap base characters). They now produceunexpected-tokenerrors as expected. The fix excludes ASCII characters from the emoji regex in symbol validation.Textoperator type: TheTextoperator now has return typestringinstead ofexpression.\textcolorinside\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.parseSyntaxErrortoken consumption: Non-command tokens (like#,&) are now consumed when producing errors, preventing potential parser loops.parseSymbolTokenhardening: Raw tokens are pre-validated against\p{XIDC}before being consumed as symbols, providing defense-in-depth against futureisValidSymbolregressions.
New Features
- Text promotion: When
InvisibleOperatorcanonicalization encounters aTextexpression or a string operand, it now absorbs all operands into a singleTextexpression. For example,a\text{ in $x$ }bcanonicalizes to["Text", "a", " in ", "x", " ", "b"]instead of producing aTuple. - Text infix keywords:
\text{and},\text{or},\text{iff}, and\text{if and only if}are now recognized as infix operators that produceAnd,Or, andEquivalentexpressions respectively, following the existing\text{where}pattern. - Additional text keywords:
\text{such that}(maps toColon),\text{for all}(maps toForAll), and\text{there exists}(maps toExists) are now recognized as operators. Textserializer:Textexpressions now round-trip back to proper\text{...}LaTeX with inline$...$for math sub-expressions, instead of falling through to the default\mathrm{Text}(...)output.Textevaluate handler: Evaluating aTextexpression now concatenates all operands into a single string.
0.55.1 2026-03-04
Resolved Issues
- After
parse('f(x):=\\sin(x)'), the symbolfis now immediately recognized as having typefunction. Previously its type remainedunknownuntil theAssignexpression was explicitly evaluated. 2f(x)and2f \left(x\right)now both correctly parse as["Multiply", 2, ["f", "x"]]whenfis a known function symbol. Previously, a space before\leftcaused the parser to produce aTupleinstead ofMultiply, and expressions whose return type wasany(e.g., calls to generically-typed functions) were also misclassified asTuple.- Expressions involving operators that return
expressiontype (such asD,Simplify,Annotated) are now correctly treated as multiplicable in juxtaposition contexts. For example,2f'(x)now produces["Multiply", 2, ["D", ...]]instead ofTuple. - The
D(derivative) operator now returns a numeric type when its body is numeric, instead of always returning the genericexpressiontype. - 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 toce.expr()/expr()(ce.box()remains as a deprecated wrapper).- Removed
ce.latexDictionarygetter/setter; configure dictionaries throughnew LatexSyntax({ dictionary: [...] }). - Removed
ComputeEngine.getLatexDictionary(); import dictionary constants from package exports. - Removed deprecated type guard aliases:
isBoxedExpression,isBoxedNumber,isBoxedSymbol,isBoxedFunction,isBoxedString,isBoxedTensor(useisExpression,isNumber,isSymbol,isFunction,isString,isTensor). - Removed
LibraryDefinition.latexDictionary; LaTeX dictionaries now live in thelatex-syntaxmodule.
Resolved Issues
- #295 The
parse()free function now accepts the form options object, soparse("\\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, includingmath-json). - New standalone
LatexSyntaxAPI (class +parse()/serialize()helpers) for LaTeX ↔ MathJSON without aComputeEngineinstance. - New
ILatexSyntaxinterface exposed viaIComputeEngine.latexSyntaxto allow custom LaTeX parser/serializer implementations. - All 16 LaTeX domain dictionaries are now exported individually, plus the
combined
LATEX_DICTIONARY. Parsertype is now exported from the main package for typed customLatexDictionaryEntryparse handlers.
Changed
ComputeEnginenow accepts an injectablelatexSyntaxdependency.- Full package imports still auto-create a LaTeX syntax instance.
- Core-only imports do not bundle LaTeX support;
parse(),.latex, andtoLatex()require an injectedLatexSyntax. - MathJSON serialization omits optional LaTeX metadata when no LaTeX syntax is present.
decimal.jshas been replaced with a nativebigint-backedBigDecimalimplementation, reducing dependency surface and bundle size.BigDecimaladd(),sub(), andmul()are now exact; rounding is limited to operations that require it (div(), non-integerpow(), transcendentals).- Numeric string/LaTeX serialization now respects precision settings:
.latex/.toString()round toce.precision, while.json/toJSON()remain lossless. - High-precision special functions (
bigGamma,bigGammaln,bigDigamma,bigTrigamma,bigPolygamma,bigZeta) now scale withBigDecimal.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, orundefinedif the expression is not a polynomial. Auto-detects the variable when the expression has exactly one unknown. SubsumesisPolynomial(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, orundefinedif not a polynomial. Handles degree 3+ polynomials with rational roots via the Rational Root Theorem. -
New
PolynomialCAS function: Constructs a polynomial from a coefficient list (descending order) and a variable. Inverse ofCoefficientList:Polynomial([1, 0, 2, 1], x)evaluates tox³ + 2x + 1. -
Improved
Factorfor degree 3+ polynomials:Factornow uses the Rational Root Theorem to factor polynomials with integer coefficients and rational roots. Previously only handled degree ≤ 2. -
Improved
Factorwith content extraction:Factornow extracts the GCD of integer coefficients before applying other strategies. For example,Factor(6x² + 12x + 6, x)now produces6(x+1)². -
New
PartialFractionCAS 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
ApartCAS function: Alias forPartialFraction. -
New
PolynomialRootsCAS function: Returns the roots of a polynomial as a set. Example:PolynomialRoots(x² - 5x + 6, x)→{2, 3}. -
New
DiscriminantCAS 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 aDivideexpression has a denominator already in factored form (product or power) and the decomposition is simpler,simplify()automatically applies partial fraction decomposition. -
Breaking:
CoefficientListnow returns descending order: The CAS functionCoefficientListnow returns coefficients from highest to lowest degree (e.g.,[1, 0, 2, 1]forx^3 + 2x + 1), matching the newpolynomialCoefficients()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.useVariationsandmatchMissingTermsdefault totruefor string patterns. -
expr.match()now accepts MathJSON arrays directly: Pass a raw MathJSON pattern like['Add', '_a', '_b']without callingce.box()first. -
New
matchMissingTermsoption formatch(): When enabled, expressions with fewer operands than the pattern can still match by treating missing terms as identity elements (0 forAdd, 1 forMultiply). For example,3x^2+5matches the patternax^2+bx+cwithb = 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
-
timeLimitnow reliably interrupts long-running evaluations:Factorial,Sum,Product,Loop, andReduceall respect thetimeLimitproperty and throwCancellationErrorwhen the deadline is exceeded. Previously, generators yielded too infrequently (every 1,000–50,000 iterations), allowing a singlegen.next()call to block for longer than the timeout. All generators now yield every iteration. TheFactorialhandler no longer silently swallowsCancellationError, andwithDeadline/withDeadlineAsyncnow usetry/finallyto always reset the engine deadline. -
Fixed GPU compilation of
Sum,Product,Loop, andFunction: These constructs no longer leak JavaScript-specific syntax (IIFEs,let,while, arrow functions,{ re, im }objects) into GLSL/WGSL output.Sum/Productwith small constant bounds are unrolled inline; larger ranges emit nativeforloops.Loopemits a GPUforloop withint/i32index.Function(lambda) now throws a clear error for GPU targets. Block-levelDeclarestatements infervec2/vec2ftype 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/FresnelSuse a three-region rational Chebyshev approximation (ported from Cephes/scipy) with a shared_gpu_polevlhelper.BesselJuses 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 usefloat x(GLSL) orvar x: f32(WGSL) instead oflet x, and blocks are emitted as plain statements instead of JavaScript IIFEs.compileFunctioncorrectly 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 asHorizontalSpacingexpressions wrapped inInvisibleOperator. -
Fixed
require()returning empty exports on Node 22+ (#292): Because the package sets"type": "module", Node treated the UMD.jsfiles as ESM, breaking the UMD factory pattern. The UMD builds now use a.cjsextension so Node always treats them as CommonJS.
0.53.0 2026-02-21
Runtime and Scoping
-
True lexical scoping for
Functionexpressions: 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 vianoAutoDeclare. -
Closure capture for nested functions: Returned functions now correctly capture outer parameters across multiple nesting levels.
-
EvalContext.valuesremoved: Symbol values now live only inBoxedValueDefinition.value. The per-frame shadow map andwithArgumentsoption were removed. -
forget()now resets values set byassume():forget('x')now clears values introduced byassume('x = ...')(value reset toundefined), in addition to clearing assumptions.
Expressions and Equality
-
expand()now returns the input expression instead ofnull: Both the free function and internalexpand()/expandAll()now return the original expression when no expansion is possible. -
New
.toRational()method: Returns[numerator, denominator]integers for rational expressions, ornullotherwise. -
New
.factors()method: Returns multiplicative factors as a flat array by decomposingMultiplyandNegatestructurally. -
.is()now tries expansion: After structural comparison,.is()expands both sides before numeric fallback, catching forms like(x+1)^2andx^2+2x+1. -
.is()is now symmetric:a.is(b) === b.is(a)now holds across all expression types.
LaTeX Parsing
-
Parse
\mleft/\mrightdelimiters: Alternative delimiters from themleftrightpackage are now treated like\left/\right. -
Parse
\colorin math mode:\color{...}is now recognized in math mode; the color argument is consumed so the following math parses normally. -
Parse
:and\colonas infix operators: Outside quantifier contexts, a bare:/\colonnow parses asColon(e.g.f:[a,b]\to\R), without affecting:=assignment or quantifier syntax. -
Parse
\dfrac,\tfrac, and\cfracas fractions: These variants now parse the same as\frac.
Fractals
- New
MandelbrotandJuliafunctions: Added built-in escape-time fractal operators.Mandelbrot(c, maxIter)andJulia(z, c, maxIter)return a smooth, normalized value in[0, 1](1for interior points, fractional for escaping points vialog₂(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.isExactto test for exact numeric literals. -
rawform preserves subtraction:x-1now 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 spuriousNothingoperands. -
Fix
\text{if}parsing with\;spacing:\text{if}\;...\;\text{then}\;...\;\text{else}\;...now parses correctly asIf. -
Block serializer now uses
;: Serialization emits;(not;\;) to avoid reintroducing spacing-related parse issues on round-trip. -
Block compiler filters
Nothingoperands: The Block compiler now removesNothingsymbols and empty compile results before generating code. -
Subscripted variable names in blocks: Names like
r_1are treated as compound symbols (notSubscript) when the base is not a known collection. -
Non-strict parser supports exponents on bare functions: In
strict: falsemode, forms likesin^2(x)andcos^{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-glslnow emits only used helper functions (plus dependencies), typically reducing preamble size by 60-80%. -
Selective WGSL interval preamble:
interval-wgslnow 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 optionaltolerance: A per-call tolerance can overrideengine.tolerancefor 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 toExpression. 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 withinengine.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)returnedfalsebecause.is()was purely structural. Now it returnstrue:ce.parse('\\sin(\\pi)').is(0); // true (evaluates, within tolerance)ce.parse('\\cos(\\frac{\\pi}{2})').is(0); // truece.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 theisNumber()guard with.numericValueaccess. Returns the numeric value if the expression is a number literal, orundefinedotherwise. Useful for safely extracting numeric values without verbose ternary patterns:import { numericValue } from '@cortex-js/compute-engine';// Beforeconst val = isNumber(expr) ? expr.numericValue : undefined;// Afterconst 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 likesin²(x) + cos²(x) = 1,(x+y)² = x²+2xy+y², andsin(2x) = 2sin(x)cos(x)that were previously returned asundefined. 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 + yvsy). -
expr.freeVariablesproperty: New property onBoxedExpressionthat 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 likeSumorProduct. Semantically identical toexpr.unknowns. -
New interval-js compilation functions: Added
Binomial,GCD,LCM,Chop,Erf,Erfc,Exp2,Arctan2, andHypotto the interval-js compilation target, with corresponding interval arithmetic implementations. -
GLSL/WGSL variable exponent support: The interval GLSL and WGSL targets now support
Powerwith variable exponents (e.g.(-1)^k,x^n). Previously these threw at compile time. Addedia_pow_interval()to both GPU library preambles using four-cornerexp(exp * ln(base))evaluation with special cases for point-integer exponents and(-1)^n. -
Factorial,Gamma,GammaLnfor GLSL/WGSL interval targets: Addedia_factorial(viaia_gamma(x+1)) to both GPU targets. Addedia_gamma(Lanczos approximation) andia_gammaln(Stirling asymptotic) to the WGSL target, matching existing GLSL implementations.
Resolved Issues
-
parse()withform: 'structural'ignored the structural flag: Thestructuraloption fromformToInternal()was dropped inparseLatexEntrypoint(), makingce.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: Usingce.parse(s, { form: ['Flatten', 'Order'] })unexpectedly evaluated numeric operands (e.g.3×2+1became7) becauseflattenForm()usedce.function()which defaults to full canonical mode. Now usesce._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 variablen. The compilation extracted bounds vianormalizeIndexingSet()which converted symbolic bounds toNaNand 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)^kreturnedemptyinstead of correct value: ThepowInterval()function required positive bases for variable exponents, causing(-1)^kpatterns in summations (e.g. Taylor series) to fail at runtime. Now correctly delegates tointPow()when the exponent is a point interval with an integer value, preserving even/odd parity. Also handles the case where base is-1and the exponent spans multiple integers by returning the conservative interval[-1, 1]. -
Factorialmissing from interval-js compilation target: Expressions containingn!(e.g.\frac{(-1)^k x^{2k+1}}{(2k+1)!}) failed interval-js compilation withsuccess: false. AddedFactorialandFactorial2interval functions and compilation handlers. -
expr.unknownsincluded bound variables: Scoped constructs likeSum,Product,Integrate, andBlockbind index variables in a local scope, butexpr.unknownswas reporting them as free unknowns. For example,\sum_{k=0}^{10} k \cdot xreturned["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 boundMwas incorrectly excluded fromunknownsbecause the scope's bindings map captured all symbols referenced during canonicalization. Now extracts bound variables structurally fromLimits/Element/Assign/Declareexpressions, so only true bound variables are excluded. This also fixesBlockexpressions where locally assigned variables (viaAssignorDeclare) were reported as unknowns. -
Integratewith symbolic bounds compiled incorrectly: Same issue as Sum/Product —compileIntegrate()usednormalizeIndexingSet()which converted symbolic bounds toNaN. Now usesextractLimits()and compiles bounds as expressions. -
Interval
piecewisetest fix: Fixed test that incorrectly accessedresult.lodirectly instead of unwrapping theIntervalResultenvelope (result.value.lo). Thepiecewise()function correctly returnsIntervalResultobjects.
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, producingAdd(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. dmsFormatserialization option: SetdmsFormat: trueinSerializeLatexOptionsto serialize angle quantities as DMS notation (e.g.Quantity(9.5, deg)→9°30').angleNormalizationserialization option: Normalize angles during serialization with'0...360'(useful for bearings) or'-180...180'(useful for longitude). Default is'none'.realOnlycompilation option: Pass{ realOnly: true }tocompile()to automatically convert complex{ re, im }results to real numbers — returnsrewhenim === 0,NaNotherwise. Useful for plotting and other contexts that only need real-valued output.Sincfunction: Unnormalized cardinal sinesinc(x) = sin(x)/xwithsinc(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. Heavisidestep function:H(x) = 0forx < 0,1/2forx = 0,1forx > 0. LaTeX parsing via\operatorname{Heaviside}, JavaScript and interval-arithmetic compilation with singularity detection at zero.
LaTeX Syntax
Whichcompilation:\begin{cases}expressions now compile to JavaScript and interval-js targets as chained ternary operators withNaNfallback when no condition matches.Sum/Productcompilation:\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.Loopcompilation:Loop,Break,Continue, andReturnoperators compile to JavaScriptforloops wrapped in IIFEs with standard control flow keywords.- Inline
Ifsyntax: Parse\text{if } C \text{ then } A \text{ else } B(or\operatorname{if}) to["If", C, A, B]expressions. wheresyntax: ParseE \text{ where } x \coloneq VtoBlockexpressions with implicit variable declarations.- Semicolon block syntax: Semicolons (
;,\;) act as statement separators, buildingBlockexpressions with auto-declared variables when assignments are present. forloop syntax: Parse\text{for } i \text{ from } a \text{ to } b \text{ do } bodyto["Loop", body, ["Element", "i", ["Range", a, b]]].
Resolved Issues
- Interval-JS compilation for Gamma functions: Added missing
gammaandgammalnexports and implementations in the interval-arithmetic library. - Interval-JS graceful fallback: The
interval-jstarget 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.runtype signature: The TypeScript type forrunnow correctly reflects the actual calling convention ((...args: unknown[])) instead of the previous misleading(...args: (number | {re, im})[]).Loopcompilation for interval-js target: Loop counter now uses raw numbers (not_IA.point()) for theforstatement, with loop index references properly wrapped in the body. Conditions inif/break/continuestatements inside loops use scalar comparisons instead of interval comparison functions.
Other Changes
- Updated color palettes
- Deduplicated runtime helper object (
SYS_HELPERS) shared betweenComputeEngineFunctionandComputeEngineFunctionLiteralin compilation target - Centralized
sincimplementation innumerics/special-functions.ts(shared by library evaluation and JS compilation runtime) - Removed dead
args === nullchecks in compilation base class
0.51.0 2026-02-14
Colors
- New
colorslibrary: 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 sRGBTuplewith 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 aList; 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 orTuple) to components in"rgb","hsl","oklch", or"oklab"(alias"lab"). Preserves alpha when present.ColorFromColorspace: Convert color space components back to a canonical sRGBTuple. Accepts the same color space names asColorToColorspace.ColorToString: Convert a color (string or sRGBTuple) 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 sRGBTuplevalues. 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 throughAnnotatedexpressions. Parsing and serialization are handled in the coreAnnotatedinfrastructure. - LaTeX font annotations:
\textbf,\textit,\texttt,\textsf,\textupnow serialize correctly fromAnnotatedexpressions viafontWeight,fontStyle, andfontFamilydict keys. - JavaScript compilation: All color operators (
Color,ColorToString,ColorMix,ColorContrast,ContrastingColor,ColorToColorspace,ColorFromColorspace,Colormap) now compile to JavaScript. oklab()CSS parsing:parseColor()now acceptsoklab(L a b)andoklab(L a b / alpha)syntax, matching the existingoklch()support.- GPU compilation:
ColorMix,ColorContrast,ContrastingColor,ColorToColorspace, andColorFromColorspacenow 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. ExportedhslToRgb()(previously private).
Resolved Issues
- (#290) Derivatives of user-defined functions:
\frac{d}{dx} fandf'(x)now correctly evaluate whenfis a user-defined function (e.g.,f(x) := 2x). Previously\frac{d}{dx} freturned0andf'(x)returned a symbolicApply(Derivative(...)). - Cleaner
Dcanonical 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 inFunction(Block(...)). Similarly,\frac{d}{dx} fwherefis 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 acceptExpressionInputin addition toLatexStringandExpression. This means you can pass numbers, MathJSON objects, or tuple arrays directly — e.g.,evaluate(["Add", 1, 2])orsimplify(["Power", "x", 2]). - Added
declare()free function to declare symbols without instantiating aComputeEngineexplicitly — e.g.,declare('x', 'integer')ordeclare({ x: 'integer', y: 'real' }).
Units and Quantities
- New
unitslibrary: 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). Quantityexpression: Pairs a numeric value with a unit:["Quantity", 9.8, ["Divide", "m", ["Power", "s", 2]]]. AccessorsQuantityMagnitudeandQuantityUnitextract the parts.- Quantity arithmetic:
Add,Subtract,Multiply,Divide, andPowerare 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 mevaluates to1.12 m). Incompatible dimensions remain unevaluated. - Unit conversion:
UnitConvertconverts between compatible units, including compound units likem/stokm/h. Supports affine temperature conversions (degC,degF,K). Returns an error for incompatible units.UnitSimplifyreduces compound units to named derived units when possible (e.g.,kg*m/s^2toN). - Dimensional analysis:
IsCompatibleUnittests dimensional compatibility.UnitDimensionreturns the 7-element SI dimension vector. Both support compound unit expressions. - LaTeX parsing:
\mathrm{...}and\text{...}containing recognized units produceQuantityexpressions when juxtaposed with numbers. Compound units with/,^, and\cdotare 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:
Quantityexpressions serialize tovalue\,\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:
degCanddegFwith affine offset conversions. - Angular unit unification: Trigonometric functions (
Sin,Cos,Tan, etc.) acceptQuantityarguments with angular units (deg,rad,grad,arcmin,arcsec) and convert to radians automatically. - Physics constants: 11 CODATA 2018 constants defined as
Quantityexpressions:SpeedOfLight,PlanckConstant,Mu0,StandardGravity,ElementaryCharge,BoltzmannConstant,AvogadroConstant,VacuumPermittivity,GravitationalConstant,StefanBoltzmannConstant, andGasConstant.
Compilation
- Tuple and Matrix compilation:
TupleandMatrixexpressions 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/mat4in GLSL,mat2x2f/mat3x3f/mat4x4fin WGSL) with proper column-major transposition. Column vectors are flattened tovecN/vecNfinstead 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 thecomplex-esmlibrary. Mixed real/complex operands are promoted inline.ImaginaryUnitcompiles to{re: 0, im: 1}. Symbols with unknown type are assumed real. Complex-awareSumandProductloops 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 thecmathmodule for transcendental functions. Real-valued expressions continue to use NumPy. - Gamma function compilation:
GammaandGammaLncan now be compiled tointerval-js,glsl,wgsl, andinterval-glsltargets. 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, andFibonacci. - GPU special functions:
Erf,Erfc,ErfInv,Beta,Factorial,Arctan2,Hypot,Haversine,InverseHaversine,Log10, andLgcan now be compiled to GLSL and WGSL targets.Erf/ErfInvuse Abramowitz & Stegun polynomial approximations;BetaandFactorialleverage 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 toBinomial(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
RationalandExactNumericValuearithmetic by centralizing overflow checks and automatic promotion toBigInt. - [#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 toBigIntto 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 thenumericApproximationoption, significantly speeding up large numerical calculations by avoiding expensive exact arithmetic.
0.50.1 2026-02-11
Compilation
CompilationResult.preamblefor shader targets:compile()withinterval-wgslandinterval-glsltargets now returns apreamblefield containing the interval arithmetic library (struct definitions, helper functions). Previously, the compiledcodereferenced functions likeia_divandia_sinthat were not included in the output. Usepreamble + codefor a self-contained shader, or callcompileShaderFunction()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).BoxedExpressionis retained as a deprecated alias for migration. - The MathJSON type is now
MathJsonExpression(the old MathJSONExpressionname has been removed from themath-jsonentrypoint). SemiBoxedExpressionis nowExpressionInput(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 Expression | Access via |
|---|---|
.symbol | isSymbol(expr) or isSymbol(expr, 'Pi') then expr.symbol |
.string | isString(expr) then expr.string |
.ops, .nops, .op1/.op2/.op3 | isFunction(expr) or isFunction(expr, 'Add') then expr.ops etc. |
.numericValue, .isNumberLiteral | isNumber(expr) then expr.numericValue |
.tensor | isTensor(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.
| Function | Purpose |
|---|---|
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 returningCompilationResult. Custom compilation targets are managed withce.registerCompilationTarget()andce.unregisterCompilationTarget().expand()andexpandAll()returnnullwhen 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 asfactorPolynomial()andfactorQuadratic()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:
canonicalAddandcanonicalMultiplynow fold exact numeric operands at canonicalization time, making behavior consistent withcanonicalDividewhich 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)remainsAdd(x, 0.5, 1.5) - Infinity/NaN:
Multiply(0, ∞)correctly returnsNaN - Single numeric:
Multiply(5, Pi)is unchanged (nothing to fold)
The folding uses the existing
ExactNumericValuearithmetic, which automatically handles radical grouping (√2 + √2 = 2√2) and rational simplification (1/3 + 2/3 = 1). - Integers:
-
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)→8Power(3, 2)→9Power(1/2, 2)→1/4Power(-2, 3)→-8Power(2, 100)remains unevaluated (exponent exceeds limit)
-
Complex promotion handles non-adjacent operands:
canonicalAddnow 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
typehandlers to operators that were missing them, enabling the type system to return precise types instead of the broad signature return type.- Arithmetic:
Factorial,Factorial2,Signreturnfinite_integer;CeilandFloorreturnfinite_integerfor finite inputs,integerotherwise. - Trigonometry:
ArctanusesnumericTypeHandler(returnsfinite_realfor real inputs,finite_numberfor complex). - Complex:
Real,Imaginary,Argumentreturnfinite_real. - Number theory:
Totient,Sigma0,Sigma1,Eulerian,Stirling,NPartitionreturnfinite_integer;SigmaMinus1returnsfinite_rational. - Combinatorics:
Choose,Fibonacci,Binomial,Multinomial,Subfactorial,BellNumberreturnfinite_integer. Truncate,GCD,LCMtype handlers:Truncatereturnsfinite_integerfor finite inputs (matchingCeil/Floor);GCDandLCMalways returnfinite_integer.
- Arithmetic:
Solving
-
Andoperator support for systems of equations:solve()now acceptsAnd(Equal(...), Equal(...))in addition toList(Equal(...), Equal(...))for representing systems of equations. Both forms route through the same linear, polynomial, and inequality solvers. -
Parametric solution type filtering:
filterSolutionByTypesnow uses=== falseinstead of!== truefor type predicate checks. This allows underdetermined (parametric) solutions to pass through when type predicates returnundefined(unknown) rather than being incorrectly rejected. -
Oroperator support insolve(): SolvingOr(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 combiningEqualand 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 numbersPolyGamma: generalized recurrence for arbitrary order nBeta: via gamma, with log-gamma fallback for large argumentsZeta: Cohen-Villegas-Zagier acceleration, functional equation for\operatorname{Re}(s)<0LambertW: Halley's method with branch-point handling
-
Arbitrary-precision (bignum) variants for special functions: When
ce.precision > 15,Digamma,Trigamma,PolyGamma,Beta,Zeta, andLambertWnow compute results to the requested precision using bignum arithmetic. The asymptotic shift threshold scales with precision to maintain accuracy (e.g.,ce.precision = 50produces 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 forY_0/Y_1, forward recurrence for higher orders, shared Hankel asymptotic withBesselJBesselI: power series + asymptotic expansionBesselK: series forK_0, Wronskian-derivedK_1, forward recurrence for higher orders, asymptotic for largex
-
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 positivex, oscillatory for negativex) for large arguments.
Linear Algebra
(Fix #285)
-
\begin{vmatrix}now parses toDeterminant: ThevmatrixLaTeX environment now produces["Determinant", ["Matrix", ...]]instead of["Matrix", ..., "'||'"]. Serialization round-trips correctly back to\begin{vmatrix}...\end{vmatrix}when the argument is aMatrixexpression, and uses\det\left(...\right)for symbol arguments. -
\begin{Vmatrix}now parses toNorm: TheVmatrixLaTeX environment now produces["Norm", ["Matrix", ...]]instead of["Matrix", ..., "'‖‖'"]. Serialization round-trips to\begin{Vmatrix}...\end{Vmatrix}when the argument is aMatrix, and uses\left\Vert...\right\Vertfor symbol arguments. -
A^{-1}producesInversefor matrix-typed symbols and matrix expressions: When a symbol is declared with typematrix, parsingA^{-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 defaultPower/Dividehandling, and function symbols still produceInverseFunction(e.g.,\sin^{-1}→Arcsin). -
Inverseserializes as^{-1}:["Inverse", "A"]now serializes toA^{-1}instead of\mathrm{Inverse}(A). -
Power(A, -1)canonicalizes toInverse(A)for matrices: WhenAhas 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: FixedDeterminantandTraceLaTeX dictionary entries to uselatexTrigger(\det,\tr) instead ofsymbolTrigger, which only matches plain identifiers. Both functions also accept plain text forms (det(A),tr(A)). -
\det Aand\tr Awork without parentheses:DeterminantandTracenow accept implicit arguments, so\det Aparses as["Determinant", "A"](like\cos xparses as["Cos", "x"]). Implicit arguments bind at multiplication precedence, so\det 2A + 1parses asdet(2A) + 1. -
Determinantserialization uses\det Afor simple arguments: Symbol arguments serialize as\det Ainstead 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, andHom:Kernelnow computes a numeric null-space basis (for scalar/vector/matrix real inputs) and returns it as a list of basis vectors.Dimensionnow evaluates finite dimensions for concrete tensors and collections, and computesdim(Hom(V, W)) = dim(V) * dim(W)when both dimensions are inferable.Degreenow evaluates polynomial degree for polynomial-form expressions while keeping ambiguous bare symbols (for exampleDegree(p)) unevaluated.Homnow evaluates/simplifies its arguments while preserving the symbolicHom(...)form.
LaTeX Parsing
arguments: 'implicit'option for function dictionary entries: Function entries in the LaTeX dictionary can now setarguments: '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), andSqrt(+∞)now handled correctly. -
Division edge cases:
a/a → 1now works for compound expressions (e.g.,(π+1)/(π+1));2/0 → ComplexInfinityand1/(1/0) → 0propagate correctly. -
Logarithm edge cases: Fixed infinity detection in
simplify-log.ts(was usingsym()which fails onBoxedNumberinfinity values); addedlog_∞(∞) → NaN, base-awarelog_c(0), guards forlog_1(x)andlog_c(c^x)evaluation. -
Absolute value of odd functions:
|arcsin(x)|,|sinh(x)|,|arsinh(x)|,|artanh(x)|now simplify tof(|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) → 0andln(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, andarcsin → arctan2that 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 registryconst result = compile(expr, { to: 'wgsl' });WGSL-specific differences from GLSL:
inverseSqrt(camelCase) instead ofinversesqrt%operator for mod instead ofmod()functionvec2f/vec3f/vec4fconstructors instead ofvec2/vec3/vec4array<f32, n>()instead offloat[n]()fn name(x: f32) -> f32instead offloat name(float x)@vertex/@fragment/@computeentry points with struct-based I/O@group/@bindinguniform declarations and@workgroup_sizefor compute
-
Interval WGSL Compilation Target: New
interval-wgsltarget for interval arithmetic in WebGPU shaders, mirroring the existinginterval-glsltarget. Since WGSL does not support function overloading, the library uses_vsuffixes for internal vec2f-parameter implementations (e.g.,ia_add_v), while the public API (ia_add,ia_sin, etc.) takesIntervalResultvalues.
Resolved Issues
-
Sequencetype inference now returns a proper tuple type: Multi-argumentSequenceexpressions previously returned'any'as their inferred type, losing all type information. They now return atuple<...>type with each element's individual type preserved (e.g.,Sequence(1, "a")types astuple<integer, string>), consistent with theTupleoperator. -
Subscript parsing now checks for collection type: The LaTeX subscript (
_) parser now checks whether the LHS is a collection (symbol declared asindexed_collection, or a list literal) and producesAt()directly at parse time, consistent with bracket indexing (x[i]). Multi-index subscripts on collections (A_{k,j}) are now correctly unpacked into separateAtarguments instead of being wrapped in aTuple. -
NumericValue(0).mul(Infinity)now returns NaN: All threeNumericValuesubclasses (MachineNumericValue,BigNumericValue,ExactNumericValue) had an early-returnif (this.isZero) return thisinmul(), which returned0without checking if the other operand was infinity.0 × ±∞is now correctly indeterminate (NaN), and±∞ × 0is 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), thepow()helper, and simplification (simplifyPower). As a result,(x^2)^{1/2}now correctly simplifies to|x|instead ofx. -
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)^nusedn % 2 === 0to test parity, but for non-integern(e.g. 0.5),0.5 % 2 = 0.5falls to the odd branch, giving(-x)^{0.5} -> -(x^{0.5})which is wrong. All three rules, plus the correspondingcanonicalPower()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})^bto(a^b)^{1/2}, which is wrong for negativea(e.g.(√(-4))^3 = -8ibut√((-4)^3) = 8i). Now only applied whena >= 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 witha >= 0orcis integer. Audit ofsimplify-power.tsconfirmed all rules there are already properly guarded. -
Relational operators now evaluate: Seven relational operators (
TildeFullEqual,TildeEqual,Approx,ApproxEqual,ApproxNotEqual,Precedes,Succeeds) previously hadcanonicalhandlers but noevaluatehandlers, so expressions likeApprox(3.14, 3.14)returned unevaluated. The approximate-equality family (TildeFullEqual,TildeEqual,Approx,ApproxEqual) now checks whether|a - b| <= toleranceviace.chop(), with support for multi-argument chains.PrecedesandSucceedsevaluate as numeric<and>respectively. Negated variants (NotApprox,NotTildeFullEqual, etc.) work automatically through theNotoperator. -
BoxedNumber.operatornow returns specific numeric types: Theoperatorproperty onBoxedNumberinstances 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 thetypeproperty 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 theisNumber()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^^XXescapes (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 perisValidSymbol()and round-trips correctly: the serializer decodes____XXXXXXback 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 liket_halforhalf_lifeare 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 (typicallyNaNorInfinity) for improper integrals. The change-of-variables estimator was inverted (f(x) / \mathrm{jacobian}instead off(x) * \mathrm{jacobian}), and the finite-interval scale factorb - awas applied to transformed domains where it is infinite. AffectsNIntegrateand compiledintegratefor any integral with infinite bounds.
Compilation
-
Truncate,Remainder, andModfor JS/GLSL targets: AddedTruncate(Math.trunc/trunc),Remainder, andModto the JavaScript and GLSL compilation targets, matching the Python target which already had them. -
Interval
truncandremainder: Addedtrunc()andremainder()to the interval arithmetic library.trunchas proper discontinuity detection (behaves likefloorfor positive,ceilfor negative, continuous at zero).remainder(a, b) = a - b * round(a/b)composes existing interval operations with discontinuity detection inherited fromround. Added corresponding mappings to both interval JavaScript and interval GLSL targets. -
Interval
Lb,Log, andRootfor GLSL: Addedia_log2,ia_log10, andRootto 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→Argumentacross all five compilation targets. -
Missing Library Operator Definitions: Added library definitions for
Exp2,Fract,Log10,Log2,Remainder, andTruncatewhich were referenced by compilation targets but had no corresponding library entries.Exp2canonicalizes toPower(2, x),Log10/Log2canonicalize toLogwith the appropriate base, andFract,Remainder,Truncatehave direct numeric evaluation. -
Derivative Rule for GammaLn: Fixed the derivative table entry that used the non-canonical name
LogGammainstead ofGammaLn, preventing the derivatived/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
continuityfield ('left'or'right') indicating from which side the function is continuous at a jump discontinuity.Floor,Round,Fract, andModreport'right'(right-continuous),Ceilreports'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 (newIA_SINGULAR_RIGHTandIA_SINGULAR_LEFTstatus 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,Logwith base,Square,Root,Fract). -
Interval Discontinuity Detection:
Floor,Ceil,Round,Sign,Fract, andModnow 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, andModto 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.02e23was incorrectly converted to the exact bigint602000000000000000000000, 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 theparseNumbers: 'auto'option. -
Scientific Notation Serialization (#284): Fixed
toLatex()withscientificandadaptiveScientificnotation options to produce properly normalized output. Previously, numbers like6.02e23would serialize as602\cdot10^{21}instead of the expected6.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). TheExactNumericValue.sum()method now usesbignumReinstead ofreto preserve full precision when handling large integer values fromBigNumericValue. -
Broadcastable Functions with Union/Any Types (#235): Broadcastable (threadable) functions like
MultiplyandAddno longer reject arguments whose type is a union of numeric and collection types (e.g.,number | list) orany. Previously, declaring a symbol asce.declare('a', 'number | list')and using it ince.box(['Multiply', 'a', 'b'])would produce anincompatible-typeerror. -
Division Canonicalization Over-Simplification (#227): Fixed
A/Abeing incorrectly simplified to1during canonicalization for constant expressions that evaluate to infinity or zero, such astan(π/2)/tan(π/2). This now correctly evaluates toNaN(since∞/∞is indeterminate) instead of1. Expressions with free variables (e.g.,x/x,sin(x)/sin(x)) continue to simplify to1per standard algebraic convention. Also fixed deferred constant divisions like0/(1-1)and(1-1)/(1-1)to properly evaluate toNaNinstead 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 ininterval-js, and by addingIntervalResultoverloads to the GLSL interval library forinterval-glsl.
0.35.0 2026-02-02
Parsing
- Large Integer Precision: Fixed precision loss when parsing integers
exceeding
Number.MAX_SAFE_INTEGERwithparseNumbers: '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 arithmeticinterval-glsl- Compiles to GLSL for GPU-based interval evaluation
0.34.0 2026-02-01
Parsing
-
\mathopenand\mathclose: The LaTeX parser supports\mathopenand\mathclosedelimiter 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 prefixesce.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 compatiblece.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 operationsconst expr = ce.parse('v + w');const compiled = expr.compile({operators: {Add: ['add', 11], // Convert + to add() functionMultiply: ['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
CompileTargetinterface,BaseCompilerclass, andJavaScriptTargetclass.import { BaseCompiler, JavaScriptTarget } from '@cortex-js/compute-engine';// Create a custom compilation targetconst 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, andGLSLTarget(plus helper types likeCompiledOperatorsandCompiledFunctions). -
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 targetclass PythonTarget {// ... implementation (see documentation)}// Register the custom targetce.registerCompilationTarget('python', new PythonTarget());// Compile to Pythonconst 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 targetsconst jsFunc = expr.compile({ to: 'javascript' });const glslCode = expr.compile({ to: 'glsl' });Notes:
- Built-in targets:
javascript(executable) andglsl(shader code) - Add targets via
ce.registerCompilationTarget(name, target) - Switch targets with
compile({ to: ... })(or override once withtarget)
- Built-in targets:
-
Python/NumPy Compilation Target: Added a complete Python/NumPy compilation target for scientific computing workflows. The
PythonTargetclass 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 targetce.registerCompilationTarget('python', python);// Compile expressions to Pythonconst 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 functionsconst 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 functionsimport { 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 shadersconst 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
Factorfunction now supports comprehensive polynomial factoring including perfect square trinomials, difference of squares, and quadratic factoring with rational roots. Addresses #180 and #33.// Perfect square trinomialsce.parse('x^2 + 2x + 1').factor().latex;// → "(x+1)^2"ce.parse('4x^2 + 12x + 9').factor().latex;// → "(2x+3)^2"// Difference of squaresce.parse('x^2 - 4').factor().latex;// → "(x-2)(x+2)"// Quadratic with rational rootsce.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 specificationThe 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)), androot(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 like6·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)·√radicalinto 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).
- Multi-prime coefficients:
-
Improved Cost Function for Negated Powers:
Negate(Power(...))now costs3 + cost(exponent), consistent with the cost ofMultiply(-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 asask(["Greater", "x", "_k"])andask(["Greater", "_x", "_k"]), normalizes inequality patterns for matching (e.g.ask(["Greater", "_x", 0])), and falls back toverify()for closed predicates when the fact is known but not stored as an explicit assumption. -
Tri-state
verify(): Implementedce.verify()as a truth query that returnstrue,falseorundefinedwhen a predicate cannot be determined from the current assumptions and declarations.And/Or/Notuse 3-valued logic. -
Element/NotElementType Membership:Element(x, T)andNotElement(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(); // → Falsece.box('one').type.matches('integer'); // → trueThis also fixes comparison evaluation:
Equal(symbol, assumed_value)now correctly evaluates toTrueinstead 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(); // → Falsece.box('x').isGreater(0); // → truece.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 repeatedce.assume(ce.box(['Equal', 'one', 1]));ce.assume(ce.box(['Equal', 'one', 1])); // → 'tautology'// Conflicting equalityce.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); // 60console.log(result.y.json); // 10// 3x3 systems work tooconst 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 = sare solved by recognizing that x and y are roots of the quadratict² - 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 patternconst 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 methodconst 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 coefficientsconst 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 <= 10const 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 <= 5const 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
nullfor 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 returningnull. Free variables appear as themselves in the solution, with other variables expressed in terms of them.// Single equation with two variablesconst 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 variablesconst 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)) = eusing 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) = ausing 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.675ce.parse('\\sqrt{x + \\sqrt{x}} = 2').solve('x'); // → [9/2 - √17/2] ≈ 2.438ce.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 = 0orx = -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 sequencece.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(); // → 55ce.parse('F_5').evaluate(); // → 5ce.parse('F_n').evaluate(); // → stays symbolic (handler returns undefined)Both simple subscripts (
F_5) and complex subscripts (F_{5}) are supported. When the handler returnsundefined, the expression stays symbolic. Subscripted expressions withsubscriptEvaluatehave typenumberand 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 theAt()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: TheAtfunction 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 sequencece.declareSequence('F', {base: { 0: 0, 1: 1 },recurrence: 'F_{n-1} + F_{n-2}',});ce.parse('F_{10}').evaluate(); // → 55ce.parse('F_{20}').evaluate(); // → 6765// Arithmetic sequence: a_n = a_{n-1} + 2, a_0 = 1ce.declareSequence('A', {base: { 0: 1 },recurrence: 'A_{n-1} + 2',});ce.parse('A_{5}').evaluate(); // → 11// Factorial via recurrencece.declareSequence('H', {base: { 0: 1 },recurrence: 'n \\cdot H_{n-1}',});ce.parse('H_{5}').evaluate(); // → 120Features:
- 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_kremains unevaluated)
Alternatively, sequences can be defined using natural LaTeX assignment notation:
// Arithmetic sequence via LaTeXce.parse('L_0 := 1').evaluate();ce.parse('L_n := L_{n-1} + 2').evaluate();ce.parse('L_{5}').evaluate(); // → 11// Fibonacci via LaTeXce.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(); // → 55Base 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 informationce.getSequence('F');// → { name: 'F', variable: 'n', baseIndices: [0, 1], memoize: true, cacheSize: 5 }// List all defined sequencesce.listSequences(); // → ['F', 'A', 'H']// Check if a symbol is a sequencece.isSequence('F'); // → truece.isSequence('x'); // → false// Manage memoization cachece.getSequenceCache('F'); // → Map { 2 => 1, 3 => 2, ... }ce.clearSequenceCache('F'); // Clear cache for specific sequencece.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:
SumandProductnow 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(); // → 143ce.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 termsconst 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 sequencece.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 casesrecurrence: '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(); // → 10ce.parse('P_{10,5}').evaluate(); // → 252Features:
- 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
isMultiIndexflag
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
- Multiple index variables with
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 functionB(a,b) = \Gamma(a)\Gamma(b)/\Gamma(a+b)LambertW- Lambert W function (product logarithm)BesselJ,BesselY,BesselI,BesselK- Bessel functions of first/second kindAiryAi,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(); // → 0ce.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^xce.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^xce.parse('\\int e^x \\cos(2x) dx').evaluate();// → 1/5·cos(2x)·e^x + 2/5·sin(2x)·e^xThese 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), returningundefinedwhen 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')); // → truece.parse('3x+1=0').isEqual(ce.parse('6x+2=0')); // → trueUses 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) → AandA ∨ (A ∧ B) → A - Idempotence:
A ∧ A → AandA ∨ A → A - Complementation:
A ∧ ¬A → FalseandA ∨ ¬A → True - Identity:
A ∧ True → AandA ∨ False → A - Domination:
A ∧ False → FalseandA ∨ True → True - Double negation:
¬¬A → A
These rules are applied automatically during simplification:
ce.box(['And', 'A', ['Or', 'A', 'B']]).simplify(); // → Ace.box(['Or', 'A', ['And', 'A', 'B']]).simplify(); // → A - Absorption:
-
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 coverMinimalCNF(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 DNFce.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 pivotingQRDecomposition(A)→[Q, R]- QR factorization using Householder reflectionsCholeskyDecomposition(A)→L- Cholesky factorization for positive definite matricesSVD(A)→[U, Σ, V]- Singular Value Decomposition
ce.box(['LUDecomposition', [[4, 3], [6, 3]]]).evaluate();// → [P, L, U] where PA = LUce.box(['QRDecomposition', [[1, 2], [3, 4]]]).evaluate();// → [Q, R] where A = QR, Q orthogonal, R upper triangularce.box(['CholeskyDecomposition', [[4, 2], [2, 2]]]).evaluate();// → L where A = LL^Tce.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(); // → 5ce.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(); // → 10ce.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)
- Safer division canonicalization for denominators that may simplify to
0.33.0 2026-01-30
Resolved Issues
Arithmetic and Infinity
-
Division by Zero: Improved handling of division by zero:
0/0returnsNaN(indeterminate form)a/0wherea ≠ 0returnsComplexInfinity(~∞) 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 returning1. Now correctly returnsNaN(indeterminate form). Thea/a → 1simplification rule now excludes infinity values.
Trigonometry
-
Trigonometric Period Identities: Fixed incorrect sign handling for
csc(π+x)andcot(π+x):csc(π+x)now correctly simplifies to-csc(x)(was incorrectlycsc(x))cot(π+x)now correctly simplifies tocot(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 tosin(2x). The product-to-sum identity now handles coefficients:2sin(x)cos(x)→sin(2x)c·sin(x)cos(x)→c·sin(2x)/2for any coefficientc
-
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)andtan(x)*cot(x), ensuring these are simplified tosin(2x)/2and1respectively.
Logarithms and Exponentials
-
Logarithm-Exponential Composition: Fixed
log(exp(x))incorrectly simplifying tox. Now correctly returnsx/ln(10)≈0.434xsincelog₁₀(eˣ) = x·log₁₀(e) = x/ln(10). The identitylog(exp(x)) = xonly holds for natural logarithm. -
Logarithm of e: Added simplification for
log(e)→1/ln(10)≈0.434andlog_c(e)→1/ln(c)for any basec. -
Logarithm Combination Base Preservation: Fixed
log(x) + log(y)(base 10) incorrectly becomingln(xy). Now correctly produceslog(xy)preserving the original base. -
Logarithm Quotient Rule: Added expansion rule for logarithm of quotients.
ln(x/y)now simplifies toln(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. Nowe^log(x)→x^{1/ln(10)}and more generallye^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. Now0^x→0whenxis known to be positive (includingπ,e, etc.). -
Exponent Evaluation in Products: Fixed
(x³)² · (y²)²not simplifying tox⁶y⁴. Numeric subexpressions in exponents (like2×3inx^{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 toy⁴/x⁶during canonicalization by distributing the negative exponent. -
Negative Base with Fractional Exponent: Fixed
(-ax)^{p/q}returning complex results whenpandqare 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}·xwhich 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 · √xfactoring (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. Nowx * √2stays as√2 · xinstead of1.414... · x, andx * 2^{1/3}stays asx · ∛2instead of1.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 ase^x · e^2instead of producing a parse error. The expression then simplifies toe^{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() methodconst result = expr.trigSimplify();Examples:
sin(x)⁴ - cos(x)⁴→-cos(2x)tan(x)·cot(x)→1sin²(x) + cos²(x)→12sin(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)whenA + B + C = πandk = 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)→11 - sin²(x)→cos²(x)and1 - cos²(x)→sin²(x)sin²(x) - 1→-cos²(x)andcos²(x) - 1→-sin²(x)tan²(x) + 1→sec²(x)andsec²(x) - 1→tan²(x)1 + cot²(x)→csc²(x)andcsc²(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)andx = π - arcsin(a)(two solutions)cos(x) = a→x = arccos(a)andx = -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 solution0)
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 newtimeDerivativeVariableparser 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 forf''(x),f'''(x), etc. for higher derivatives. -
Euler's subscript notation:
D_x f→["D", "f", "x"]andD^2_x forD_x^2 ffor second derivatives. -
Derivative serialization:
Dexpressions 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 GammaTrigamma(x)- The trigamma function ψ₁(x), derivative of digammaPolyGamma(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)andeˣ / 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^nwhen n is even (e.g.,(-x)^4→x^4)(-x)^n→-x^nwhen 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}→-1when both p and q are odd (real odd root)
-
Power Distribution: Added rule to distribute integer exponents over products:
(ab)^n→a^n · b^nwhen 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
\innotation in summation and product subscripts:-
Parsing:
\sum_{n \in \{1,2,3\}} nnow 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
\innotation:\sum_{n\in \{1, 2, 3\}}n -
Range support: Works with
Rangeexpressions viace.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:
Intervalexpressions work with Element-based indexing, including support forOpenandClosedboundary 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} nwith unknownS→ 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]} nwith symbolic bound → stays symbolic- Previously these would all return
NaNwith 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 jevaluate 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
MatrixMultiplyfunction 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-dimensionserrors - LaTeX serialization using
\cdotnotation
- Matrix × Matrix:
-
Matrix Addition and Scalar Broadcasting:
Addnow 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-dimensionserrors
-
Matrix Construction Functions: Added convenience functions for creating common matrices:
IdentityMatrix(n): Creates an n×n identity matrixZeroMatrix(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
Normfunction 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 eliminationEigen(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])
- Vector → Matrix: Creates a diagonal matrix from a vector
(
-
Higher-Rank Tensor Operations: Extended
Transpose,ConjugateTranspose, andTraceto 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]
- Transpose: Swaps last two axes by default (batch transpose), or specify
explicit axes with
-
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 returnexpected-square-matrixerror 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 + 1againstx + y + znow rejects immediately (arity mismatch: 4 vs 3) instead of trying 24 permutations
- Arity Guard: Patterns without sequence wildcards (
Resolved Issues
Arithmetic
-
Indeterminate Form Handling: Fixed incorrect results for mathematical indeterminate forms:
0 * ∞now correctly returnsNaN(previously returned∞)∞ / ∞now correctly returnsNaN(previously returned1)∞^0now correctly returnsNaN(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}, andx^2 * xwould not simplify. Now correctly simplifies to2^(x+1),e, andx^3respectively. 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 toNaNinstead of staying symbolic. The factorialevaluatefunction was attempting numeric computation on symbolic arguments. Now correctly returnsundefined(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 expectedmatrixtype (a 2D list with dimensions), butBoxedTensor.typereturnedlist<number>without dimensions. NowBoxedTensor,BoxedFunction, andBoxedSymbolcorrectly deriveshapeandrankfrom 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) dxreturningNaNwhen evaluated numerically with.N(). The integrand was already wrapped in aFunctionexpression 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 aFunctionbefore 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 aTupleinstead of a function call becauseSubscriptexpressions weren't being canonicalized before the function call check. Now correctly recognizes thatf_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 (likeFactorial) when applied to expressions with lower precedence. Previously,Negate(Add(a, b))incorrectly serialized as-a+binstead 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 ofa+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 like3=4\vee 7=8were 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:
\rightarrownow parses asImplies(previously parsed asTofor set/function mapping)\leftrightarrownow parses asEquivalent(previously produced an "unexpected-command" error)- Long arrow variants now supported:
\Longrightarrow,\longrightarrow→Implies;\Longleftrightarrow,\longleftrightarrow→Equivalent - The existing variants
\Rightarrow,\Leftrightarrow,\implies,\iffcontinue to work \toremains 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 addxas a symbol with function type to the global scope. Later, when the simplification rules cache was built, rule parsing would fail because wildcards like_xin rules would be type-checked against the polluted scope wherexhad 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 + xnow correctly simplifies to2x(term combination)e^x * e^{-x}now correctly simplifies to1(exponential inverse)sin(∞)andcos(∞)now correctly evaluate toNaNtanh(∞)now correctly evaluates to1,tanh(-∞)to-1log_b(x^n)now correctly simplifies ton * log_b(x)(log power rule)- Improved cost function to prefer
n * ln(x)form overln(x^n) - Trigonometric functions now reduce arguments by their period (e.g.,
cos(5π + k)simplifies usingcos(π + 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 returnx+xinstead of2x. The bug was in the simplification loop detection: when canonicalizing before simplification, the non-canonical form was recorded in the "seen" set, and sinceisSame()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
Doperator not differentiating expressions containing theRootoperator (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 returns1/(3x^(2/3)), equivalent to the expected(1/3)·x^(-2/3). The fix adds a special case in thedifferentiatefunction to handleRoot(base, n)by applying the power rule with exponent1/n. -
Abs Derivative: Fixed
d/dx |x|returning an error when evaluated with a variable that has an assigned value. The derivative formula now usesSign(x)instead of a complexWhichexpression that couldn't be evaluated symbolically. -
Step Function Derivatives: Fixed
D(floor(x), x),D(ceil(x), x), andD(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, andarctan. 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 theerfderivative). -
LogGamma Derivative: Added derivative rule for
LogGamma(x)which returnsDigamma(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)
- Fixed
-
Symbolic Derivative Evaluation: Fixed derivatives of unknown functions returning
0instead of symbolic derivatives. For example,D(Digamma(x), x)now correctly returnsDigamma'(x)(asApply(Derivative(Digamma, 1), x)) instead of incorrectly returning0.
LaTeX Parsing and Serialization
-
(#256) Subscript Symbol Parsing: Fixed parsing of single-letter symbols with subscripts. Previously,
i_Awas incorrectly parsed as["Subscript", ["Complex", 0, 1], "A"]becauseiwas recognized as the imaginary unit before the subscript was processed. Nowi_Acorrectly parses as the symboli_A. This applies to all single-letter symbols including constants likeeandi. Complex subscripts containing operators (n+1), commas (n,m), or parentheses ((n+1)) still produceSubscriptexpressions. -
LaTeX Serialization: Fixed TypeScript error in power serialization where
denom(anumber | null) was incorrectly passed where anExpressionwas expected. Now correctly usesoperand(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 = 2to be parsed incorrectly asx = (1 ∨ x) = 2instead 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 xinstead of\forall x, x>y). The body of the quantified expression is now correctly serialized. -
(#257) LaTeX Parsing: Fixed
\gcdcommand 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\lcmas 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,
1000now serializes to1\cdot10^{3}(or1\times10^{3}depending onexponentProduct) instead of10^{3}. -
LaTeX Parsing: Fixed
\coshincorrectly mapping toCschinstead ofCosh. -
(#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 aSubscriptexpression whereCDrepresents implicit multiplication. TheDelimiterwrapper 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 returnboolean, enabling correct type checking when quantified expressions are used as arguments to logical operators.
Simplification
- Sign Simplification: Fixed
Sign(x).simplify()returning1instead of-1whenxis negative. The simplification rule incorrectly returnedce.Onefor both positive and negative cases.
Type System
- Ceil Type Signature: Fixed
Ceilfunction signature from(real) -> integerto(number) -> integerto matchFloor. This resolves "incompatible-type" errors when computing derivatives of ceiling expressions or usingCeilin contexts expecting a general number type.
Polynomials
- Polynomial Degree Detection: Fixed
polynomialDegree()returning 0 for expressions likee^xore^(-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 oferf(x)which is(2/√π)·e^(-x²).
Pattern Matching
- (#258) Pattern
Matching: Fixed
BoxedExpression.match()returningnullwhen matching patterns against canonicalized expressions. Several cases are now handled:Rationalpatterns now match expressions like['Rational', 'x', 2]which are canonicalized to['Multiply', ['Rational', 1, 2], 'x']Powerpatterns now match['Power', 'x', -1]which is canonicalized to['Divide', 1, 'x'], returning{_base: x, _exp: -1}Powerpatterns now match['Root', 'x', 3](cube root), returning{_base: x, _exp: ['Divide', 1, 3]}
Sum and Product
- (#252)
Sum/Product: Fixed
SumandProductreturningNaNwhen the body contains free variables (variables not bound by the index). For example,\sum_{n=1}^{10}(x)now correctly evaluates to10xinstead ofNaN, and\prod_{n=1}^{5}(x)evaluates tox^5. Mixed expressions like\sum_{n=1}^{10}(n \cdot x)now return55x. Also fixedtoString()forSumandProductexpressions with non-trivial bodies (e.g.,Multiply) which were incorrectly displayed asint().
Equation Solving
-
(#242) Solve: Fixed
solve()returning an empty array for equations with variables in fractions. For example,F = 3g/hsolved forgnow correctly returnsFh/3instead of an empty array. The solver now clears denominators before applying solve rules, enabling it to handle expressions likea + bx/c = 0. Also added support for solving equations where the variable is in the denominator (e.g.,a/x = bnow returnsx = 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 formax + b√x + c = 0using quadratic substitution. Also added support for solving logarithmic equations likea·ln(x) + b = 0which returnsx = 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
quantifierScopeparsing 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' }) // defaultce.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:Supportsce.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)Set,List,Range, and integerIntervaldomains 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
ToCNFandToDNFfunctions convert boolean expressions to Conjunctive Normal Form and Disjunctive Normal Form respectively:Handlesce.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)And,Or,Not,Implies,Equivalent,Xor,Nand, andNoroperators using De Morgan's laws and distribution. - Boolean operator evaluation: Added evaluation support for
Xor,Nand, andNoroperators withTrue/Falsearguments:ce.box(['Xor', 'True', 'False']).evaluate() // Returns Truece.box(['Nand', 'True', 'True']).evaluate() // Returns Falsece.box(['Nor', 'False', 'False']).evaluate() // Returns True - N-ary boolean operators:
Xor,Nand, andNornow support any number of arguments:Xor(a, b, c, ...)returns true when an odd number of arguments are trueNand(a, b, c, ...)returns the negation ofAnd(a, b, c, ...)Nor(a, b, c, ...)returns the negation ofOr(a, b, c, ...)
- Satisfiability checking: New
IsSatisfiablefunction checks if a boolean expression can be made true with some assignment of variables:ce.box(['IsSatisfiable', ['And', 'A', ['Not', 'A']]]).evaluate() // Falsece.box(['IsSatisfiable', ['Or', 'A', 'B']]).evaluate() // True - Tautology checking: New
IsTautologyfunction checks if a boolean expression is true for all possible variable assignments:ce.box(['IsTautology', ['Or', 'A', ['Not', 'A']]]).evaluate() // Truece.box(['IsTautology', ['And', 'A', 'B']]).evaluate() // False - Truth table generation: New
TruthTablefunction generates a complete truth table for a boolean expression:ce.box(['TruthTable', ['And', 'A', 'B']]).evaluate()// Returns [["A","B","Result"],["False","False","False"],...] - Explicit
Predicatefunction: Added a newPredicatefunction 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.Outside quantifier scopes,ce.parse('\\forall x. P(x)').json// Returns ["ForAll", "x", ["Predicate", "P", "x"]]P(x)is still parsed as["P", "x"]to maintain backward compatibility with function definitions likeQ(x) := .... D(f, x)no longer maps to derivative: The LaTeX notationD(f, x)is not standard mathematical notation for derivatives and previously caused confusion with theDderivative function in MathJSON. NowD(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. NowN(x)parses as["Predicate", "N", "x"]instead of the numeric evaluation function. This allowsNto 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].
- Configurable quantifier scope: New
Polynomials
- Polynomial Simplification: The
simplify()function now automatically cancels common polynomial factors in univariate rational expressions. For example,(x² - 1)/(x - 1)simplifies tox + 1,(x³ - x)/(x² - 1)simplifies tox, and(x + 1)/(x² + 3x + 2)simplifies to1/(x + 2). Previously, this required explicitly calling theCancelfunction with a variable argument.
Sum and Product
- Sum/Product Simplification: Added simplification rules for
SumandProductexpressions with symbolic bounds:- Constant body:
\sum_{n=1}^{b}(x)simplifies tob * 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 tob(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 to2^n - Alternating binomial sum:
\sum_{k=0}^{n}((-1)^k * C(n,k))simplifies to0 - Weighted binomial sum:
\sum_{k=0}^{n}(k * C(n,k))simplifies ton * 2^(n-1) - Partial fractions (telescoping):
\sum_{k=1}^{n}(1/(k(k+1)))simplifies ton/(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 ton(n+1) * 2^(n-2) - Weighted cubed binomial sum:
\sum_{k=0}^{n}(k^3 * C(n,k))simplifies ton²(n+3) * 2^(n-3) - Alternating weighted binomial sum:
\sum_{k=0}^{n}((-1)^k * k * C(n,k))simplifies to0(n ≥ 2) - Sum of binomial squares:
\sum_{k=0}^{n}(C(n,k)^2)simplifies toC(2n, n) - Sum of consecutive products:
\sum_{k=1}^{n}(k(k+1))simplifies ton(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 tox^b - Factorial:
\prod_{n=1}^{b}(n)simplifies tob! - 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 to2^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 tox!/(x-n)! - Telescoping product:
\prod_{k=1}^{n}((k+1)/k)simplifies ton+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 toc \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
- Constant body:
0.31.0 2026-01-27
Breaking Changes
- The
[Length]function has been renamed to[Count]. - The
xsizeproperty of collections has been renamed tocount. - The
xcontains()method of collections has been renamed tocontains(). - 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 (\arsinhand\arcsinh) are accepted as input (Postel's law).
Resolved Issues
LaTeX Parsing
-
Metadata Preservation: Fixed
verbatimLatexnot being preserved when parsing withpreserveLatex: 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{...}withpreserveLatex: truewhich was incorrectly returning an "invalid-symbol" error instead of a string expression.
Calculus
-
Derivatives:
d/dx e^xnow correctly simplifies toe^xinstead ofln(e) * e^x. ThehasSymbolicTranscendental()function now recognizes that transcendentals which simplify to exact rational values (likeln(e) = 1) should not be preserved symbolically. -
Derivatives:
d/dx log(x)now returns1 / (x * ln(10))symbolically instead of evaluating to0.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. Previously1/-2would not canonicalize to-1/2. -
Arithmetic: Fixed
.mul()to preserve logarithms symbolically. Previously multiplying expressions containingLnorLogwould evaluate the logarithm to its numeric value.
Serialization
-
Serialization: Fixed case inconsistency in
toString()output for trigonometric functions. Some functions likeCotwere being serialized with capital letters while others likecscwere lowercase. All trig functions now consistently serialize in lowercase (e.g.,cot(x)instead ofCot(x)). -
Serialization: Improved display of inverse trig derivatives and similar expressions:
- Negative exponents like
x^(-1/2)now display as1/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 + 1displays as1 - x^2) while preserving polynomial ordering (e.g.,x^2 - x + 3stays unchanged) d/dx arcsin(x)now displays as1/sqrt(1-x^2)instead of(-x^2+1)^(-1/2)
- Negative exponents like
-
Scientific Notation: Fixed normalization of scientific notation for fractional values (e.g., numbers less than 1).
Sum and Product
-
Compilation: Fixed compilation of
SumandProductexpressions. -
Sum/Product: Fixed
sumandprodlibrary functions to correctly handle substitution of index variables.
New Features and Improvements
Serialization
- Number Serialization: Added
adaptiveScientificnotation mode. When serializing numbers to LaTeX, this mode uses scientific notation but avoids exponents within a configurable range (controlled byavoidExponentsInRange). 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 polynomialCoefficientList(expr, var)- Get the list of coefficientsPolynomialQuotient(dividend, divisor, var)- Polynomial division quotientPolynomialRemainder(dividend, divisor, var)- Polynomial division remainderPolynomialGCD(a, b, var)- Greatest common divisor of polynomialsCancel(expr, var)- Cancel common factors in rational expressions
Calculus
- Integration: Significantly expanded symbolic integration capabilities:
- Polynomial division: Integrals like
∫ x²/(x²+1) dxnow correctly divide first, yieldingx - 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) dxnow yieldarctan(x+1) - Reduction formulas:
∫ 1/(x²+1)² dxnow works using reduction formulas - Mixed partial fractions:
∫ 1/((x-1)(x²+1)) dxnow decomposes correctly - Factor cancellation:
∫ (x+1)/(x²+3x+2) dxsimplifies 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²) dxusing trig/hyperbolic substitution
- Polynomial division: Integrals like