Compute Engine Changelog
Coming Soon
Breaking Changes
-
A negative
Take/Dropcount counts from the end (#414, reported by enumeratio).Take(xs, -n)is the lastnelements ofxs, andDrop(xs, -n)isxswithout its lastnelements:Take([1, 2, 3, 4, 5], -2)is[4, 5](it was[]) andDrop([1, 2, 3, 4, 5], -2)is[1, 2, 3](it was the whole list). A string gives a string:Take("hello", -2)is"lo"andDrop("hello", -2)is"hel". A count past the length is still clamped, now in both directions: over 5 elements,Take(xs, 10)andTake(xs, -10)are all 5 elements, andDrop(xs, 10)andDrop(xs, -10)are[]. A count of 0 is unchanged. A negative count needs the length of the collection: when that length is not known or is infinite (Take(Range(1, ∞), -2), a symbol with no value), the expression stays unevaluated. Compiled JavaScript and Python give the same results. In compiled JavaScript, a negative count over an infinite collection is a compile error when the count is a constant, and throws when the count is known only at run time. -
A collection count can be a
bigint(#416, reported by enumeratio). The type ofexpr.countand of thecountcollection handler is nownumber | bigint | undefined. Abigintis used only for a finite count that is not a safe integer (larger thanNumber.MAX_SAFE_INTEGER); a smaller count is always anumber, and an infinite count isInfinity. A handler can return abigintfor any count:expr.countconverts a safe one to anumber. Code that does arithmetic withexpr.countmust check its type first, because JavaScript throws aTypeErrorwhen abigintand anumberare mixed.Count(QuotientRing(Integers, 2^61 - 1)),Lengthand|\mathbb{Z}_{2^{61}-1}|now give the exact integer 2305843009213693951; they stayed unevaluated. Other counts are exact where they were rounded floats,Infinityorundefined:Rangewith exact integer bounds:Count(Range(1, 10^20 + 1))is 100000000000000000001 (it was1e20).CartesianProduct,Permutations,CombinationsandPowerSet:Count(Permutations(Range(1, 200)))is 200! (it was+∞), andPowerSetof a set with 1100 elements has the count 2^1100 and is finite (it was+∞and infinite). A count that needs more than 10,000 multiplications to compute stays unknown; a finite collection is never reported as infinite.Repeat(x, n)with annthat is not a safe integer.- The views of such a collection:
Take,Drop,Rest,Most,Zip,Insert,DeleteAt,ReplaceAtandWhen.
The count of a
CartesianProductor aPowerSetwith an operand of unknown size isundefined; it wasNaN. A ring ℤ/nℤ with more than 2^53 elements is not enumerable:Sum,Maxand the other operators that walk a collection to its end stay unevaluated. Indexing from the end of a collection whose count is abigintis not supported yet:Last(Range(1, 10^20))stays unevaluated (it was1e20), andTake(Range(1, 10^20), -2)stays unevaluated (it was the wrong[1e20, 1e20]).
Behavior Changes
-
simplify()combines same-base powers in a product as it does in a quotient (#415, reported by enumeratio). For a symbolxwith no assumptions,x^a / x^bsimplified tox^(a−b), butx^a · x^bwas left unchanged, because the product rule required a base known to be non-zero. The engine already readsx/xas1for a symbolx, so products now follow the same convention:x^a · x^bisx^(a+b),x^a · x^(−a)is1,x · x^aisx^(a+1)andx^a · x · x^bisx^(a+b+1).evaluate()does not change: it still leavesx^a · x^bas it is. A literal zero base is not affected:0^1 · 0^(−1)is stillIndeterminate. -
The branch of
LambertWmust be an integer, and it can be named (#418, reported by enumeratio). The branch is the second argument,LambertW(z, k), as in mpmath, SciPy, SymPy and Julia. Mathematica (ProductLog[k, z];LambertW[k, z]in Wolfram|Alpha), Maple, Sage and MATLAB put the branch first, so input copied from them has the two arguments swapped. The signature is now(z: complex | infinity, branch: integer?) -> number(it was(complex | infinity, number?) -> number):- A branch that is not an integer is a type error.
LambertW(-1, -0.1)(the Wolfram order) was left unevaluated; it now has anincompatible-typeerror on-0.1. A swap of two integers cannot be found:LambertW(1, 2)is W₂(1) andLambertW(2, 1)is W₁(2). - The branch can be given by name, in any position:
lambertW(-0.1, branch: -1)andlambertW(branch: -1, z: -0.1)in Epsil, or["LambertW", -0.1, ["NamedArgument", "'branch'", -1]]in MathJSON. All give W₋₁(−0.1) ≈ −3.5772.
The
LambertWreference documentation now gives the argument order, the real domain of each real branch, and the named form. - A branch that is not an integer is a type error.
New Features
- The tie rule of
Roundis an engine setting (#417, requested by enumeratio).ce.roundingTiesselects howRoundrounds a value exactly halfway between two integers:"away-from-zero"(the default, unchanged:Round(2.5)is3andRound(-2.5)is-3),"to-even"(IEEE 754, Python, NumPy and Mathematica:Round(2.5)is2andRound(3.5)is4),"toward-zero","toward-positive-infinity"(JavaScriptMath.round:Round(-2.5)is-2) and"toward-negative-infinity". The rule applies at every precision, to an exact rational (Round(5/2)), to an exact constant that is at a tie under.N(), to the sign ofRound, and to the formRound(x, n), which rounds tondecimal places (with"to-even",Round(0.125, 2)is3/25). An unknown rule is an error. Compiled JavaScript, interval JavaScript, GLSL, WGSL and Python use the rule in effect at compile time; a function compiled before a change keeps its rule, and an automatically compiledMapis compiled again.Remainderdoes not use the rule: its quotient is still rounded with a tie toward+∞.
Issues Resolved
-
Roundof a large odd integer in compiled Python and shader code. Python computedsign(x)·floor(|x| + 0.5), and|x| + 0.5is rounded when|x| ≥ 2⁵²:Round(2⁵² + 1)was2⁵² + 2. GLSL and WGSL had the same error inf32from2²³(Round(8388609)was8388610). The code now compares the exact distance to the floor of|x|with0.5. -
Remainderat a tie in compiled Python, GLSL, WGSL and interval JavaScript. The interpreter rounds the quotient with a tie toward+∞, as JavaScriptMath.rounddoes:Remainder(5, 2)is5 − 2·3 = −1. Python and the shaders rounded the quotient withnp.roundorround(), which round a tie to even, and gave1. The interval target rounded a tie away from zero, and gave1forRemainder(−5, 2). All of them now give−1. -
TakeandDropwith a symbolic count no longer give a wrong answer toAnyorAll.Any(Take([1, 2, 3], n), x > 0)wasFalseandAll(Drop([1, 2, 3], n), x < 0)wasTrue. Both now stay unevaluated. -
TakeandDropwith a count past the safe integers or an infinite count clamp to the length.Take([1, 2, 3], 10^20),Take([1, 2, 3], 1e20)andTake([1, 2, 3], ∞)stayed unevaluated, while compiled JavaScript gave[1, 2, 3]and compiled Python raised anOverflowError. All three now give[1, 2, 3]. -
Collection views no longer give a wrong answer to
AnyorAllwhen their walk cannot produce the elements.Reverse,RotateLeftandRotateRightof a collection with more than 2^53 elements, andInsert,DeleteAt,ReplaceAtandSlicewithout a usable position, reported that they could be walked, soAnygaveFalseandAllgaveTrue. These now stay unevaluated.Repeat(x, n)with a negativenpast the safe integers is empty in every respect:Element(x, …)wasTrue. -
A
Rangewith exact bounds and step has the exact number of elements. The count of aRangewas computed in floats with a small tolerance, so aRangewhose exact upper bound is just below a step gave one element too many, above its upper bound:Range(0, 9999999999999/10^12)gave the elements 0 to 10, andRange(1, 10^20, 1/2)counted 200000000000000000000 elements (the count is 199999999999999999999). ARangewith a bound past the largest double, such asRange(1, 10^400), was infinite:Lengthwas+∞. With exact bounds and step, the count, the elements,Length,Element,Lastand the reducers now all use the exact count. With a float bound or step, the count is computed in floats as before. -
MaxandMinof aRangeare exact.Min(Range(10^20 + 1, 1, -1))was0,Max(Range(1, 10^20 + 1))was the rounded float1e20, andMin(Range(1/3, 1, 1/3))was0.333…. They are now1,100000000000000000001and1/3: the first or last element, read exactly. -
Sum,Mean,Median,VarianceandPopulationVarianceof aRangeno longer go through every element. With exact integer or rational bounds and step, they use the closed form of an arithmetic sequence:Sum(Range(1, 10^6))took 3 s and now takes less than 1 ms, andSum(Range(1, 10^20))did not finish and is now the exact5000000000000000000050000000000000000000.evaluate()gives the exact value, and.N()gives a float for a result that is not an integer. -
Powers of a matrix are no longer combined as if they were numbers (#415). The product of two matrices is the matrix product, but
Expof a matrix and a non-integer power of a matrix are element-wise, so the same-base rules gave wrong answers for matrices:simplify()turnedExp(A)·Exp(B)intoExp(A+B)(also for literal matrices),A^a / A^bintoA^(a−b), and√A·√AandA^(1/3)·A^(2/3)intoA, and(e^A)^2wase^(2A). These now stay as they are, except where a matrix power applies:√A·√AisMatrixPower(√A, 2),(e^A)^2isMatrixPower(e^A, 2), andA·AisMatrixPower(A, 2). -
A definite integral with an irrational bound keeps its exact value.
∫_2^{√5} 4 dxwas0.944…; it is now4√5 − 8. The same applied toEvaluateAt(f, a, b)wheneverf(b)andf(a)are two different exact numbers, as√3and√2.EvaluateAt(…).N()still gives a float. -
The sum of two lists or matrices keeps exact elements exact.
[√2, 2] + [1, 1]was[2.414…, 3]; it is now[1 + √2, 3], and[√2, 2] − [1, 1]is[−1 + √2, 1]. A list plus a number was already exact..N()still gives floats. -
Determinants of matrices with irrational or symbolic entries.
- The determinant of
[[√2, 1], [1, 1]]was0.414…; it is now√2 − 1. The same applied to every determinant whose computation subtracts two different exact numbers. - From 4×4, a matrix with a symbol or an exact irrational entry has an
expanded determinant. The determinant of a 4×4 matrix of 16 symbols was
a quotient of 1,900 characters with terms in
a⁴; it is now the sum of its 24 terms. This applies up to 6×6 for a matrix with symbols, and up to 10×10 for a matrix of numbers. A singular 4×4 matrix with radical entries has determinant0, where it was a nonzero float. Determinant(m).N()of a 4×4 or larger matrix chooses the largest pivot of each column. A pivot that is zero in exact arithmetic but about1e-16after rounding gave2048for a matrix whose determinant is−155.19.CharacteristicPolynomial([[a, b], [c, d]], x)stayedDeterminant([[x − a, −b], [−c, x − d]]); it is nowx² − (a + d)x + ad − bc.
- The determinant of
-
PolyLogat a large order past |z| = 1 (#412, reported by enumeratio). For an integer order above 170 and |z| > 1 the value was wrong:PolyLog(250, 4).N()was−0.25andPolyLog(250, −4).N()was0.25, where the values are4and−4to double precision. At a larger order the evaluation was slow or did not end:PolyLog(1000, 4)stayed unevaluated after 0.2 s,PolyLog(5000, 4)ran for minutes andPolyLog(2500000, 4)did not return. Now, when the first terms of the power series give the value (the error of the partial sum has a proven bound off the branch cut), they are used: these points answer at once, also for a non-integer order (PolyLog(2500000.5, 4)stayed unevaluated after 2 s). The compiledPolyLoggives the same values:PolyLog(200, −4)was0.25and is now−4. A non-integer order between about 70 and 200 past |z| = 1 was also inaccurate:PolyLog(170.5, −4)was off by 0.7%, andPolyLog(200.5, 1e40)was about−1e−40·i. The first is now correct, and the second stays unevaluated, as do the other points where no method gives the value, such asPolyLog(250, 1e100). For an integer order from 8 to 170 past |z| = 1 the series is more accurate than the inversion formula used before: on the measured points where the series answers, the largest relative error went from 5e−14 to 7e−16.Inside the unit disk, an integer order of 8 or more now uses the power series.
PolyLog(1e9, 0.99)took seconds and now answers at once, and the values are correct to the last digit (PolyLog(2500000, −0.9)was−0.9000000000000012withprecision: 'machine'). Above machine precision,PolyLog(2500000, 0.3)took 1.6 s andPolyLog(1e7, 0.3)2.3 s; they now answer at once. -
LerchPhiandPolyLogat a very large |z| or a large order. The continuation of the Lerch transcendent past |z| = 1 returned wrong values that passed its own error check:LerchPhi(1e100, 20.25, 1).N()was−0.170, where the value is−1.29e−71, soPolyLog(20.25, 1e100).N()was−1.7e99instead of−1.29e29. At orders from 80 to 150 and |z| from 1e8 to 1e40, values were off by up to 2.5e−10. The tail integral did not follow the oscillation of the integrand, and several factors underflowed. Now the values are correct within 1e−11, or the expression stays unevaluated: on 12,000 points checked against an independent 25- to 100-digit computation, no value is off by more than 8.8e−13 (before, 1,209 were off by more than 1e−11). For an order with a positive real part, the first terms of the series with a proven bound of the rest now give the value at once where they suffice.
0.148.0 2026-10-05
Behavior Changes
-
|S|of a set or a string is its number of elements.|\{1,2\}|wasAbs(Set(1, 2)), anincompatible-typeerror. It is nowCount(Set(1, 2)), which is2, on the LaTeX, MathJSON andce.functionroutes:Abs(Set(1, 2))isCount(Set(1, 2)).|\mathbb{Z}/5\mathbb{Z}|is5,|\emptyset|is0,|\mathbb{Z}|isPositiveInfinity, and|\text{abc}|is3. For a set with no value,|S|isCount(S). A list is unchanged:|[1, -2]|is the element-wise absolute value[1, 2]. A dictionary still gives the type error.Countof a set or a string is written back as|S|.\#S,\operatorname{card}(S)and\operatorname{Card}(S)read asCount(S);\#alone, or followed by an operator (\#+1), is still the symbolhash. When the user definesAbs,|S|keeps that definition andCountis written\mathrm{Count}(S). CompiledAbsof an operand whose type could be a string or a set throws aTypeErrorfor such a value, where it gaveNaN. -
A dictionary has a LaTeX form. A dictionary serialized to an empty string, so it disappeared from LaTeX output. It is now written
\operatorname{Dictionary}(\operatorname{KeyValuePair}(\text{a}, 1), …), which parses back to the same dictionary. -
Special characters in a LaTeX string are escaped. A string is written
\text{…}, with\,{,},$,%,#,&,_,~and^escaped, so that it reads back as the same string:\text{$}was read as the unit USD and\text{%}as an empty string. A string whose\text{…}form reads as a unit or a keyword (m,km,and) is written"m". The string"50%"was written as the number50%. A control character or a private-use character is written as its code point (a tab is\char"0009{}), where it was written as it is: TeX rejects most control characters and reads a tab or a line break as a space (#345). Acharactervalue is written with the same escapes as the one-character string:#was written\text{#}and a backslash\text{\backslash }. -
A
Solveresult list holds all the solutions, on every route. An empty list states that there is no solution. When the solver cannot show that its list is complete,Solvestays unevaluated,expr.solve()returnsnull, andexpr.explain('solve')ends with the stepsolve.incomplete-…and the unevaluatedSolveas its result. Over ℝ, ℂ or with no domain, a periodic trig equation gives its principal roots: a finite list whose translates by the period give every root. When the unknown has no declared type and no domain is given, a polynomial gives all its roots, complex roots included (x^2 + 1 = 0is[i, −i]), and every other equation gives its real solutions only (e^x = −1and|x^2 + 1| = 0are[],|x − 1| = 3is[−2, 4]). Declare the unknown complex to solve such an equation over ℂ. These results change:expr.solve()andexplain('solve')returned[]whereSolvestayed unevaluated:e^x = x + 2,x^2 = 2^x,cos x = x,arsinh(x) = 1,(x + e^x)(x − e^{−x}) = 0,x^5 + ax + 1 = 0now givenull(new stepsolve.incomplete-no-roots). ThusSolve(x = 1 ∨ e^x = x + 2, x)was[1]and now stays unevaluated. AnOrwhose alternatives all have no root gives[](e^x = −1 ∨ e^x = −2), where it gavenull. An identity (x = x) gives a list with one free parameter, asSolvedoes. A candidate of a root template that the host added toce.solveRulesthat the check rejects no longer gives[], and it does not show that a factor of a product has no root: with the templateBesselJ(0, x) → 0,(x − 1)·BesselJ(0, x) = 0was[1], and now has no answer.- A trig function of an argument that is not linear in the unknown gives
no answer:
sin(e^x) = 0was[](ln πis a root),sin(x^2) = 0was[0],sin(√x) = 0[0],sin(ln x) = 0[1],cos(2^x) = 0one root,sin(1/x) = 0[+∞, ~∞]. The harmonization rulessin(f) → f,cos(f) → f − π/2andtan(f) → f, which gave one branch only, are removed.sin(sin x) = 0(was[0, π]) also has no answer now. - An infinite value is never a root:
1/x = 0was[+∞, ~∞], it is[]. - A trig function of a linear argument
a·x + bgives all its principal roots:sin(2x) = 0was[0], it is[0, π/2];cos(2x) = 0was[π/4], it is[π/4, −π/4];sin(x + 1) = 0was[−1], it is[−1, π − 1];cos(2x + 10) = 0in degrees was[40], it is[40, −50].tan(2x) = 1,sin(2x) = 1/2andcos(3x − 1) = 1/2, which had no answer, now have one.a·sin x + b·cos x = 0gives its second root:sin x = cos xwas[π/4], it is[π/4, 5π/4]. - An even power under a logarithm or a rational power keeps both signs:
ln(x^2) = 2was[e], it is[−e, e](also over[−5, 5]);x^(2/3) = 4was[8], it is[8, −8](the engine gives the real value(−8)^(2/3) = 4);x^(4/3) = 16was[8], it is[8, −8]. A logarithm of such a power is solved as it is given, not from its simplified form(2/3)·ln(x):ln(x^(2/3)) = 2was[e^3], it is[e^3, −e^3](also over[−30, 30]);ln(x^(4/3)) = 4is[e^3, −e^3]andln(x^(−2/3)) = 2is[e^−3, −e^−3].ln(x^(3/2)) = 3is still[e^2]. - A logarithm or an exponential of a linear argument is solved:
ln(x + 1) = 2is[e^2 − 1],ln(2x + 1) = 1is[(e − 1)/2],−3ln(x + 1) = 6is[e^−2 − 1],log_10(x + 1) = 2is[99],ln(x + 1) + 1 = 0is[1/e − 1],e^(x + 1) = 2is[ln(2) − 1]and10^(x + 1) = 5is[log_10(5) − 1]; they were[]. An exponential with a positive base has no real root when the other side is not positive:e^(x + 1) = −2is[]. - A constant times an absolute value is solved:
2|x| = 1is[1/2, −1/2],3|x| − 6 = 0is[2, −2],|x| = e^−1is[1/e, −1/e]andln(x^−2) = 2is[1/e, −1/e]; they had no answer.2|x| + 1 = 0is[]. - A substitution no longer uses a symbol of the equation as its temporary
unknown. A symbol with a value was used: with
u := 1/2,sin(2x) + u = 0gave[]fromexpr.solve(), and it is now[−π/12, 7π/12];e^(2x) − 3e^x + 4u = 0,x^4 + x^2 − 2u = 0andx·√(x^2 + 1) = 2uwerenullor[], and they now have their roots. A type of the same name no longer applies to the temporary unknown: withu: integer,2√x + 3·⁴√x = 2gave[], and it is now[1/16]. - A list that a recursive strategy made from a partial list is not an
answer:
ln((x − 2)(x + e^x) + e) = 1was[2]. A polynomial factor with a symbolic coefficient that gives no root is not shown to have none:(e^x − 2)(x^3 + ax + 1) = 0was[ln 2]. - Over a bounded domain, a list that is known to be partial is accepted
only when the numeric scan resolves each root it finds:
sin(x)·(x + e^x − 1 − 10^−8) = 0over[−1, 1]was[0](the root near5·10^−9was missing) and now stays unevaluated. Each periodic factor of a product gives its family in the domain:(x − 1)·cos(x) = 0over[0, 5]is[1, π/2, 3π/2],e^x·sin(x) = 0over[−1, 7]is[0, π, 2π], andx·sin(x) = 0over[−10, 10]gives the seven multiples of π; they stayed unevaluated. - A system of congruences with no solution (
x ≡ 1 (mod 4),x ≡ 2 (mod 8)) gives[]from the system solver; before, the[]came from the univariate solver, which now givesnullthere. - Over a bounded domain, the samples of the numeric scan no longer show
that a list is complete: they can only find a root that is not in the
list. Interval arithmetic must prove that each other part of the domain
has no root.
sin(x) − 2·e^(−10^12·(sin(x) − 1/2)^2) = 0over[0.3, 0.8]was[]: its two roots nearπ/6are in a dip that is narrower than the distance between two samples. It now stays unevaluated. A pole in the domain does not stop the proof: the numerator of the equation written as one fraction (sin(x) − cos(x)fortan(x) − 1) is used near the pole. Only an enclosure of real values is a proof:√(sin x/(sin x − 2)) − 2·e^(−10^12·(sin x + 1/2)^2) = 0over[3.3, 4]was[], but it has two roots near7π/6; it now stays unevaluated. - In
Solve, a parameter (a symbol other than the unknown, with no value) whose type is not declared is real, as insimplify(). A parameter declaredcomplex, or with a type that excludes the reals, can be complex. The unknown keeps its own type or domain. A root whose membership in the type of the unknown is not decided is not removed as a decision: the list has no answer (stepsolve.incomplete-undecidedwhen a part of the list is kept). These results change:tan(2x) = aandtan(3x) = awere[](x = 0is a root fora = 0); they are[arctan(a)/2]and[arctan(a)/3].cos(2x + 1) = aandsin(2x) + a = 0were[]; they give their two principal roots, with the guard|a| ≤ 1.(x − 1)(tan(3x) − a) = 0was[1]; it is[1, arctan(a)/3].- The guarded roots are the same for a parameter declared
real:cos(2x) = awitha: realwas[]. - For an unknown declared
real:x = a,2x = a,(x − 1)(x − a) = 0,sin(x) + a = 0and2|x − a| = 2were[]; they are[a],[a/2],[1, a], the two guarded principal roots, and[a + 1, a − 1]. - With
adeclaredcomplex, all these equations have no answer. - A polynomial that is a product of factors with a coefficient that is
not a number is solved factor by factor:
(x − 1)(x − a) = 0is[1, a], not the two roots of the quadratic formula. - A decided check still removes a root: for an integer
x,2x = 3is[];2x = ahas no answer (a/2is an integer only for an evena).
- An exponential is positive only for a real exponent.
e^(ix + 1) = −e,e^(ix^2) = −1,e^(i(x + 1)) = −1ande^(ix) = −1were[](x = πis a root of the first and the last); they now have no answer.e^(ax) = 2is still[ln(2)/a]ande^(x + a) = 2is still[ln(2) − a]; withadeclaredcomplex, they have no answer. Roots that are not real are removed:e^(x^2) = 1/2was[±√(−ln 2)]and2^(x^2 + 1) = 1was[i, −i]; both are[]. |u| = ris split intou = ±ronly whenuis real for each realx.2|x + i| = 2was[1 − i, −1 − i]and|x + i| = 1the same (0is the real root): they now have no answer.|x − a| = 2is still[a − 2, a + 2], and has no answer for anadeclaredcomplex. Roots that are not real are removed:2|x^2 + 1| = 1and|x^2 + 1| = 1/2were four imaginary roots and are[];|x^2 − 1| = 3was[2, −2, i√2, −i√2]and is[2, −2].- An absolute value beside other terms with the unknown is split into its
two cases, and each case is solved:
|x − 1| = x^5 + xwas[](a root template solved only one case), it is[0.486…];|x| + x^5 − 3 = 0, which had no answer, is[1.133…].|x| = xwas[0], but eachx ≥ 0is a root: it now has no answer. When a case is not solved, there is no answer:|x − 1| = x + 2e^xwas[](a root near−0.27). - For an unknown declared
complex, the strategies that suppose a real unknown give no answer:e^x = −1,sin(x) = 2,cosh(x) = 0were[], and|x| = 1was[−1, 1]. - A logarithm with no root template is solved:
log(x) = 2is[100],log(x) = log(7)is[7],log_2(x + 1) = 0is[0],log(x^2 − 3) = 0is[2, −2],log_2(x) = 0is[1]; they had no answer. A root no longer keepsb^(log_b(v)):log_2(x + 1) = log_2(3)was[−1 + 2^(log_2 3)], it is[2], andlog_2(x + 1) = log_2(3) + 1is[5]. A root of a logarithm with a base that is not known to be positive and different from 1 is guarded:log_a(x + 1) = 3was[a^3 − 1], it is[a^3 − 1]with the guard0 < a ∧ a ≠ 1;log_a(x) = 3, which had no answer, is[a^3]with the same guard. - A root template is built in only when it is one of the template
objects of the engine, not when it has the id of one. A wrong copy of
solve.linearthat keeps its id ({ ...rule, replace: 7 }) no longer makes the rejection of its candidate a decision: with such copies ofsolve.linearandsolve.power,5x − 10 = 0was[], it now has no answer.
-
#409 The
Noperator gives an inexact result. A number thatNreturns is a float, also when its value is an integer.["N", 2]evaluated to the exact integer2, thusN(2)/3was the exact rational2/3, and["Mean", ["N", ["List", 1, 2, 3, 4], 30]]was5/2. Now["N", 2]evaluates to the float2, which serializes as{"num": "2.0"}(the same value asce.box({num: "2.0"})),N(2)/3is0.666…and the mean is the float2.5. The elements of a list or tuple result are floats too:["N", ["List", 1, 2], 30]is["List", {"num": "1.0"}, {"num": "2.0"}]. With a precision above machine precision, the float is a big decimal.Nof a lazy collection is lazy, and each element is a float when it is read:N(Range(1, 4))/3was[1/3, 2/3, 1, 4/3], it is now[0.333…, 0.666…, 1.0, 1.333…]. These results do not change: an infinity, and a symbolic result (the1inN(x + 1)stays exact). AnNin the body of aMapgives inexact elements, also when theMapis compiled:Map(x ↦ N(x^2), [1, 2, 3])is[1.0, 4.0, 9.0]. The.N()method of an expression is not changed:ce.box(6).N()is still the exact6, and the elements of the.N()of a lazyMapkeep the values of.N(). The serialization of the.N()of a lazyMapchanges: the body of its function is in the engine-internal markerNumericApproximation, not inN, for example["Map", ["Function", ["Block", ["NumericApproximation", ["Sin", "_1"]]], "_1"], ["Range", 1, 200]]. -
#391
N(x, p)does not change the precision of the engine. Withpabove the precision,N(x, p)setce.precisiontopand left it there: afterce.box(["N", "Pi", 30]).evaluate(),ce.precisionwas30, and every later evaluation computed at 30 digits. NowN(x, p)computes atpdigits plus guard digits (see below) and then restores the precision of the engine and of the big decimals, also when the evaluation throws:ce.precisionstays21. The result is rounded topdigits, andtoString(),.jsonand.latexshow itspdigits:N(Pi, 30)is3.14159265358979323846264338328(before,.jsonalso showed the guard digits,3.141592653589793238462643383279503). An operation on the result is computed at the precision of the engine, as for any other big decimal, and its result shows that precision:N(Pi, 30) + 1is4.14159265358979323846. This applies to each element of a list or tuple result, to each part of a complex result, and to each float of a symbolic result:N(x + π, 50)isx + 3.14159…with 50 digits, andN(x + π, 5)isx + 3.1416(it wasx + 3.14159265358979323846). On an engine at machine precision, the float of a symbolic result is a machine float. A float of the operand does not change:N(x + 0.1, 50)isx + 0.1. A value that does not change with the precision is displayed with the precision of the engine, because its digits past that precision are not correct: withw = (1/7).N()·sin(1).N(), computed at 21 digits,N(w, 40)showed 40 digits, 19 of them wrong, and now shows 21. The same applies to the value of a kernel that computes with machine floats. The elements of a lazy collection result are computed from the operand ofNwhen they are read, each one atpdigits by its ownN:N(Map(Range(1, 3), x ↦ xπ), 30)gives 30 digits ofπ,2πand3π. The function of a lazyMapis computed by theNof each element: withN(Map(Range(1, 3), x ↦ √(x + 0.5)), 30), the first element was1.2247448713915890491(the 21 digits of the engine), and it is now1.22474487139158904909864203735. This also applies to the elements of another lazy collection, such asReverse(Map(…)), and to an infinite collection. The function of theMapkeeps the variables of its scope:N(Block(c := 2, Map(x ↦ x/c, Range(1, 3))))gives[0.5, 1, 1.5], also when the caller has a variablec. On an engine at machine precision,N(x, p)withpat or below 15 gives a machine float:N(1/3, 11)gave a big decimal, because it is computed at 16 digits. A function literal remembers its values for each precision, also the values of an exact evaluation (which can be a float): withf(t) := N(π)andg(t) := f(t),N(g(1), 50)afterN(g(1))showed the 21-digit value off(1). A sequence remembers its numeric terms for each precision, apart from its exact terms: withB_n = π·B_{n−1}/n,N(B_10, 50)afterN(B_10)was the 21-digit term0.025806891390014060012598262(wrong past the 22nd digit), and it is now0.025806891390014060012598294252898849657186441048147; an exact evaluation ofB_10after.N()gave the float term.N(x, p)computes with guard digits (5, orp/10for a largep) and then rounds topdigits. Before, the last digit could be wrong:N(Exp(i), 30)had the real part0.540302305868139717400936607444, now…607443, andN(Gamma(1/3), 30)ended with…098, now…097. A complex result ofN(z, p)withpat or below the precision is rounded as a big decimal: the parts were rounded to machine floats, andN(Exp(i), 20)was(0.5403023058681398 + 0.8414709848078965i). It is now(0.5403023058681397174 + 0.84147098480789650665i). A float above1.8e308is rounded as a big decimal too (it was not rounded), and an imaginary part below5e-324is kept (it was dropped). An infinite sum with a closed form is computed from the closed form when the precision is above machine precision:N(Sum(1/k^2, k=1..∞), 30)was1.6449340668482313(from an extrapolation with machine floats, 14 correct digits), and it is now1.64493406684822643647241516665. An infinite sum with no closed form is still computed with machine floats, and shows the 17 digits of its value.N(Measurement(v, δ), p)keeps the uncertainty and roundsv:N(Measurement(1.23456, 0.01), 3)was1.23, it is nowMeasurement(1.23, 0.01). Thus the numeric value of an integral,N(Integrate(…), p), is aMeasurement, not a plain number. The.N()method of a lazyMapwhose function reads a variable of another scope now reads that variable:Block(c := 5, Map(x ↦ x/c, Range(1, 300))).N()was[1/c, 2/c, …], it is now[0.2, 0.4, …]. -
N(list, p)rounds each element. With a precisionpat or below the working precision,Nrounded a number result topsignificant digits, but not the elements of a list or tuple result:N([1/3, Pi], 4)was[0.333333333333333333333, 3.14159265358979323846]. It is now[0.3333, 3.142], also in nested lists and tuples. -
A set is never paired by position in an element-wise operation. A set has no order, but beside a list it supplied elements by position and the result was cut to the shorter length:
Power([1, 2, 3], Set(1, 2))was[1, 4]. A set at a number parameter now gives theincompatible-typeerror thatPower(Set(1, 2), 2)already gave. At a parameter that is not a number, the set is used whole in each element:String([1, 2], Set(3, 4))is a list of two strings, andLess([1, 2, 3], Set(1, 2))is a list of three comparisons that stay unevaluated.Power([1, 2], Interval(0, 1))gave[1, 1.189…]from the endpoints of the interval, and it is now the same error. -
Numeric functions give a type error for a set operand.
PowerandSingaveincompatible-typefor a set, but 82 other numeric functions, such asFloor,Mod,Arctan2,GammaandZeta, stayed unevaluated, andIsPrime(Set(1, 2))wasFalse. They all give the error now. A list still broadcasts:Floor([1.5, 2.5])is[1, 2]. -
Solveof a computed collection is a type error.Solve(Range(1, 3), x)andSolve(Linspace(a, 0, 3))were[], a false statement that there is no solution. A first operand that is a collection but is not a written list, set or tuple of equations (Range,Linspace,Map, a symbol that holds a list) now gives anincompatible-typeerror. A written list is a system of equations, as before. -
Error functions and trigonometric integrals on the imaginary axis.
Erf,Erfc,Erfi,SinIntegral,SinhIntegral,CosIntegralandCoshIntegralof an argument exactly on the imaginary axis (an exact imaginary number such as2i, or a float complex number with a real part of0) are computed with the identitieserf(iy) = i·erfi(y),erfc(iy) = 1 − i·erfi(y),erfi(iy) = i·erf(y),Si(iy) = i·Shi(y),Shi(iy) = i·Si(y),Ci(iy) = Chi(|y|) ± iπ/2andChi(iy) = Ci(|y|) ± iπ/2, from the real kernels:- The part that is a constant is exact.
Erf(i).N()was2.22e-16 + 1.6504i, it is now1.6504i;Erfc(i).N()was0.9999999999999998 − 1.6504i, it is now1 − 1.6504i. - Above machine precision, the other part of
Erf,ErfcandErfihas the working precision: at 50 digitsErf(i).N()is1.650425758797542876025337729561362443895679874874i, was a double. - A value past the number range stays unevaluated instead of
NaN: a complex value that is too large has a direction that no infinity of the engine holds, the ruleGammaalready follows. This applies toErf(27i)andSi(1000i)at machine precision,Ci(1000i), andErf(10^{10}i)at every precision. A real overflow is still+∞(Erfi(27)).
- The part that is a constant is exact.
-
Bessel and Airy functions of a complex argument.
BesselJ,BesselY,BesselI,BesselK(integer order) andAiryAi,AiryBi,AiryAiPrime,AiryBiPrimecompute a value at a complex argument under.N(), and underevaluate()when the argument is a float. They stayed symbolic:BesselJ(3, 0.7i).N()wasBesselJ(3, 0.7i), it is now-0.007367373607628006i.BesselYandBesselKalso have a value on the negative real axis, their branch cut: the limit from above, as forLn(-2)(BesselY(0, -2).N()is0.5103756726497453 + 0.4477815582824712i). On the imaginary axisJ_n(iy) = iⁿ·I_n(y)andI_n(iy) = iⁿ·J_n(y), so one part is exactly0. Measured against mpmath at more than 2,000 points: a relative error of at most7·10⁻¹⁴for J, I and K,4·10⁻¹⁴for Y away from its zeros,5·10⁻¹⁴for the Airy functions up to|z| = 40and3·10⁻¹³at|z| = 120(the conditioning of the function there). A non-integer order stays symbolic. The result type ofBesselYandBesselKof a real argument that is not positive is nownumber, notreal. Compiled code computes these functions for a real argument only: a complex operand is refused (compiledBesselI(0, z)gave1atz = 1 + i). -
LambertWon every branch, andSinc,FresnelS,FresnelCof a complex argument..N()(andevaluate()of a float argument) computesW_k(z)for every integer branchkand every complexz, with the branch cuts of mpmath'slambertw(on a cut, the limit from above, as forLn). A real argument outside the real domain of a branch has its complex value:LambertW(-1).N()is-0.31813 + 1.33724iandLambertW(0.5, -1).N()is-2.25916 - 4.22096i(they stayed symbolic).W_k(0) = -∞fork ≠ 0.Sinc(1+2i).N()is1.41700 - 0.87439i. On the imaginary axisSinc(iy) = sinh(y)/y,FresnelS(iy) = -i·FresnelS(y)andFresnelC(iy) = i·FresnelC(y), with the constant part exact. Against mpmath, the largest relative error is6.6·10⁻¹⁶forLambertW(31,000 points, branches −3 to 3) and4.3·10⁻¹⁶forSinc; for the Fresnel integrals it is4·10⁻¹⁶for|z| < 1and5·10⁻¹⁴for|z| < 10, and further out it follows the condition numberπ|z|². -
LambertW(-1/e)andLambertW(-1/e, -1)are exactly-1, underevaluate()as well as.N(), at every precision.evaluate()left them unevaluated. At machine precision.N()gives-1, not the complex value of the double just below-1/e. -
The type of
LambertW(x)for a realxisrealonly when the branch and the range ofxprove it (W₀on[−1/e, ∞],W₋₁on[−1/e, 0)). Otherwise it isnumber:LambertW(-1)was typedreal. -
solvekeeps a root from aLambertWtemplate only when it is real. The templates that solvex·eˣ = band the related shapes give the real roots:W₀when its argument is in[−1/e, ∞), andW₋₁for the second real root when its argument is in[−1/e, 0). Outside these intervalsW₀andW₋₁now have complex values, which are two of infinitely many complex roots:solvereturns the real roots of these equations, as it does forsin(x) = 2. Sox·eˣ = 3still solves toW(3)only, andx·eˣ = -1has no root. -
A parameter declared as a scalar maps over a tuple argument.
Apply((u: real) ↦ 2u, (a, b))was anincompatible-typeerror. It is now(2a, 2b), asSin((a, b))is(sin(a), sin(b)), andApply((u: real) ↦ 7, (a, b))is the tuple(7, 7). A scalar is a number type orboolean, asNot((True, False))is(False, True):(p: boolean) ↦ ¬papplied to(True, False)is(False, True). A parameter declaredstringbinds a tuple whole. The rule is the same for a function assigned the literal with no declaration, and for a function declaredfunctionand then assigned. A signature declared for the function also declares its parameters:kdeclared(real) -> realand assignedu ↦ 7givesk((a, b)) = (7, 7). A list of tuples maps over the list, then over each tuple, and a tuple of tuples maps at each level. A component that does not match the declared type is an error in its own cell:(n: integer) ↦ n + 1applied to(1.5, 2)gives(Error(incompatible-type, …), 3). A tuple with a list component (([1, 2], [3, 4])) is anincompatible-typeerror, as forSin. A symbol declared as a tuple with no value holds the call, as a list symbol does, and the call maps when the symbol gets a value. The type of the call is the tuple of the results (tuple<real, real>). A parameter with no declared type (u ↦ 7) still binds the tuple whole. The compiled code computes the same tuple: an array in JavaScript, an array of enclosures on the interval target, and avecNon GLSL and WGSL (a tuple of booleans is declined there, because a shader vector holds numbers). Before, these targets declined the call, andk((a, b))forkdeclared(real) -> realand assignedu ↦ 2ucompiled toNaNin JavaScript and to an empty interval on the interval target. A list beside the tuple, at a parameter declared as a collection, is bound whole in each cell:(u: real, w: list<real>) ↦ u + Length(w)applied to(1, 2)and[10, 20]is(3, 4), typed as a tuple, and the JavaScript target compiles it. A failing component carries the same "while applying … element-wise" note as a failing element of a list. -
The norm of a point with list components of different lengths is an error, and the norm of an empty point list is
[]. WithL = [1, 2, 3]andM = [],Norm((L, M))andAbs((L, M))are theincompatible-dimensionserror thatHypot(L, M)andL + Mgive. Before, they were[], as if the empty component made the other list not count. The L∞ norm of such a point, which stayed unevaluated for any length mismatch, gives the same error.PointList(L, M)zips to the shortest component and is a list of zero points, soNorm(PointList(L, M))is now[], one norm per point, as its typelist<number>says. Before, it was0, the norm of an empty vector. -
A function literal applied to a list symbol with no value stays unevaluated. With
vdeclaredlist<real^2>and no value,Apply(u ↦ 7, v)has the type of a list of two numbers, but it evaluated to7, andApply((u: real) ↦ 2u, v)was anincompatible-typeerror. Both now stay unevaluated, as they do whenvis one of several arguments. Whenvgets a value, the application maps over it:v := [3, 4]gives[7, 7]and[6, 8]. -
A declared user function applied to a list with no value stays unevaluated. With
fdeclared(real) -> realand assignedu ↦ 7, andwdeclaredlist<real>with no value,f(w)evaluated to7while its type waslist<real>. A body that reads its parameter (u ↦ u + 1) gavew + 1. The call now staysf(w), as it does for a function assigned with no declaration and for a function literal (Apply(u ↦ 7, w)). Afterw := [1, 2], it evaluates to[7, 7]. A scalar argument is applied as before. -
A function assigned without a declaration holds a call on an argument that can still become a list. With
h := u ↦ 7andqdeclaredreal | list<real>with no value,h(q)evaluated to7, which is wrong afterq := [1, 2](the call then gives[7, 7]). The call is now held, asf(q)for a functionfdeclaredfunctionandApply(u ↦ 7, q)already were, and it maps whenqgets a list value. Aqdeclaredreal, or with a scalar value, is applied at once, as before. -
A call on a set or a dictionary with no value is no longer held. With
sdeclaredset<real>(ordictionary<real>) and no value,Apply(u ↦ 7, s)andf(s)for a functionfdeclaredfunctionand assignedu ↦ 7stayed unevaluated, whileh(s)forh := u ↦ 7gave7. A call is held only so that it can map over the argument when the argument gets a list value, and a set or a dictionary is never mapped over. The three calls now give7, and withu ↦ u + 1or arealparameter they give the sameincompatible-typeerror. An argument that can still be a list (list<real>,real | list<real>,broadcastable<real>,collection<real>) is held on all three routes, as before. -
Applyof a function literal gives the error for an argument that its parameter refuses.Apply((u: real) ↦ 7, "abc")evaluated to the inertApply((u) => 7, Error(incompatible-type, …)), whileh("abc")forh := (u: real) ↦ 7gave the error itself. TheApplynow gives the sameError(incompatible-type, …), for a string, a boolean or a set. A cell of a list mapped by a function declaredfunctionand assigned such a literal also gives the error, not the inertApply. -
A string or a boolean at a declared
realparameter makes the call invalid on every route.h("abc")forh := (u: real) ↦ 7was refused when it was boxed, typederror, whilef("abc")forfdeclaredfunctionandApply((u: real) ↦ 7, "abc")were typedintegeruntil they were evaluated. The three calls are now refused when they are boxed. -
The type of
Apply(u ↦ 7, s)forsdeclaredcollection<real>orbroadcastable<real>isbroadcastable<…>. The call is held untilshas a value, and it maps over a list value, but it was typed as one value (number). The named callsh(s)andf(s)were already typedbroadcastable<…>, and now the three agree. -
Derivative(f)is a function literal also when its closed form holds the derivative of another function. Withh(x) := g(x)·xandgnot defined,Derivative(h)evaluated to the expression_ · Apply(Derivative(g, 1), _) + g(_)in the hole_, whereDerivative(Sin)evaluates to(x) ↦ cos(x). It now evaluates to(x) ↦ x · Apply(Derivative(g, 1), x) + g(x). The derivative of a function literal (Derivative((x) ↦ x·g(x))) gave the body alone, withxfree; it now gives the literal too.h'(t),Apply(Derivative(h), t)and their value oncegis defined do not change. The derivative of a recursive function (f(x) := x·f(x−1)), whose closed form holdsDerivative(f)itself, stays the inertDerivative(f): as a function literal, its body would evaluateDerivative(f)again at each application. -
A short numerator over a long denominator serializes as
\frac.\frac{x}{y^2+z^2}serialized as(x)(y^2+z^2)^{-1}, and\frac{1}{(x+1)(x+2)}as((x+1)(x+2))^{-1}. The default fraction style wrote an inverse power when the numerator had at most two leaves and the denominator more than five. With a numerator other than 1, that output parsed back toMultiply(x, Divide(1, …)), not toDivide(x, …). The default style now writes\frac{x}{y^2+z^2}and\frac{1}{(x+1)(x+2)}. A power-1of a long base serializes the same way:\frac{1}{x-\frac{\pi}{2}}, not(x-\frac{\pi}{2})^{-1}. Thereciprocalfraction style still writes the inverse power when a caller selects it. In that style, the numerator gets parentheses only when it needs them (a sum, a negative value):x\times(y^2+z^2)^{-1},2(y^2+z^2)^{-1},(x+1)(y^2+z^2)^{-1},(-y)(y^2+z^2)^{-1}, not(x)(y^2+z^2)^{-1}or(2)(y^2+z^2)^{-1}. A symbol or a decimal number before the inverse power gets an explicit\times:x(y^2+z^2)^{-1}parses as a call ofx, and1.5(2)^{-1}as the inverse of the repeating decimal1.5222…. The output parses back toMultiply(x, Divide(1, y^2+z^2)), which evaluates to the same expression asDivide(x, y^2+z^2). -
A negative number at the start of a difference has no parentheses.
-x-1serialized as(-1)-x, and\frac{-x-1}{y}as\frac{(-1)-x}{y}. They now serialize as-x-1and\frac{-x-1}{y}: the terms also keep their order, and the prettified MathJSON of-x-1is["Subtract", ["Negate", "x"], 1], not["Subtract", -1, "x"]. A raw["Subtract", -1, "x"]serializes as-1-x. A negative number after a minus sign keeps its parentheses (x-(-1)). A sum or a difference subtracted inside a sum now has parentheses:Add(y, Negate(Add(a, b)))serialized asy-a+b, which is a different value, and now serializes asy-(a+b).toString()keeps the order too:-x - 1, not-1 - x. Only a positive constant moves in front of a negated term (1 - x). -
The
timeLimitMsoption ofloadIntegrationRulesalso sets the time limits of the rule driver's sub-searches. The step budget decides when the driver gives up, and the time limits only guard against a hang. But the two sub-searches of the driver (the native rational fallback and the clean-up of an expansion result) had a fixed limit of 5 s. On a slow or loaded machine, that limit could stop a sub-search that the step budget allowed, and a caller could not raise it. Each sub-search now gets one sixth oftimeLimitMs: 5 s for the default of 30 s, as before. On a heavily loaded machine, a caller can raisetimeLimitMsso that no time limit decides the result. For example,∫(3+2x)³·csc(1+2x)/(1+sin(1+2x)) dxneeds about 64,000 of the 300,000 steps and takes about 4 s on an idle machine; with a load average of 300 on 8 cores, the 30 s limit stopped it and the integral stayed unevaluated. -
The integration rules wait longer before they give up on the clock. The default
timeLimitMsofloadIntegrationRules()is now 120 s (it was 30 s), and each of the two bounded sub-searches of the rule driver gets one sixth of it (20 s). The step budget, which does not depend on time, decides when the driver gives up; the clock is only a guard against a hang. With 30 s, a machine 2 to 3 times slower than an idle desktop, or a loaded one, could stop a search that the step budget allows, and the integral stayed unevaluated there while it closed on a faster machine: for example\int(3+2x)^3\csc(1+2x)/(1+\sin(1+2x))\,dxneeds about 4 s on an idle machine. A real hang now blocks for up to 2 minutes before the integral is given up; set a lowertimeLimitMsto change that. -
In the lenient grammar, a number after
√is the whole radicand.√12was read as√1·2and√2.5as√2·0.5, the TeX reading of a radicand without braces. They are now√(12)and√(2.5), assqrt12andsqrt2.5are. The number ends the radicand:√12xis√12·x. Because the TeX reading differs, a number of more than one token reportsambiguous-radical(√2does not). The strict grammar keeps the TeX reading:\sqrt12and√12are\sqrt{1}2. -
In the lenient grammar, an unbraced subscript of digits is the whole run of digits on every base.
π_12wasπ_1·2and(x)_12was(x)_1·2, whilex_12wasx_{12}. They are nowπ_{12}and(x)_{12}, and2748_16is the number 2748 in base 16. On a base that is not a symbol, a run that starts with0keeps the one-token reading, because the number value drops the zero:(x)_01is(x)_0·1, and it reportsambiguous-implicit-subscript. A symbol keeps the text:π_01isPi_01, asx_01isx_01. -
More
ambiguous-*parse diagnostics in the lenient grammar. These inputs were read with no diagnostic, although a person can mean a second reading. The reading does not change, and the strict grammar reports none of these codes:ambiguous-letter-decimal: a period and digits after an operand that is not a letter (√b.5,e^f.5,(x+1).5,|x|.5), and after a superscript of digits (x².5,x^{2}.5). The Desmos spellingst^{i}.4and\left(1-t\right).9are not reported.ambiguous-implicit-subscript: a Greek base (Δ_a2, asx_a2), and a constant or a group (π_a2,π_1y,(x)_a2). Also an unbraced subscript of two letters that is split because a script or a digit follows it:x_ab^2isx_a·b^2anda_kx^kisa_k·x^k, and a person can meanx_{ab}^2, the reading ofx^2_ab.ambiguous-radical: a number after√, white space and a number (√1 000is√1·0).ambiguous-number-notation: a number with a subscript of digits as the argument of a function name with no parentheses (min3_12ismin(BaseForm(3, 12)),sin2_8), as10111_2is reported.ambiguous-factorial: a radicand or an exponent with a subscript (√i_1!, as√i!), and a!after white space after the argument of a function name (αtanπ !isα·(tan π)!, whiletan x!istan(x!)).ambiguous-radical: a radicand with a subscript then white space (√a_1 b, as√a b), and a run of letters read one letter at a time before√(xy√i, asy√i).ambiguous-exponent-end:√after an exponent (e^θ√z,x^√θ√g), and a function name after an exponent and white space when the exponent is not signed (e^x cos(x),e^x sin x). Before, a function name was reported only after a signed exponent (e^-x sin x); the same rule now applies to every operand after the exponent.x^2 sin xande^{x} cos(x)are not reported.ambiguous-function-subscript: a base oflogthat is the0of a longer run of digits (log_01(x),log012(x)). The base is0, as in the strict grammar\log_01(x), and a person never means base 0.
See
docs/plans/2026-10-01-lenient-ambiguity-codes.md, section 5. -
In the lenient grammar, a braced
-1on a function name is the inverse function, as the unbraced-1is and as\sin^{-1}is in LaTeX.sin^{-1}(x)andsin⁻¹(x)(the tokenizer reads⁻¹as^{-1}) were1/sin(x). They are nowarcsin(x), and they reportambiguous-inverse-function, assin^-1(x)does. -
In the lenient grammar, the inverse of a logarithm is a power, as in the strict grammar.
ln^-1(x)wasApply(InverseFunction(Ln), x), which does not simplify, and is nowexp(x);log^-1(x)andlg^-1(x)are now10^x. The codeambiguous-inverse-functionis still reported. -
In the lenient grammar, a letter followed by digits that start with
0keeps the digits.x01wasx_1andx012wasx_12: the number value of the digits dropped the zero. They are now the symbolsx_01andx_012, asx_01is. A run with more digits than a JavaScript number holds exactly is kept the same way:x123456789012345678901is the symbolx_123456789012345678901. These still reportambiguous-implicit-subscript. -
A subscript on
cbrtornPrkeeps the meaning of the name, in the lenient grammar.cbrt_2(x)wasApply(Subscript(Root, 2), x), which lost the index 3 of the cube root, and is nowApply(Subscript(Root, 2), x, 3).nPr_2(5, 2)wasPermutations_2(5, 2), a subscript on the collection of the arrangements, and is nowSubscript(Binomial(5, 2)·2!, 2), a subscript on the count. Both still reportambiguous-function-subscript. -
Argument(Arg) gives the angle in the engine's angular unit, as the inverse trigonometric functions do. Withce.angularUnit = 'deg',Argument(-1)wasπ(a value in radians) whileArgument(i)was90, and the compiledArgumentalways returned radians. NowArgument(-1)is180in degrees,200in grads and1/2in turns, forevaluate(),.N()and a float operand (Argument(-5.1)is180), andAbsArgtakes the same angle. The compiled code on thejavascript,interval-js,glsl,wgslandpythontargets multiplies the radian result by the unit factor, so it agrees with.N(). Radian mode does not change, and neither doLn,SqrtandComplexRootsof a complex value, which compute their angle in radians. -
A
Solveover a bounded domain whose list of roots would be too long to give now stays unevaluated, instead of returning a part of the list. When the domain holds more than 1000 periods of a trigonometric equation, the solver does not expand the roots. It returned the principal roots that are in the domain:Solve(sin(x) = 0, x ∈ [0, 10^9])was[0, π],Solve(cos(3x) = 1/2, x ∈ [0, 10^7])was[π/9], andSolve(sin(x) = 0, x ∈ [10^8, 10^9])was[], a false statement that there is no root. These now stay unevaluated. An integerRangethat is small enough is enumerated instead:sin(x) = 0over-10^4..10^4is still[0]. The same rule applies to an equation that has the unknown also outside a trigonometric function of a linear argument: its list of roots is given only when a numeric scan of the domain finds no other root.Solve(x·sin(x) = 0, x ∈ [-10, 10])was[0, π]andSolve(sin(x^2) = 0, x ∈ [0, 5])was[0]; both now stay unevaluated, whileSolve(x·sin(x) = 0, x ∈ [0, 3])is still[0]. Over the real line (x ∈ ℝ) and the complex numbers,Solvestill gives the principal roots. Over another domain that is not bounded, the principal roots were given as if they were all the roots:Solve(sin(πx) = 0, x ∈ ℤ)was[0], but each integer is a root. Such aSolve(overℤ, a half-line such as[0, ∞), or a union) now stays unevaluated, and a finite set is enumerated:Solve(sin(πx) = 0, x ∈ {0, 1/2, 1})was[0]and is now[0, 1]. An equation with the unknown in a function that the solver cannot invert, and that the numeric scan cannot check, also stays unevaluated over a bounded domain: over[0.5, 20],(x - 1)·Haversine(x) = 0and(x - 1)·Sinc(x) = 0were[1], and over[0.5, 10],(x - 1)·BesselJ(0, x) = 0was[1]. The functions that the solver inverts are the arithmetic operations, powers and radicals, exponentials and logarithms,Abs, the hyperbolic functions and the inverse trigonometric functions. The haversine is now a periodic term, assinis:(x - 1)·Haversine(x) = 0is[1]over[0.5, 6], and(x - 1)·Haversine(πx) = 0over-4..4was[1]and is now[-4, -2, 0, 1, 2, 4]. -
Solveno longer gives a part of the roots as the answer, also without a domain and over ℝ. The principal roots are the answer only for a trigonometric equation, because they represent its periodic families of roots. For a function that the solver cannot invert and that is not periodic, the list of the solver is only a part of the roots:Solve((x - 1)·BesselJ(0, x) = 0, x)was[1], and now stays unevaluated, also withx ∈ ℝ. A product is solved factor by factor, and a factor that gives no root can have roots that the solver does not find:Solve((x - 2)(x + e^x) = 0, x)was[2], butx + e^x = 0has the root−W(1) ≈ −0.567. Such aSolvenow stays unevaluated, without a domain, over ℝ and over[−1, 3]. A factor that is shown to have no real root does not change the answer: a polynomial (x^2 + 1), a power of a constant (e^x), or a function of known sign (e^x + 1). Thus(x - 2)(x + 3),(x - 2)·e^x,(x - 2)(x^2 + 1)andx·ln(x)give[2, −3],[2],[2]and[1]as before. Over a bounded domain, the numeric scan can still show that the list is complete:(x - 1)·Haversine(x) = 0over[0.5, 6]is[1]. The methodexpr.solve()follows the same rule and returnsnullfor these equations:.solve("x")of(x - 1)·BesselJ(0, x)returned[1], and of(x - 2)(x + e^x)it returned[2]. For a list of equations in one unknown,Solvekeeps the candidates that are roots of every equation; when each equation gives only a part of its roots, it now stays unevaluated:Solve([(x - 2)(x + e^x) = 0, (x - 2)(x + e^x)(x + 5) = 0], x)was[2], but−W(1)is also a common root.expr.solve()of anOrreturnsnullwhen one of its alternatives has no answer: the union of the other lists is not an answer (x = 3 ∨ (x - 1)·BesselJ(0, x) = 0was[3, 1]).expr.explain('solve')gives the same result: for these equations its last step is "No complete answer", with the reason (a factor gave no solution, or the unknown is in a function that the solver cannot invert), and itsresultis the unevaluatedSolve. A root template that you add toce.solveRulesis your claim of a solution, and its roots are still returned for a function that the solver cannot invert: with a templateBesselJ(1, x) → 0,Solve((x - 1)·BesselJ(1, x) = 0, x)is[1, 0]. -
In the lenient grammar, a Greek name written without a backslash takes a subscript into its name, as the backslash spelling does.
alpha_{01}wasalpha_1and is now the symbolalpha_01, as\alpha_{01}is.alpha_maxwasalpha_m·a·xand is nowalpha_max.pi_01wasPi_0·1and is nowPi_01. The order of the scripts does not change the reading:alpha^2_{01}andalpha_{01}^2are bothalpha_01^2. The rules and the diagnostics are the same as for the backslash spelling: a run of two letters before a script is split (alpha_ab^2isalpha_a·b^2, and reportsambiguous-implicit-subscript), and white space before the_stops the join (alpha _{01}isalpha_1). The ASCII names of constants keep the subscript outside the name:oo_{01}is stillSubscript(PositiveInfinity, 1). The strict grammar does not change. -
In the lenient grammar, a spelled-out name is a whole unbraced exponent also when a
_or a digit follows it, as the command spelling is.x^alpha_1ande^alpha_1were read one letter at a time (x^a·l·p·h·a_1), and are nowx^{alpha_1}ande^{alpha_1}, asx^\alpha_1is.x^alpha2isx^alpha·2andx^oo_1isx_1^∞, asx^\alpha2andx^\infty_1are. An operand after the exponent reportsambiguous-exponent-end(x^alpha_1 t), as afterx^\alpha_1 t. -
In the lenient grammar, a script, a prime or a digit after a run of letters belongs to the last part of the run when the run holds a spelled-out name, as after a run of single letters (
xy_1isx·y_1,xy2isx·y_2).xalpha_1wasSubscript(x·alpha, 1)and is nowx·alpha_1, asx\alpha_1is;xalpha^2was(x·alpha)^2and is nowx·alpha^2;alphax_1isalpha·x_1.xalpha2wasx·alpha·2and is nowx·alpha_2, asx\alpha2andalpha2are, andxalpha01wasx·alpha·1and is nowx·alpha_01. The run still reportsambiguous-letter-run, and a digit subscript reportsambiguous-implicit-subscript. -
In the lenient grammar, a spelled-out Greek name directly after an argument of one letter is read as the name, as after a letter in a run of letters. The argument is an unbraced exponent, radicand or argument of
\frac.e^xalpha_1wase^x·a·l·p·h·a_1and is nowe^x·alpha_1, ase^x\alpha_1is;\sqrt xalpha_1is\sqrt{x}·alpha_1;e^xpiwase^x·p·i(with the imaginary unit) and is nowe^x·π. The codes do not change:e^xalpha_1reportsambiguous-exponent-endandambiguous-letter-run. A bare function name after the argument is read as the function, as after white space:e^xsin(t)wase^x·s·i·n(t)and is nowe^x·sin(t), withambiguous-exponent-end, ase^x sin(t)is. A word that starts at the argument is not split:\sqrt betastays\sqrt{b}·e·t·a,x^foostaysx^f·o·oandx^acos tstaysx^a·c·o·s·t. -
A symbol whose subscript a dictionary entry reads has the same reading in each order of the scripts, in both grammars.
\delta^2_{01}wasdelta_01^2and\mu^2_0wasmu_0^2; they are nowKroneckerDelta(0, 1)^2andMu0^2, as\delta_{01}^2and\mu_0^2are.\varepsilon^2_0isVacuumPermittivity^2. The same applies to the set notations with a subscript:\R^2_-was a syntax error and\mathbb{R}^2_{>0}wasSubscript(RealNumbers, Error)^2; they are nowNegativeNumbers^2andPositiveNumbers^2, as\R_-^2and\mathbb{R}_{>0}^2are. White space before the_does not change this reading, as LaTeX ignores it:\R^2 _-was a syntax error and\mu^2 _0wasmu_0^2; they are nowNegativeNumbers^2andMu0^2. (White space before the_still stops the join of a subscript to a symbol name:x^2 _{01}isx_1^2.) A script or a prime after\delta_{ij}now applies toKroneckerDelta(i, j):\delta_{01}^2was a syntax error. -
\delta_{11}isKroneckerDelta(1, 1). A subscript ofKroneckerDeltathat is a run of digits with no separator is one index for each digit, as a run of letters is (\delta_{ij}isKroneckerDelta(i, j)). The run was one number:\delta_{11}wasKroneckerDelta(11), which evaluates to 0 (δ₁₁ is 1),\delta_{01}wasKroneckerDelta(1)(the 0 was lost) and\delta_{12}wasKroneckerDelta(12). They are nowKroneckerDelta(1, 1),KroneckerDelta(0, 1)andKroneckerDelta(1, 2). A subscript with a separator keeps its numbers:\delta_{10, 2}isKroneckerDelta(10, 2), andKroneckerDelta(10, 2)serializes to\delta_{10, 2}. One index of two or more digits serializes in a second group, which reads back as one index:KroneckerDelta(11)is\delta_{{11}}. A group of letters and digits is one index for each, in both grammars:\delta_{n0}isKroneckerDelta(n, 0); in the lenient grammar it wasKroneckerDelta(n_0), and\delta_{nm}no longer reportsambiguous-letter-run. A command that is a symbol is one index:\delta_{\pi1}isKroneckerDelta(Pi, 1)in both grammars (in the lenient grammar it wasKroneckerDelta(Pi_1)). In the lenient grammar, a spelled-out Greek name is one index, as its command is:\delta_{nalpha}wasKroneckerDelta(n·alpha)and is nowKroneckerDelta(n, alpha), withambiguous-letter-run;\delta_{pi1}wasKroneckerDelta(Pi_1)and is nowKroneckerDelta(Pi, 1). The strict grammar reads one index for each letter. In the lenient grammar, an unbraced run of digits is the whole subscript, asx_11isx_{11}, and is read as the braced run:\delta_11wasKroneckerDelta(1)·1and is nowKroneckerDelta(1, 1);\delta_01was the symboldelta_01and is nowKroneckerDelta(0, 1). The strict grammar reads one token, as TeX does.\delta_0was the symboldelta_0and is nowKroneckerDelta(0), as\delta_1isKroneckerDelta(1). -
evaluate()returns an exact sequence term that is not a number. A term of a sequence declared withce.declareSequence()ora_n := …was returned only when it was a single number literal; any other term stayed unevaluated. ForB_n = π·B_{n−1}/n,B_0 = 1,B_10.evaluate()wasB_10and is nowπ^10/10!(1/3628800·π^10); fora_n = a_{n−1} + √2,a_0 = 1,a_10.evaluate()wasa_10and is now1 + 10√2..N()gives a float, as before. A term with a free symbol is also returned: forc_n = x·c_{n−1} + 1,c_0 = 1,c_3.evaluate()isx(x(x + 1) + 1) + 1. A term with more than 250 nodes stays unevaluated (c_62is returned,c_63is not), and so does a term whose type is not a number.ce.getSequenceTerms()returns these terms too:getSequenceTerms('a', 0, 3)wasundefined, and it is now[1, 1 + √2, 1 + 2√2, 1 + 3√2]; it is stillundefinedwhen a term in the range has no value, or when a term is not a number (Undefined). The terms of a sequence whose name is not one letter (alpha,fib) are read correctly:getSequenceTerms()readalpha_{0}as a product of letters.ce.checkSequenceOEIS()looks up the numeric values of the terms, from the first index of the domain of the sequence. It reports an error when a term has no numeric value (a term with a free symbol), when a term is complex or not an integer (i·Z_{n−1},a_{n−1} + √2: OEIS holds integer sequences, and the real part was sent), and when a term of a defined sequence has no value (it reported that the sequence was not defined).ce.getSequenceCache()returns only the exact terms, with the index as the key: afterF_6.N()it held the termsF_2…F_6that.N()computed, and it is now empty. The terms that.N()computes are kept apart, for each precision, andce.clearSequenceCache()clears them too. Three more changes to the terms of a sequence:- A memoized term is computed again when a symbol that the recurrence or
a base case reads changes. With
c_n = x·c_{n−1} + 1,c_0 = 1andx := 2,c_3is15; afterx := 3,c_3was still15(from the memo), and it is now40. An assumption or a change of the precision also makes the memo outdated; an assignment to a symbol that the recurrence does not read does not. A term with a free symbol, such asx(x(x + 1) + 1) + 1, is now memoized too. - A cold read of a large index no longer overflows the call stack: the
lower terms are computed on demand, with a stack of the terms whose
computation is not complete, so the depth of the calls does not grow
with the index.
F_500.evaluate()andF_500.N()for the Fibonacci recurrence threwRangeError: Maximum call stack size exceededon a new engine;F_2000now gives its 418 digits. This applies to every pure recurrence, also one with two indices (P_{400,200}of the Pascal recurrence) and two sequences that read each other. A recurrence with side effects (an assignment,Print,Random(), a function declaredpure: false) is computed recursively, and each term is computed one time, so each side effect occurs one time for each term; a cold read that needs more than 100 nested levels of such a recurrence stays unevaluated (read lower indices first). Only the terms that the recurrence reads are computed: withA_n = If(n = 1000, 7, A_{n−1} + Random()),A_1000no longer computesA_1…A_999and consumes no draw, and with the indexIf(n < 10, 0, n − 1),A_5readsA_0only. A constraint that reads the sequence no longer throws an internal value, and a constraint that reads the term it checks no longer overflows the call stack. A recurrence that reads a symbol whose value has a side effect (a valueAssign(c, c + 1)orRandom(), evaluated at each read) is treated as a recurrence with side effects. The memoized terms become outdated when a symbol that the constraints read changes, or when a symbol that another sequence the recurrence reads depends on changes: withA_n = B_nandB_n = B_{n−1} + x, afterx := 2,A_2was still2, it is now4. A term whose computation changes a symbol that the recurrence reads is computed one time in a read (before,a_10of such a Fibonacci recurrence took 5157 computations). A read that needs more than 100,000 terms, an index that is not a safe integer (A_{10^20}, which did not stop), and a term that reads itself stay unevaluated. The read stops when the time limit ofce.withTimeLimit()expires. - A base value is evaluated when it is read: with
b_0 = s,b_n = b_{n−1} + 1ands := 10,b_3wass + 3and is now13;.N()ofp_2forp_0 = π,p_n = p_{n−1} + 1stayed unevaluated and is now5.14159…. - An exact term that contains a float (
h_n = h_{n−1} + 0.1√2) is remembered for one precision:N(g(3), 50)withg(k) = h_kgave the 21 digits of the term, and a laterh_3.evaluate()gave the 55-digit term thatNcomputed. - A term that stays unevaluated is remembered, so it is computed one time.
Before, a recurrence that reads two lower terms took a time exponential
in the index when its terms stay unevaluated:
G_n = G_{n−1} + y·G_{n−2}took 6.9 s forG_20, andd_n = d_{n−1}(d_{n−1} + 1)withd_0 = wtook 3.9 s ford_12. Both now take less than 0.2 s.
- A memoized term is computed again when a symbol that the recurrence or
a base case reads changes. With
New Features
-
#391 An accuracy goal and a precision goal for
N.N(x, [p, a])(MathJSON["N", x, ["List", p, a]]) gives a value ofxwithpcorrect significant digits or with an absolute error below10^-a, whichever goal is met first, asN[x, {p, a}]does in Mathematica. Either goal can bePositiveInfinity:["N", x, ["List", "PositiveInfinity", 20]]asks for 20 correct digits after the decimal point, however many significant digits that takes. Forx = 10^10·(e^100 − e^(999999999999/10^10)), it gives2.688117141681729591326298974395630530648558808379370953675983977e+43(64 digits), whereN(x)gave2.6881171417e+43. The engine evaluatesxat a precision that it doubles until two successive values agree to within the goal, with two more digits, then rounds the value tomin(p, a + ⌊log10 |x|⌋ + 1)significant digits. A value below10^-ais0.0:N(Exp(-100), [PositiveInfinity, 20])is0.0. A result that is not a number, or a list of numbers, ignores the goal and is the value at the engine precision:N(x + Pi, [PositiveInfinity, 5])isx + 3.14159265358979323846. When the precision would have to be more than 1000 digits, the call stays unevaluated. A value that does not change with the precision does not meet a goal, because two of its values agree also in their digits that are not correct:N(Zeta(0.5 + 14.134725141734693i), [30, PositiveInfinity])andN(w, [40, PositiveInfinity])for a floatwwith 21 digits stay unevaluated. A value with fewer significant digits than a machine float is exact to its digits and meets the goal:N(0.1, [30, PositiveInfinity])is0.1, andN(Cosh(Ln(2)), [30, PositiveInfinity])is1.25. An exact value meets it:N(1/2, [30, PositiveInfinity])is0.5, and an exact integer keeps all its digits:N(123456789012345678901234567890, [PositiveInfinity, 5])is123456789012345678901234567890. A precision goal for a value that is 0, such asΓ(1/3)·Γ(2/3) − 2π/√3, stays unevaluated after three working precisions, because the values are rounding errors (before, the engine computed up to 2000 digits, which took about a minute); with an accuracy goal, the value is0.0. The precision of the engine does not change. Each number of a list result has its own digits:N([π, 1000π], [PositiveInfinity, 3])is[3.142, 3141.593]. A goal list that does not have two elements, or that has an element that is not a number, is a type error, and so is a set (N(\pi, \lbrace 30, 5\rbrace)): the elements of a set have no order. A precision goal below 1, or two infinite goals, leave the call unevaluated. A lazy collection of two elements is a valid goal:N(Pi, Range(4, 5))is3.142. An exact value that would need more than 1000 digits leaves the call unevaluated. A float0from cancelling terms does not meet a precision goal:N(Exp(10^-100) - 1, [30, PositiveInfinity])is1e-100. A float of the operand meets a goal that needs no more digits than it has: for a machine floaty,N(2y, [PositiveInfinity, 5])is6.28319. An impure operand, such asRandom(), is evaluated one time. On an engine at machine precision, a value outside the range of a machine float is kept as a big decimal:N(Exp(1000), [5, PositiveInfinity])is1.9701e+434, not+∞. A float that is exactly0meets a goal only when the exact value is0, because terms can cancel at the working precision:N(10^100·(e^(10^−100) − 1), [PositiveInfinity, 20])was0, it is now1.0000…. Each float of a symbolic or mixed result is compared at two working precisions and rounded to the goal: forR = e^(π√163) − 262537412640768744,N([R, x], [10, PositiveInfinity])was[-0.001, x], it is now[-7.499274028e-13, x], andN([π, x], [5, PositiveInfinity])is[3.1416, x]. An operand that reads an impure stored value, such as a symbol whose value is an unevaluatedRandom(), is evaluated one time. -
Compiled JavaScript runs without the engine. The new entry point
@cortex-js/compute-engine/runtimeexportscreateJavaScriptRuntime(options), the_SYShelper bundle thatcompile()builds forrun(), with no engine behind it: store thecodeof aJavaScriptTargetresult, and run it on a page, in a worker or on a server withruntime.load(result). The runtime type exposes onlyload,frame,setFrame,iterationLimit,deadlineandruntimeVersion; the helpers are internal and may change in any release. The helpers that read engine state take it as options:randomis the source of draws outside anyWithRandomSeedframe and of the integrals' Monte-Carlo samples (nulldenies draws, and a draw then throws aCapabilityDeniedError, a different class in each bundle, so teste.name, notinstanceof);frame(orruntime.setFrame()) is the frame of an interpretedWithRandomSeedthe code is called from ({ seedLo, seedHi, next }or{ seed, next }), andruntime.frame.nextis the advanced counter after the call;iterationLimitcaps the lazy-stream walks (default 1024, andInfinitywhen set to 0 or less, asce.iterationLimit) anddeadlineis an optional time after which the shuffle and choice loops throw. A seeded program gives the same values interpreted, withrun()and as stored code:WithRandomSeed(7, RandomShuffle(Range(1, 6)))is the same list in all three.load()evaluates the definitions that read nothing per call (a constant list, a memo) once, asrun()does, from the newCompilationResult.preambleOnce,preamblePerCallandcallCode, so aAt(L, Floor(x))with a 1,000-elementLno longer rebuildsLon every call.load()also applies the input conversionsrun()does, from the newCompilationResult.entryPlan: a real given to a complex-declared symbol is lifted (z^2 + zatz = 2is 6 from both, whereload()gave a complex NaN), and aFloat64Arrayfor a list symbol is copied. The digits a negative base's exponent is read to,(-2)^xatx = 33.3333333333333, are fixed inCompilationResult.reconstructionDigits, since the runtime's own number library cannot tell a machine-precision engine from the default (10822639409.68 fromrun(), NaN from the runtime before).run()uses the same recorded digits: before, it read the working precision on each call, so code compiled at machine precision gave NaN for this power afterce.precisionwas changed or after another engine was constructed.CompilationResult.runtimeVersionandruntime.runtimeVersionare the version of the helper set;load()throws when they differ or when the stored code has none. Functions passed in thefunctionsorimportscompile options are copied into the code as source (toString()), so a closure loses its enclosing scope and a function given by name must exist where the code runs. The engine's own_SYS(run.SYS) is built from the same factory. (#372, contributed by enumeratio) -
ResidueClass(k, n): an element of ℤ/nℤ (#399, #411, contributed by enumeratio). A residue class is now a value.- Canonical form.
kis reduced to0…n−1:ResidueClass(7, 5)isResidueClass(2, 5)andResidueClass(-1, 7)isResidueClass(6, 7).nmust be an exact integer ≥ 1. A rationalkwhose denominator is a unit reads asu·v⁻¹:ResidueClass(1/3, 7)isResidueClass(5, 7). A float or a symbolickornstays unevaluated. - Equality.
ResidueClass(7, 5) == ResidueClass(2, 5)isTrue. Classes are not ordered. - Arithmetic. Sums, differences, products and integer powers of classes
of one modulus are classes:
ResidueClass(5, 7) + ResidueClass(4, 7)isResidueClass(2, 7). An exact integer, or a rational whose denominator is a unit, is read in the ring:ResidueClass(5, 7) + 3isResidueClass(1, 7). An inverse, a quotient or a negative power needs gcd(k, n) = 1:1/ResidueClass(3, 7)isResidueClass(5, 7), while1/ResidueClass(2, 4)stays unevaluated. Classes of two different moduli, a float and an infinity are never combined with a class: the expression stays unevaluated. - No number rule applies to a class. Canonical forms,
evaluate(),.N(),simplify(),Expand,Factorand the.add(),.mul(),.div()and.pow()methods fold an expression that holds a class only with the rules of the ring. Soc/candc − care never cancelled to the integers 1 and 0 when the classchas no inverse. This also holds for a symbol whose value holds a class.Solve,DandIntegratestay unevaluated for an expression that holds a class. - ℤ/nℤ.
QuotientRing(Integers, n)listsResidueClass(0, n)…ResidueClass(n−1, n), without building them all for a largen. Its element type isvalue.Element(ResidueClass(7, 5), ℤ/5ℤ)isTrue, a class of another modulus isFalse, and an integer is not an element:Element(7, ℤ/5ℤ)isFalse. - LaTeX.
\overline{k}_{n}isResidueClass(k, n)whenkandnare integer literals, and a class is written back the same way. A bare\overline{7}is stillConjugate(7), and\overline{z}_1is still the conjugate of z₁.Subscript(Conjugate(3), 5)is now written{\overline{3}}_{5}, so that it reads back as written. Modis still the remainder:Mod(7, 3)is1.
- Canonical form.
-
Contour integration by the residue theorem.
ce.contourIntegrate(f, z, contour)and theContourIntegrateoperator integrate over a circle, a rectangle or a simple polygon (CircleContour,RectangleContour,PolygonContour), and\oint_{|z-c|=r}now evaluates. The report gives each pole with its location, order, residue and leading Laurent coefficient, the sum of the residues and the value. Supported integrands are rational functions, entire numerators, products ofsin/cosof a real affine argument in the denominator, andP(z)·exp(c/(z−a))with an essential singularity. An input that cannot be certified exactly stays unevaluated, and a pole on the contour givesIndeterminate. A call has a step budget, so the point where it gives up does not depend on the machine. Seedocs/CONTOUR-INTEGRATION.md. Contributed in PR #410. -
Real integrals by residues.
Integrategives an exact value over(−∞, ∞)for a rational function, or a rational function timescos,sinorexp(i·a·x); over[0, ∞)for such an even integrand; and over[0, 2π]or[0, π]for a rational function ofsin xandcos x. For example,∫ sin(x)/x dxover(−∞, ∞)isπ, and∫₀^{2π} dx/(2 + cos x)is2π/√3..N()uses the exact value: before,.N()of the sinc integral was7.1 ± 7.3. A Cauchy principal value is never assumed;RealLineContour(True)asks for one. -
A pole on the path of a real integral gives
+∞,−∞or no value.∫ dx/x²over(−∞, ∞)was0and is now+∞, and∫₀^{2π} dx/(1 + cos x)is+∞.∫ (1/x² − 1/(x−1)²) dxover(−∞, ∞)was0and is nowIndeterminate: the integrand tends to+∞at 0 and to−∞at 1.
Improvements
- More accurate
erf,erfcanderfiin doubles. The machine kernels of the error functions, which compiled JavaScript also uses, are now W. J. Cody's rational approximations (erfithrough the Dawson integral), evaluated with compensated arithmetic. Measured against mpmath on 7,100 points in [0, 30], the largest error went from 15 to 1 unit in the last place forerf, from 889 to 2 forerfc, and from 502 to 2 forerfi(3 next to its overflow at 26.71). They take about the same time per call as before. Erfiof a large argument above machine precision finishes.Erfi(1000).N()at 50 digits did not finish; the big-decimal kernel now uses the asymptotic series once it is accurate to the working precision.- Complex
ErfandSinIntegralnear 0. The relative error ofErf(10^{-8}(1 + i)).N()was10⁻⁸, and ofSinIntegralat10⁻⁶(1 + i)10⁻¹⁰: the kernels subtracted two values close to each other. Both now use their Maclaurin series for|z| ≤ 1, with a relative error below2·2⁻⁵². SinhIntegralandCoshIntegralnear 0.SinhIntegral(10^{-10}).N()was9.99982e-11, it is now1e-10. The real kernels ofShiandChiuse their Maclaurin series for|x| ≤ 2.- More accurate
SinIntegral,CosIntegral,SinhIntegral,CoshIntegralandExpIntegralEiin doubles, mostly as fast or faster. Measured against mpmath on more than 30,000 points from 0.12 to 10³⁰⁰, the largest error is now 0.74 unit in the last place forSinIntegral(21 before), 1.5 forCosIntegralaway from its zeros (73 before), and forSinhIntegral,CoshIntegralandExpIntegralEi1 for2 ≤ x < 50and 2 above (11 before);CoshIntegralbelow 2 is within 1.6 away from its zero (4 before).CosIntegralgave wrong values past 10¹⁵, andSinhIntegral,CoshIntegralandExpIntegralEigave+∞from 716; they are finite up to their overflow (717.05 and 716.36).SinIntegralandCosIntegralare up to 3 times faster for2 ≤ x ≤ 16;CoshIntegralbelow 2 is about 2 times slower. Compiled JavaScript uses the same kernels. .N()of a sum or product with a very large exponent gap finishes.(10^{10^9} + 1).N(),(10^{10^9}·i).N()andErf(10^{10^9}·i).N()did not finish at a precision of 50 digits, or failed with "Maximum BigInt size exceeded": the exact sum aligned the operands to a billion-digit significand. Now they take a few milliseconds. In the arithmetic of approximate values above machine precision, an operand that is more than 10,000 digits (or 4 times the precision) below the other is replaced by a value of the same sign just under that limit, which rounds the same way at every precision up to that limit. Exact arithmetic is not changed.
Issues Resolved
-
Limitat infinity of a complex-valued function (#396). The limit at±∞chose between+∞and−∞from the sign of a term, and a complex value has no sign:\lim_{t\to\infty} e^{it}was+∞,\lim_{t\to\infty} \operatorname{erf}(it)was1and\lim_{t\to\infty} e^{it}/twas+∞. A function with a non-real constant is now resolved through its real part and its imaginary part:e^{it}/tis0,(1+it)/tisi,(2t+i)/(t-3i)is2, and a limit that does not exist (e^{it}) or is not finite (i·t,Erf(it)) stays unevaluated.Gammagoes to0along a vertical line:Limit(t ↦ Gamma(i·t), ∞)is0, andAbs,Real,ImaginaryandConjugatecarry such a limit.Limit(t ↦ Gamma(t), ∞)is+∞; it was unevaluated..N()andNLimitextrapolate the real part and the imaginary part of a complex-valued function separately; they gaveNaN. -
Limitat infinity with a coefficient of unknown sign stays unevaluated.Limit(t ↦ a·t, ∞)andLimit(t ↦ a·eᵗ/t, ∞)were+∞for a symbolawith no assumption. The answer depends on the sign ofa, so the limit is now unevaluated;-2tandπ·tare unchanged. A non-integer power of a base that goes to−∞is also unevaluated:Limit(t ↦ (−t)^{3/2}, ∞)was+∞, and the value is not real. An integer power takes its sign from the parity ((−t)³is−∞). -
NLimitat−∞samples negative arguments.NLimit(t ↦ arctan t, −∞)wasπ/2; it is−π/2. -
No double superscript for a half-integer power deep in an expression (#345). Inside two lists,
2^{(1+x)/2}was written2^{1/2}^{1+x}, which TeX rejects. It is now2^{(1+x)/2}. At the top level it is still\sqrt{2}^{1+x}. -
A symbol whose stored value is impure is read one time for each place it appears under
.N(). Withrassigned an unevaluated random draw,.N()of2r,r + π,r/3,r − 1and2r + 1evaluated the stored value two times, and the result used the second draw. A stored value that reads an impure symbol (s := r + 1) or calls a function with an impure body is treated the same way. The exact evaluations that.N()does again near a jump (Round,Floor,Equal, a lazy rounding broadcast) and after an overflow (aSumorProductterm of±∞, a factor of0) no longer evaluate such a symbol again. -
The memos of functions, lazy collections and stored values follow what a sequence reads. With
g := k ↦ B_kandB_n = B_{n−1} + x,g(2)kept its first value afterxchanged. Now it gives the new value. A term whose read was stopped (more than 100,000 terms, or a recursion that is too deep) is not kept by the memo of a function, of a lazy collection (Map,Tabulate) or of a stored value: a later read gives the term. -
A fused
Mapno longer disposes the definitions of the scope of its function. The fast route ofMapevaluated each element in a frame that was the scope of the function itself, and the end of the frame disposed every definition of that scope: for a function written at the top level, every global definition, after each element. A disposed definition loses its assumptions at a checkpoint restore, and every memo that reads a global symbol was made outdated by each suchMap. -
The check that an evaluation has no side effects has one implementation, which follows stored values, the bodies of user functions and the definitions of sequences. It no longer reads every element of a large numeric list made with
ce.list(). -
Counting set operations.
Count(Intersection(Integers, Set(1, 2)))wasPositiveInfinity; it is2, in both operand orders.Intersection(Integers, Set(1, 2))now evaluates toSet(1, 2).Count(SymmetricDifference({1, 2, 3}, {2, 3, 4}))was1; it is2.Count(Union(A, S)),Count(Intersection(A, S))andCount(SetMinus(A, S))of sets with no value were0; they stay unevaluated.Intersection({1, 2, x}, Integers)evaluated toSet(1, 2), droppingx, althoughxmay be an integer. A set operation now stays unevaluated when the membership of an element in an infinite set is undecided, in both operand orders. A set operation with such a membership is not walked by other operators, so it does not give a partial answer.- A walk of
Intersection(Integers, Set(1, 2)), and a count of a union with an operand such asIntersection(Integers, Interval(0, 3)), never ended. They now end. Countof an intersection of two large ranges no longer copies their elements.
-
A dictionary's MathJSON keeps its values. An exact rational value was written as a machine number (
1/2read back as0.5), and a function value as a bare array (Sqrt(2)read back as a list). -
simplify()of a list quotient or difference keeps the list.[x]/[x]simplified to1and[x] − [x]to0. They now give[1]and[0], asevaluate()does. -
The identities of
loadIdentities()that contain an angle apply only in radians. They are written for radians, but they applied in every angular unit. Withce.angularUnit = 'deg'and the identities loaded,Sin(Pi).simplify()was0, butsin(π°)is0.0548…, the value of.N().Argument(i)wasπ/2, not90, andIm(ln z)becameArgument(z), an angle in degrees. Now an identity with a trigonometric or inverse trigonometric function,ArgumentorAbsArgin its sides or its conditions applies only whence.angularUnitis'rad'. The unit is read each time the identity is tried, so a change of the unit after the load is taken into account. The other identities, which include the identities of the hyperbolic functions, apply in every unit. -
The inverse of a logarithm with a base.
\ln_3^{-1}(x)wasexp(x): the base 3 was dropped. In the lenient grammar,ln_3^-1(x)was1/log₃(x), a reciprocal, andlog_3^-1(x)wasApply(InverseFunction(Log), x, 3). All three are now3^x, the inverse oflog₃, as\log_3^{-1}(x)already was. The lenient readings reportambiguous-inverse-function. -
The arguments of a logarithm with a base are kept, in both grammars. The arguments after the first were dropped with no error:
ln_3(x, y),\ln_3(x, y)andlog_3(x, y)wereLog(x, 3). In the lenient grammar,log_10(x, y)wasLog(x, y), the logarithm in basey. They are nowLog(x, 3, y)andLog(x, 10, y), and the canonical form reportsyas an unexpected argument. -
A run of digits keeps its digits in the lenient grammar. A long run lost precision:
π_123456789012345678901wasSubscript(Pi, 123456789012345680000),(x)_9007199254740993had the subscript9007199254740992, and a longer run gaveInfinity. The same was true of a base oflog(log_12345678901234567890(x)) and of an unbraced exponent (x^123456789012345678901). A run that a JavaScript number cannot hold exactly is now a number string, with all its digits. A leading zero was dropped:π_01wasπ_1,[1,2,3]_01wasAt([1,2,3], 1),1_000wasSubscript(1, 0),x^2_01wasx_1^2andtan_01xwastan_1(x). After_on a base that is not a symbol, a run that starts with0is now the one token0, as in the strict grammar ([1,2,3]_01is[1,2,3]_0·1), and the reading reportsambiguous-implicit-subscript(orambiguous-function-subscripton a function name). A symbol keeps the text:π_01isPi_01andx^2_01isx_01^2. -
A subscript on a symbol in parentheses is a compound symbol, in both grammars.
(x)_0wasSubscript(x, 0)with the typesymbol, so a product with it was aTuple:(x)_0 ywas(Subscript(x, 0), y), and(x)_0 \cdot 1was a type error. The same was true of a base that becomes a symbol:x2_1 + y(Subscript(x_2, 1) + y) was a type error.(x)_0is now the symbolx_0, asx_0is, andx2_1isx_2_1, so(x)_0 yis the productx_0·y. A base that is not a symbol keeps its subscript:(x+1)_0isSubscript(x + 1, 0). -
ShapeandRankof a lazy collection.Shape(Range(1, 3))was()andRank(Range(1, 3))was0, the answer for a scalar. A finite indexed collection now gives the answer of the list with the same elements:Shape(Range(1, 3))is(3)andRank(Range(1, 3))is1. A set, a dictionary, an infinite collection, and a symbol with a collection type and no value stay unevaluated. CompiledShapeandRankdecline to compile for an operand whose type is a set, a dictionary, a tuple or a string, where the compiled answer was different from the evaluated one. -
Solveof a list of equations.- A list of several plain expressions is a system of equations, each equal
to 0, as the
=spelling is:Solve([x + y - 1, x - y], [x, y])is[(1/2, 1/2)]. It stayed unevaluated. - A list of several equations in one unknown gives the roots that all the
equations share:
Solve([x = 1, x^2 = 1], x)is[1]andSolve([\sin(2x) = 0, \cos(x) = -1], x)is[π, -π]. Both were[]. For periodic equations, when no principal root is common,Solvestays unevaluated instead of saying that there is no solution. - A list of inequalities with no equation stays unevaluated. Its solution
set is a region:
Solve([x < 1, y > 0], [x, y])was[(1, 0)], a point that does not satisfyx < 1. - A domain given as an argument applies to a list of equations:
Solve([x + y = 3, x - y = 1], x ∈ 0..5, y ∈ 0..5)is[(2, 1)]. It was[]. Solve([x = 1, 2x = 2], x)threw aTypeError(roots is not iterable). It is now[1].expr.solve()of a system of linear inequalities givesnullfor an unbounded region, as documented. It gave a corner point of the region.
- A list of several plain expressions is a system of equations, each equal
to 0, as the
-
\barover a number.\bar{7}was anunexpected-commanderror. It now parses toConjugate(7), as\overline{7}does, and0.\bar{3}is the repeating decimal1/3, as0.\overline{3}is.\bar{x}is stillMean(x). -
#405 The error of a numeric integral covers its true error.
- A semi-infinite oscillatory integral took as its error the difference of the
last two accelerated values of its lobe sums. When the lobes have a part of
one sign (the lobes of an integrand with two frequencies whose ratio is
rational repeat a pattern), the accelerated values move slowly toward the
integral and two of them agree long before they reach it:
∫₀^∞ sin(t/2)·cos 2t/t dt = 0was-0.00000000681 ± 0.00000000089, and∫₀^∞ sin t·cos 3t/t dt = 0was0.00001183017 ± 0.00000000075. The error now also covers the values from the last four lobe sums and from the first half of the lobe sums, and the Levin u-transform of the sums of one period of lobes gives a second value. The two integrals are now0.0000000000000 ± 0.0000000000059and0.0000000033 ± 0.0000000071, and∫₀^∞ sin t·(2 + cos t)/t dt = 5π/4, which was4.037 ± 0.065, is3.926990818 ± 0.000000010. - When the lobes shrink but no method finds the integral, the result is
NaN, not a Monte-Carlo value:∫₀^∞ sin t·cos(√2·t)/t dt = 0was0.072 ± 0.018. - The lobes are integrated by Gauss–Kronrod instead of adaptive Simpson, and
the values are more accurate with fewer evaluations:
∫₀^∞ sin t/t dtwas4.2e-11fromπ/2with an error of2.5e-10, after 9634 evaluations. It is now1.570796326795 ± 0.000000000012,7e-16fromπ/2, after 2387 evaluations. - The error of a lobe whose panels are not resolved (a singularity inside the
lobe) is added to the error of the result.
∫₀^∞ sin t/(t − 5)² dt, which diverges, was-217023352.094 ± 0.0013. It is nowNaN. A divergent integral whose lobes have a part of one sign that decreases too slowly to have a sum (∫₁^∞ sin t·(1 + sin t)/√t dt, which was-0.14277 ± 0.000069) is alsoNaN. - A lobe with an integrable singularity is integrated again in two parts, with
the singular point at an end of each part, and a lobe ends at a pole of the
integrand, not
10⁻¹⁴before it.∫₀^∞ sin t/√|t − 5| dtwas-1.96685088 ± 0.00000000066,2.7e-4from the integral (-1.9671167915329387). It is now-1.967116791534 ± 0.000000000046.∫₀^∞ sin t/|t − π|^1.5 dtwas1.25699002 ± 0.0000000008, and is now0.144840978411 ± 0.000000000065(the integral is0.14484097841008…).∫₀^∞ sin t·ln|t − 5|/t dtis now2.929478525094 ± 0.000000000014(the integral is2.92947852509409…). ∫₀^∞ sin t·cos 6t/√t dtand other integrals whose lobes repeat a pattern with several lobes of the same width wereNaN. They now have a value:-0.04339550 ± 0.00000011(the integral is-0.04339545…).- The error of the adaptive Gauss–Kronrod quadrature is at least four units in
the last place of the sum of the magnitudes of its panels: the integrand is
evaluated in doubles.
∫₁² ln³t/(t − 1) dtwas0.1425141979357109345283 ± 0.0000000000000000000031, with a true error of1.9e-17, and is now0.14251419793571093 ± 0.00000000000000013.
- A semi-infinite oscillatory integral took as its error the difference of the
last two accelerated values of its lobe sums. When the lobes have a part of
one sign (the lobes of an integrand with two frequencies whose ratio is
rational repeat a pattern), the accelerated values move slowly toward the
integral and two of them agree long before they reach it:
-
#409 Exact values of
Arcosh,Arcoth,EllipticKandEllipticEat 0. Underevaluate(),Arcosh(0)andArcoth(0)are nowiπ/2, andEllipticK(0)andEllipticE(0)are nowπ/2. They stayed unevaluated, and only.N()gave the value. More generally,Arcosh(x)for an exactxin[−1, 1]isi·Arccos(x)whenArccos(x)is a special angle:Arcosh(1/2)isiπ/3,Arcosh(−1)isiπ,Arcosh(√3/2)isiπ/6(they stayed unevaluated).simplify()gives the same values forArcoshandArcoth. A float argument (Arcosh(0.0),EllipticK(0.0)) still gives a float. -
#409 Inverse trigonometric functions of other spellings of the special values. The table of special angles holds one spelling of each value, for example
\frac{\sqrt{10-2\sqrt5}}{4}forsin(π/5). A value written another way stayed unevaluated. NowArcsin,Arccos,Arctan,Arccot,ArcsecandArccscalso find the special angle when the square of the argument is exactly the square of a value of the table:Arcsin(√2/4·√(5 − √5))was unevaluated, it is nowπ/5;Arcsin(√(10 + 2√5)/4)is2π/5;Arctan(√(1 − 2√5/5))isπ/10;Arcsin(√(2 − √3)/2)isπ/12;Arccos(−√2/4·√(5 − √5))is7π/10. The test is exact: a value that is only near a special value (Arccos((1 + √5)/4 + 10⁻³⁰)) stays unevaluated. -
#409 A term whose radical coefficients cancel is dropped from a sum.
(√2/4·x).sub(√2/4·x)was the product0·x, not0. This also made the difference of two equal radical expressions such as√2/4·√(5 − √5)read as not zero. -
QuotientRing(Integers, n)is a finite collection ofnresidue classes, not a set of integers (#399, contributed by enumeratio).\mathbb{Z}/5\mathbb{Z}and\mathbb{Z}_5had no count (countwasundefined, andCount(…)stayed unevaluated), and their type wasset<integer>, although 7 and 2 are the same element of ℤ/5ℤ. For a positive integer literaln, the collection now has the countn, is finite and is not empty, andCountevaluates:Count(\mathbb{Z}/5\mathbb{Z})is5. A symbolic, zero or negative modulus, or a base other thanIntegers, stays inert. The type isset<value>: its elements are the classesResidueClass(k, n)(see New Features). -
A finite collection whose elements cannot be computed no longer reads as empty.
Linspace(a, 1, 3)with a symbolicahas the count 3 but no elements that can be computed, and so does ℤ/5ℤ. Many operators checked only that a collection was finite and then walked its elements, so they read it as empty and gave a wrong answer with no diagnostic:Union(Linspace(a, 1, 3), {1})wasSet(1),Unique(…)was[],Tally(…)was([], []),Intersection(…, {1})wasEmptySet,SetMinus(Set(1), …)wasSet(1),First(…)wasNaN,Flatten([[1], Linspace(a, 1, 3)])was[1],Equal(Linspace(a, 1, 3), [1, 2, 3])wasTrue, andSort(…)gave aninternal-error. These operators now stay unevaluated, andEqual,IdenticallyEqualand.isEqual()are undecided. An empty collection still gives a definite answer:Union(Linspace(a, 1, 0), Set(1))isSet(1). An element-wise function of such a collection gives the lazy form that it already gives forRange(1, n):Sin(Linspace(a, 1, 3))isMap((_) => sin(_), Linspace(a, 1, 3)). -
EvaluateAtwith one operand no longer throws.EvaluateAt(x^2)threw aTypeError. It now stays unevaluated. -
BesselYandBesselJof a real argument are accurate.BesselYcomputedY₀andY₁from a series that loses aboutx/2.3digits, then recurred:BesselY(12, 40).N()was-1.83instead of-0.0236, andBesselY(30, 200).N()was-5.5·10⁷⁶instead of-0.0224.BesselJstarted its backward recurrence too low for a large argument:BesselJ(12, 95)had a relative error of10⁻⁸, andBesselJ(80, 80)was1.98instead of0.104.Y₀andY₁now come from Steed's continued fractions for2 < x ≤ 30, and the recurrence ofJstarts high enough. Against mpmath at orders 0 to 80 andxup to 240, both are within1·10⁻¹⁴of the size of the function. The asymptotic expansions also reduce the phasex − (2n+1)π/4exactly (BesselY(0, 1000)had an error of3·10⁻¹⁴). Compiled JavaScript uses the same kernels. -
FresnelSandFresnelCof a real argument between 1.6 and 4.5 are accurate to7·10⁻¹⁶. One coefficient of the auxiliary function was wrong: the relative error was up to4.5·10⁻¹²(FresnelS(2.5)was0.61918175581901, it is0.6191817558195929). The GLSL and WGSL lowerings had the same coefficient (with no visible effect in 32-bit floats); it is corrected there too. -
LambertWnext to-1/eis accurate. The real kernels returned exactly-1for an argument within10⁻¹⁵of-1/e, also above machine precision: at 50 digits,LambertW(-1/e + 10^{-20})had an error of2·10⁻¹⁰. They now use the series about the branch point, with1/eheld to more than double precision, and above machine precision Halley's iteration with extra digits. -
In Epsil,
f(x) := bodydefinesfasf(x) = bodydoes (#400, contributed by enumeratio). As a statement,f(x) := x^2 + aparsed toAssign(f(x), x^2 + a), which evaluates to itself with no diagnostic and leavesfundefined, sof(3)wasf(3). It now parses toDefineFunction(f, Function(x^2 + a, x)), andf(3)isa + 9. The two spellings are synonyms for every function head: typed, rest and wildcard parameters, a return type, a definition inside a block, and literal-pattern clauses. Sof(0) := 1followed byf(n) := n*f(n-1)defines the factorial, andf(5)is120.Assignitself is unchanged. -
The GLSL and WGSL targets compile the prime of a function.
\sin'(t)isApply(Derivative(Sin, 1), t), and the shader targets declined it ("Could not compileApply"), also inside a function body (h(x) := x^3 + \sin x, thenf := t \mapsto h'(t)andf(x)). A shader has no function values, so the argument is now substituted into the closed form of the derivative:\sin'(t)compiles tocos(t), andftofloat _fn_f(float t) { return 3.0 * (t * t) + cos(t); }. The interval target uses the same closed form. A derivative with no closed form, or with a closed form too large to write out, still declines, with a message that says so. -
The GLSL and WGSL targets compile a
Mapwhose source has a length known at compile time.Min(Map(k \mapsto k^2, x + [0, 4, 2]))declined on the shader targets ("Could not compileMap"). TheMapis now written out element by element:min(min((x * x), _gpu_pow2(x + 4.0)), _gpu_pow2(x + 2.0)). This applies underMin,Max,SumandProduct, and aMapalone is a vector value (vec3(…)in GLSL,vec3f(…)in WGSL). The source can be a literal list, a literal range, or a list built from them by element-wise arithmetic, with up to 64 elements. AMapover a list of unknown length still declines. -
The norm of a point whose component is a list compiles on the
interval-js,glslandwgsltargets.\left|(x+[\frac12,1],y)\right|is one point per element of the list, so its norm is one number per element. Thejavascripttarget compiled it to the list of norms; the other targets declined it ("the interval target requires a fixed-arity point operand", "component 1 is collection-valued … scalar components only"). They now compile it as√(c₁² + … + cₙ²), element by element, with the same code as the norm written by hand: onglsl,sqrt(_gpu_pow2_v2(x + vec2(0.5, 1.0)) + (y * y)), and oninterval-jsan array of one enclosure per element. This applies when the list components have one length that is known at compile time. When the lists have different lengths, or a length known only at run time,interval-jszips the points to the length of the shortest list, asjavascriptdoes (|(x+[1,2],y+[3,4,5])|is two norms), andglslandwgsldecline with a message that gives the lengths. A point of scalars compiles as before (length(vec2(x, y)),_IA.hypot(_.x, _.y)). Oninterval-js,Absof aPointListof scalars also compiles now; it declined before. -
The compiled norm of a point with a restricted list component is one number per element. A restricted list
L\{c\}has the typelist<…> | missing, and itsmissingpart hid the list from the tests that decide whether a coordinate is a list. On thejavascripttarget,|(L\{0<t\}, y)|withL = [1, 2, 3]andy = 3was√23, one norm of the vector(1, 2, 3, 3). It is now[√10, √13, √18], as in the interpreter, andNaNwhen the condition fails.Hypotof such a point is corrected in the same way. TheAbsof a restricted list of points,|([1,2]\{0<t\}, y)|, was[[1, 3], [2, 3]](the absolute value of each coordinate). It is now[√10, √13], andundefined(the interpreter'sMissing) when the condition fails;interval-jsnow compiles it too. The type of such a norm islist<number> | number, notnumber. -
Arctan2has a symbolic derivative.D(Arctan2(y, x), x)is-y / (x^2 + y^2)andD(Arctan2(y, x), y)isx / (x^2 + y^2), with the chain rule for composite arguments (D(Arctan2(t^2, cos t), t)has a closed form). The multi-index form also has a closed form:Apply(Derivative(Arctan2, 1, 0), y, x)isx / (x^2 + y^2). In a non-radian angular unit the partial derivatives are divided by the unit factor, as forArctan: in degree mode,D(Arctan2(y, x), x)is-180y / (π(x^2 + y^2)). Before, the derivative stayedApply(Derivative("Arctan2", 0, 1), y, x): the JavaScript target compiled it to a numeric derivative, and theinterval-js, GLSL and WGSL targets did not compile it. The closed forms now compile on all targets. -
Derivative(F, k₁, …, kₙ)of a library operator has a closed form. The multi-index form applies the same rules asD:Apply(Derivative(Log, 0, 1), x, b)is-ln(x) / (b * ln(b)^2),Apply(Derivative(Power, 1, 0), x, n)isn * x^(n - 1)andApply(Derivative(Mod, 0, 1), x, m)is-floor(x / m). Before, these stayed unevaluated althoughDof the same expression had a closed form. A partial derivative with no closed form (BesselJin its order) stays unevaluated. EvaluatingDerivative(F, k₁, …, kₙ)no longer declares the symbols_1,_2, … in the caller's scope. -
DofModin the modulus, ofRoundwith a digit count and of the upper incomplete gamma function.D(Mod(x, m), m)is-floor(x / m), and when both operands depend on the variable,D(Mod(u, c), v)isu' - floor(u / c) * c'.D(Round(x, 2), x)is0, as forRound(x).D(Gamma(s, z), z)is-(e^(-z) * z^(s - 1)); the partial derivative inshas no closed form and stays symbolic. Before, these derivatives stayed unevaluated. -
A univariate
Derivativeof a two-argument operator with aderivativekey stays symbolic.Derivative(F)for a user operatorF(x, y)declared with aderivativekey threw "Not canonical". It now stays unevaluated, asDerivative(Arctan2)does. -
The L∞ norm of a point with list components is one maximum per point. With
L = [1, -5, 3]andK = [-4, 2, 1],Norm((L, K), "Infinity")is[4, 5, 3], as the compiled JavaScript code already answered and as the other orders give one norm per point. Before, the interpreter left it unevaluated. -
The string order
"Infinity"ofNormcompiles to JavaScript.Norm(v, "Infinity")compiles asNorm(v, +∞), for a vector, a matrix, a point, a point with list components and a list of points. Before, it did not compile ("the "Infinity" norm has no compiled form"), while the interpreter answered it. The GLSL, WGSL and interval targets compile only the default order, and refuse both spellings with the same message. -
The GLSL and WGSL targets check the arguments of an applied function literal against the declared parameter types. An argument whose type does not match the declared type of its parameter is an
incompatible-typeerror underevaluate():Apply((u: real, w: list<real>) ↦ u + Length(w), V, V)forV: list<real^2>. The shader targets now decline such an application, and the message names the parameter and the two types. (A mismatch that the types already prove, as forVhere, now makes the application invalid when it is boxed, so it is declined as an invalid expression.) A parameter with no declared type is not checked. A tuple at a parameter declared as a scalar is not a mismatch: the application maps over its components (see "A parameter declared as a scalar maps over a tuple argument" under Behavior Changes). -
The GLSL and WGSL targets compile the square of a vector. A structural
Square([1, x])declined because the scalar helper_gpu_pow2was chosen for avec2. It now compiles to_gpu_pow2_v2(vec2(1.0, x)), as[1, x]^2does. -
An applied derivative of a function of several arguments compiles on all targets.
Apply(Derivative(F, k₁, …, kₙ), a₁, …, aₙ)compiles from the closed form of the derivative on theinterval-js, GLSL and WGSL targets:Apply(Derivative(Power, 0, 1), x, n)islog(x) * pow(x, n)in GLSL, and the partial derivatives ofArctan2and of a user functionh(u, w)compile in the same way, also in degree mode. Before, these targets declined the application ("only a function-literal callee compiles on the interval target"); the JavaScript target already compiled the closed form. A derivative with no closed form (BesselJin its order) is still declined. A derivative of a user function of several arguments whose body is large is declined before it is differentiated, with the size limit that a function of one argument has (the size of the body and the total orderk₁ + … + kₙ): the mixed derivative(2, 1)of a four-deep nested radical in two variables takes about 24 seconds to compute and has a closed form of more than 20,000 nodes. The decline messages now give the true reason on all four targets: the derivative has no closed form, its closed form is too large to write out, or the number of arguments is not the number of parameters of the closed form. Before, the GLSL and WGSL targets said "no closed form small enough to write out" in all three cases, and the JavaScript target said that theDerivativecompile handler had no lowering. -
The JavaScript target declines an
Applywith the wrong number of arguments. The interpreter curries((a, b) ↦ a + b)(x)into a function of one argument and rejects(a ↦ a + 1)(x, y). The compiled code answeredNaNfor the first andx + 1for the second. The application is now declined, as on the other targets, and the compilation falls back to the interpreter. -
The JavaScript target declines an
Applywhose argument does not match a declared parameter type. In a strict engine, an argument whose type does not match the declared type of its parameter (a string, a boolean or a set atu: real, or an element of such a type in a mapped list) is anincompatible-typeerror underevaluate(). The compiled code did not make this check. The application is now declined with the reason, as on the GLSL and WGSL targets, which use the same check. A tuple at a parameter declared as a scalar is not a mismatch: the application maps over its components (see Behavior Changes). -
The text form of a power keeps the parentheses of its base.
ce.box(['Power', ['Power', 't', 2], ['Rational', -3, 10]]).toString()wast^2^(-3/10), which reads ast^(2^(-3/10)). It is now(t^2)^(-3/10). The same fix wraps other bases that are not one unit: a rational or radical number ((3/4)^x, not3/4^x), an imaginary number or a number in exponent notation ((2i)^x,(1e+30)^x), a quantity ((5 m)^2, not5 m^2), a measurement, a sum, an integral, a limit and a restriction ((x {0 < x})^2). A power with the exponent1that is not in canonical form keeps the parentheses of its base in a product:(a + b) * c, nota + b * c. The LaTeX form was already correct. -
Evaluating a
Derivativedeclares nothing in the caller's scope.Derivative(Sin)evaluates to(x) => cos(x). To make it, the handler declared the symbolsxand_in the caller's scope, with the typenumber. A later use ofxwas then checked against that type:x && Truegave anincompatible-typeerror. The multi-index formDerivative(Arctan2, 1, 0)declared its parametersx_1,x_2in the same way. The symbols are now declared in a scope that is discarded, and a caller binding of the same names (such asx_1declared as a string) does not change the result. -
A derivative with respect to a function stays symbolic.
D(f(x), f)was0, andDerivative(Apply, 1, 0)evaluated to(x_1, x_2) => 0: the rules took the applicationf(x)as a constant, because the variable was its function and not one of its arguments. There is no closed form, so they now stay unevaluated. -
Derivative(Exp)has noln(e)factor. It evaluated to(x) => ln(e) * e^x. It is now(x) => e^x. -
A compiled call of a named function checks the declared parameter type. With
kassigned(u: real) ↦ 2u, a call whose argument does not matchreal(a string, a boolean, a set) is anincompatible-typeerror underevaluate(). Such a call is now declined with the reason on the JavaScript, GLSL, WGSL and interval targets, with the check that the compiledApplyof a function literal uses. A tuple argument maps over its components instead (see Behavior Changes). -
The cells of
Applyover a list are typed with the declared parameter type.Apply((u: integer) ↦ u + 1, [1.5, 2])was typedvector<real^2>, from the type of the elements, while the named calls were typedvector<integer^2>. The three agree now. Each failing cell of such a map carries the "while applying 'Apply' element-wise" note, as on the named routes. -
The JavaScript target binds a tuple whole at a parameter with no declared type. The compiled code mapped a function over any array at run time, and a point is an array, so
Apply(u ↦ 7, (a, b))gave[7, 7]andg(P),Apply(u ↦ 7, P)andMap(g, P)for a list of pointsPgave[[7, 7], [7, 7]], whereevaluate()gives7and[7, 7]. They now give the same values asevaluate(). -
The JavaScript target compiles
h(P)for a list of pointsP. Withh := (u: real) ↦ 2u, the call was declined as "a broadcastable head over a possibly list-valued operand", whilef(P)forfdeclaredfunctionandApplyof the literal compiled. It now compiles to the value ofevaluate(),[[2, 4], [6, 8]]forP = [(1, 2), (3, 4)]. -
A block that declares a function, assigns it, and calls it now compiles.
["Block", ["Declare", "k", "'function'"], ["Assign", "k", ["Function", ["Multiply", 2, "u"], "u"]], ["k", "x"]]— also the parse ofk(u) \coloneq 2u; k(x)— evaluated to6forx = 3, but the JavaScript and interval targets declined it withUnknown operator `k`. The call now compiles the same way as forconst k = (u) => 2u: with the run-time broadcast over a list argument, the arity check, recursion, and the fold of a call with constant arguments. AnAssignwith noDeclarecompiles too. A call of a block-local function that is assigned again now uses the function the interpreter uses at that point:let h = (k) => k + 1; h := (k) => k + 2; h(3)compiled to4(the old function), where the interpreter gives5, and a reassignment inside a loop body gave4for13. On the JavaScript target such a call (after a second assignment, an assignment inside a branch or a loop body, or from a function body) reads the variable at run time and is not folded. It declines with the reason when the assigned functions do not have the same signature, or when the name is also assigned a value that is not a function. The interval target declines a call of a function that is assigned more than once, with the reason. The arity decline now states the interpreter's behaviour correctly: a call with fewer arguments than parameters is a partial application (a function value), and a call with more is an error. -
The index form of a partial derivative costs no more than nested
D.Apply(Derivative(Arctan2, 3, 3), y, x)took about 2.2 times as long as the same derivative written as six nestedD. The index form now evaluates that nestedDover fresh symbols and gives the same expression. The time went to deriving the types of the intermediate expressions, not to the differentiation rules. The same evaluation runs for.N()of an applied partial derivative. -
A partial derivative is computed once across evaluation points.
Apply(Derivative(f, k₁, …, kₙ), x, y).N()sampled at many points, as a plot does, computed the partial derivative again at each point (about 0.5 s at each point forDerivative(Arctan2, 3, 3)). The result is now cached per function and order vector, asDerivative(f, n)already was. A new definition of the function or of a function that it calls, an assignment to a free symbol of its body, and a change of the assumptions or of the configuration make the next request compute it again. -
A product whose text ends with a symbol keeps a
\timesbefore a parenthesized factor.Multiply(InvisibleOperator(2, v), Add(x, 1))serialized as2v(x+1), and a new engine read it as2times a call ofv(and declaredva function). The same happened with a nestedMultiply(0.03, v), with-2v, withuvbefore a tuple, and with the Desmos shapeV_{einColor}\cdot0.03v_{einO}\cdot\sum…, in the raw and structural forms. A bare symbol already got the sign. Now any factor whose text ends with a symbol name gets it:2v\times(x+1). A decimal number before a group that starts with a digit gets it too:Multiply(1.5, Delimiter(2))serialized as1.5(2), which reads as the repeating decimal1.5222…, and now serializes as1.5\times(2). -
A lazy collection that is the value of a block or of a function call reads the locals of that block or call. A
Mapor aFiltercomputes its elements when they are read, and it found its function and its sources by name at that time. After the block or the call had ended, its locals did not resolve:let r = do { let h = (k) => k + 1; map(h, [1, 2]) }stored[h(1), h(2)], and the same withfunction f() { … }; f(), with a local source list, or with an element of an infinite source read after the block. The compiled code gave[2, 3]. When the collection leaves the block or the call, each local that it reads is now replaced with its value, and the collection stays lazy: the value isMap(k ↦ k + 1, [1, 2]), which gives[2, 3]. A function or a source of an enclosing scope is not replaced, and the collection continues to read it by name. -
An assignment stores a lazy collection inside a list literal as the list of its elements. With
let r = [map(h, [1, 2])]followed byh = (k) => k * 10,r[1]gave[10, 20], while the compiled code and the list written with calls ([[h(1), h(2)]]) gave[2, 3]. An assignment already stored a finite lazy collection that reads a variable as the list of its elements; this now also applies to such a collection in a list or tuple literal, at every depth. -
A constant factor in the numerator of a quotient no longer stops an integral.
∫ a·sin(t)/t dtstayed unevaluated, while∫ a·(sin(t)/t) dtgavea·Si(t). The integrator now moves the factors of the numerator that do not depend on the variable out of the integral, as it already did for a product and for the denominator.∫ a·sin(t)/t dtis nowa·Si(t),∫₋₁¹ a·sin(t)/t dtisa·Si(1) − a·Si(−1), and∫ a·eᵗ/t dt,∫ a·cos(t)/t dt,∫ a/ln(t) dtand∫ −a·sin(t)/t dtalso close. This applies when the integration rules package is not loaded; with it, these integrals already closed. -
An integral with an
xˣfactor no longer runs without end.∫ eˣ·xˣ dxoverflowed the stack,∫ cos(x)·xˣ dxdid not finish in minutes, andDSolveofy'' + y = xˣdid not finish in 300 s. Integration by parts chose u = xˣ, whose derivativexˣ + ln(x)·xˣcontains xˣ again, and recursed with no progress. It also accepted a result that was a sum with an unevaluated integral in it, and the evaluation of that integral started the same integration by parts again. Integration by parts now declines both. These integrals stay unevaluated in less than a second, andy' + y = xˣgives the quadraturey = (c₁ + ∫ eˣ·xˣ dx)/eˣ. -
The derivative of
PolyLog(s, z)inzhas a closed form.D(PolyLog(s, z), z)stayedApply(Derivative("PolyLog", 0, 1), s, z), whichN()cannot evaluate. It is nowPolyLog(s − 1, z)/z, which reduces where the engine has a closed form: fors = 1it is1/(1 − z), and fors = 2it is−ln(1 − z)/z. The chain rule applies to the second argument:D(PolyLog(2, x²), x)is−2 ln(1 − x²)/x. The derivative inshas no closed form and staysApply(Derivative("PolyLog", 1, 0), s, z). -
simplify()cancels the terms of a sum that differ only by the sign of the argument of an odd or even function.simplify()moved the sign out ofsin(-x)andcos(-x), but not out of a negative number such assin(-1), and not for the other odd functions. Also, in a sum it did not simplify a hyperbolic or special function term. Sosin(1) - sin(-1),Si(1) - Si(-1),sinh(x) + sinh(-x)andErf(-x) + Erf(x)stayed as they were. Nowsimplify()uses f(-x) = -f(x) for the odd functionsSin,Tan,Cot,Csc,Sinh,Tanh,Coth,Csch,Arcsin,Arctan,Arccsc,Arsinh,Artanh,Arcsch,SinIntegral,SinhIntegral,Erf,Erfi,FresnelSandFresnelC, and f(-x) = f(x) for the even functionsCos,Sec,CoshandSech, when the argument is-xor a negative real number.sin(1) - sin(-1)simplifies to2sin(1),Si(1) - Si(-1)to2Si(1),cos(-2) - cos(2)andErf(-x) + Erf(x)to0, and the value of∫₋₁¹ a·sin(t)/t dtsimplifies to2a·Si(1).evaluate()does not change: it keepssin(-1)andSi(-1)as they are. -
The lenient grammar no longer drops a subscript on a function name. Only
logused its subscript (its base,log_2(8)). On another function name the subscript was dropped with no diagnostic:tan_1xwastan(x),ln_3(x)wasln(x)andsqrt _110thetawassqrt(theta). The subscript is now kept as the strict grammar keeps it:ln_3(x)isLog(x, 3), as\ln_3(x)is, andtan_1xisApply(Subscript(Tan, 1), x), as\tan_1 xis. A person can mean a name such astan_1, so the reading reports the new codeambiguous-function-subscript. -
A
^after a postfix operand is no longer dropped, in both grammars.x^g.5^was read as(x^g)^{0.5}andx^g.5^2as(x^g)^{0.5}·2: the parser read the two factors of the productx^g·0.5as a base and an exponent, and dropped the^. The same fault dropped an operator that had no right operand:g=^Δwasg^{(missing)}·Δ, with no=. A script after a postfix operator now applies to the whole result, as it does when the operand has no script:x^g.5^2is(x^g·0.5)², asx.5^2is(x·0.5)², andx^2!^3is(x^2!)^3(it was(x^2)^{(missing)}·3). A^with no exponent holds anError(x^g.5^,g=^Δ). The scripts after an unknown LaTeX command apply to its error, and the parse continues after them:\foo^2 + yisPower(Error, 2) + y, and\intop_0^1\sin x dxkeeps the integrand. -
More Unicode superscript characters are read as a superscript, in both grammars:
⁺,⁽,⁾and the modifier lettersˣandʸ.e⁻ˣwase^{-}(the postfixSuperminus) times the stringˣ; it is nowe^{-x}, ase⁻ⁿise^{-n}.x⁻⁽¹⁾andx⁽ⁿ⁺¹⁾were errors. -
simplify()no longer applies a library identity to a user function that has a library name. WithSinandCosdefined by the user ast ↦ t + 1,sin(0)simplified to0,sin(π/6)to1/2andsin(x)² + cos(x)²to1, whileevaluate()gave1,π/6 + 1and2(x + 1)². The same fault changed the value of a userLn,Log,Exp,Abs,Sqrt,Root,Tan,Csc,Cot,Sinh,Tanh,Arsinh,FactorialorBinomial:ln(1) → 0,√4 → 2,|−2| → 2,(n + 1)!/n! → n + 1.simplify()now replaces each call of a shadowed library name by an opaque symbol before the rules apply, and puts the calls back after. So no library identity applies to such a call, also when the call is in a sum or a product, and thefustrategy does the same. A rewrite that uses the calls only as values still applies (Sin(x)·Sin(x)isSin(x)²), and√x·(√x + 1)keeps its value with a userSqrt. Thetrigstrategy does not writecos θ + i·sin θwhere a user definition, a parameter or a block local shadowsCosorSin. The same is true for a function parameter or a block local with a library name:((Sin) ↦ Sin(0) + Sin(x))(t ↦ t + 1)simplified its body toSin(x). When no library name is shadowed,simplify()does not change. -
A user value of
Piis no longer read as π byevaluate(). Withce.declare('Pi', { value: 3 }),sin(π)evaluated to0,cos(π)to-1,sin(π/6)to1/2ande^{iπ}to-1, while.N()gavesin(3),cos(3)andsin(1/2)(ande^{iπ}.N()was also-1). The code that finds a multiple of π in an angle now recognizes only the library constant, so these aresin(3),cos(3),sin(1/2)andcos(3) + i·sin(3).An exact result that holds the library π is also no longer returned when a user binding gives
Pianother value. Its.N()was correct, but its MathJSON namesPi, and boxed again it had the user value: withPiequal to 3,Argument(-1)evaluated toπand boxed again to 3, andDegrees(30)gaveπ/6, which boxed again to0.5. These calls now stay symbolic underevaluate(), and.N()gives the same value as before:Argumentof a negative number or ofi,Arctan2when its exact value holds π,DegreesandDMSof an exact angle in radians, the values at an infinite argument ofArctan,Arctan2,Arccot,Arcsec,Artanh,Arsech,SinIntegralandCosIntegral,ComplexRootswhen a root is not a closed form (ComplexRoots(1, 4)is still[1, i, −1, −i]), ande^{iθ}in degrees, grads or turns. The degree conversion does not use the user value:30°is the angle π/6 whatever the namePiholds. Also,ce.Pi.mul(0)is0again: with a user value ofPiit was held as the product0·π, as for a variable with a value. WithPiequal to 3,LogGamma(1/2)evaluated toln(3)/2and now stays symbolic, as doEllipticK(0)andEllipticE(0)(π/2, which boxed again to 1.5),ClausenCl(2, Pi)was0(the value at π) and is nowClausenCl(2, 3), and a periodic equation solved over a bounded interval (Solve(sin(x) = 0, x ∈ [0, 10])) gave only some of its roots ([0]) and now stays unevaluated. ASolveover a domain also stays unevaluated when a root in the domain holds π:Solve(x·sin(x) = 0, x ∈ [0, 4])was[0, π]andSolve(sin(x) = 0, x ∈ ℝ)was[0, π], which boxed again to[0, 3]. A root list with no π is still given:Solve((x - 1)·sin(x) = 0, x ∈ [0.5, 2])is[1], and in degreesSolve(sin(x) = 0, x ∈ [0, 360])is[0, 180, 360](it stayed unevaluated). -
The order of the scripts no longer changes the reading of a subscript. A subscript before the superscript joins the symbol name and keeps its text:
x_{01}^2isx_01^2. A subscript after the superscript was read as a number, sox^2_{01}wasx_1^2, in both grammars. In the lenient grammar,\alpha^2_01andθ^2_01werealpha_0^2·1andtheta_0^2·1, while\alpha_01^2isalpha_01^2, andx^2_maxwasx_m^2·a·x. A subscript after a superscript now joins the symbol name as it does before it, on a letter, a Greek letter, a command (\operatorname{speed}), a constant and a primed symbol (x'^2_{01}is(x_01')^2), with each spelling of the prime marks:x^{\prime}^2_{01}(the spelling of the serializer) wasSubscript(x', 1)^2and is now(x_01')^2, as arex^\prime^2_{01}andx^{\doubleprime}^2_{01}(withx_01''). It reports the diagnostics of the other order. The same rules apply: a base that evaluates its subscript (\gamma) and an indexed collection keep the subscript as before. The constantπnow has the reading of\pi:π_{01}wasPi_1and isPi_01, as\pi_{01}is. In the lenient grammar,π_01wasπ_0·1withambiguous-implicit-subscript, while\pi_01wasPi_01; both are nowPi_01, asx_01isx_01. The strict grammar reads one token after an unbraced_in each order, as TeX does:\pi_01isπ_0·1. White space directly before the_stops the join in each order:x _{01}^2andx^2 _{01}are bothx_1^2, asx _{01}isx_1. White space before the superscript does not:x ^2_{01}isx_01^2. A spacing command before a script is white space, in both grammars:x\,_{01}wasNothing_1·x(the space was the base of the subscript) and is nowx_1, asx _{01}is; the same applies to\;,\quad,\hspace{…}and tox\,^2, which is nowx^2. A subscript after prime marks reports the joined name:x'_{01}reportedundeclared-symbolforx, and now reportsx_01, asx_{01}'does. -
A trigonometric equation solved over a bounded interval gets all its roots. The solver added multiples of the period of the equation to its principal roots, but the roots can be closer together than that period.
Solve(sin(x) + cos(x) = 0, x ∈ [0, 7])was[7π/4], without3π/4: the period is 2π, but the roots are π apart. The same error gave[0, π, 2π]forsin(2x) = 0(withoutπ/2and3π/2),[0, 2π]forsin(x)cos(x) = 0, and[2π - 1]forsin(x + 1) = 0. The roots are now expanded with their own spacing (π forsin(x) + cos(x), becausef(x + π) = -f(x)), and a numeric scan of the interval checks the list. When the scan finds a root that is not in the list,Solvestays unevaluated:sin(13x)cos(x) = 0over[0.05, 2]was[π/2], one of its nine roots. The scan also finds a root where the function touches zero next to an end of the interval, and a root at the edge of a region where the function is not real:Solve(x·√(cos(x) + 99/100) = 0, x ∈ [3.1, 7])was[], but2π − arccos(−0.99) ≈ 3.2831is a root. Near a largex, two values are the same root only when they are much closer than the samples of the scan:Solve(sin(13x)cos(x) = 0, x ∈ [10^8, 10^8 + 7])was a list of 3 of its roots. These now stay unevaluated. When the scan finds no root, the answer is the empty list:Solve(sin(x) = 0, x ∈ [0.1, 0.2])andSolve(sin(x) = 2, x ∈ [0, 7])stayed unevaluated, and are now[].Solve(sin(x) = 0, x ∈ [10^17, 10^17 + 64])did not stop, and now stays unevaluated. Two principal roots that are very near each other are no longer taken as one family of roots: over[0, 7],(sin(x) - 1/2)(sin(x) - 1/2 - 10^-10) = 0was a list of 3 of its 6 roots, becauseπ/6andarcsin(1/2 + 10^-10)are only1.15·10^-10apart. Two roots are in the same family only when their difference is an exact multiple of the spacing; when the numeric values cannot show it,Solvestays unevaluated. A root of high multiplicity no longer makes the scan reject a complete list:Solve(sin(x)^10 = 0, x ∈ [0, 2π])stayed unevaluated and is now[0, π, 2π], andSolve((x - 1)^9·sin(x) = 0, x ∈ [0.5, 4])is now[1, π]. -
Solvegives the roots of a trigonometric equation in the angular unit of the engine. In degree mode,Solve(sin(x) = 1/2, x)was[30]: the second root wasπ − arcsin(1/2), which is not a root when the angle is in degrees. It is now[30, 150](in grads[100/3, 500/3], in turns[1/12, 5/12]).Solve(cos(x^2) = 0, x)was[]in degree mode, and is now[3√10, −3√10]. Over a bounded interval, the period of the equation is now in the angular unit:Solve(sin(x) = 1/2, x ∈ [0, 720])was[30]in degree mode, and is now[30, 150, 390, 510]. -
Integrateno longer integrates a user function as the library function of the same name. WithSin := t ↦ t + 1,∫₀¹ Sin(x) dxevaluated to1 − cos(1)(.N()gave 1.5) and∫ x·Sin(x) dxto−x cos x + sin x. The body of the user function now replaces the call, asDdoes: these are3/2andx³/3 + x²/2. When the body cannot replace the call (anevaluatehandler, a block local or a parameter with a library name, a body that reads a symbol with the name of the integration variable), the integral stays unevaluated. -
Sincgives the same value in every angular unit.Sinc(x)issin(x)/xwithxread as a number, not as an angle, but its exact branch computedSinof the operand, which reads the operand in the current angular unit. In degree mode,Sinc(10⌊π⌋)evaluated to1/60; the value issin(30)/30 ≈ −0.0329. The exact branch now converts the operand from radians to the current unit before it computesSin, so the exact valuesSinc(π) = 0andSinc(π/2) = 2/πare the same in every unit.
0.147.0 2026-10-02
Behavior Changes
-
TotientandNPartitionevaluate for zero and negative integers.Totient(0)is0andTotient(-n)isTotient(n)(Totient(-12)is4);NPartition(n)is0for a negative integern. These are the values of Mathematica'sEulerPhiandPartitionsP, and of the Fungrim identities the engine already loads (Totient(-n) = Totient(n),NPartition(-n) = 0). Before, these inputs stayed unevaluated (Totient(0),NPartition(-3)). -
arctanandarccotare odd on their branch cuts. The cut ofarctanis the imaginary axis outside[−i, i], the cut ofarccotthe imaginary axis inside it. Both halves of each cut took the side right of the axis, soarctan(−z)was not−arctan(z)there. Each half now takes the side it is continuous with, as in mpmath and Mathematica, and asarsinhalready did on the same axis. The values that change, in the interpreter (N(), andevaluate()of a float argument), compiled JavaScript and Python, and forarctanonly in GLSL and WGSL (the shader targets compilearccotof a real argument only):arctan(iy)fory < −1: the real part changes fromπ/2to−π/2(arctan(−2i)wasπ/2 − 0.549i, it is now−π/2 − 0.549i;arctan(−1.5i)wasπ/2 − 0.805i, it is now−π/2 − 0.805i).arccot(iy)for0 < y < 1: the real part changes fromπ/2to−π/2(arccot(0.5i)wasπ/2 − 0.549i, it is now−π/2 − 0.549i).arctan(iy)fory > 1,arccot(iy)for−1 < y < 0, andartanhandarcothon their cuts on the real axis do not change.
-
The lenient grammar reads
**as^.**was an infix operator that bound looser than a prefix minus and than a^in its exponent. It now has the same reading as^(ce.parse(text, { strict: false })):-e**θis-(e^θ), was(-e)^θ;-x**2is-(x²), was(-x)².1**x^M,a**b^candx^2**3are anunexpected-superscripterror, as1^x^Mis. Before, they were1^(x^M),a^(b^c)and(x²)³.x**y!is(x^y)!, asx^y!is, wasx^(y!).x**2yisx²·yand reportsambiguous-exponent-end, asx^2ydoes.- A chain of
**alone is read from the right, as in programming languages, and does not change:2**3**2is2^(3^2).
-
White space after
^does not change the reading of the lenient grammar.x ^ piisx^π, wasx^p·i;x^ -yisx^(-y), was an error. White space after the-of an exponent on a letter is also skipped:e^- xise^(-x), wasSuperminus(e)·x.A^+ x(the pseudo-inverse ofAtimesx) and\R^- xdo not change, and the strict grammar does not change. -
x^^2andx^²are a double-superscript error, in both grammars. The tokenizer dropped the second^of a^^that was not followed by two hexadecimal digits, so these inputs were read asx². A TeX^^character code such as^^41is still read. -
The factorial of a real non-integer follows
ce.precision.x!for a real non-integerxis Γ(x + 1). It was computed in doubles at every precision, soFactorial(2.5).N()was3.3233509704478448, a double,Factorial(200.5).N()overflowed to+ooandFactorial(-200.5).N()underflowed to0. It is now computed asGammacomputes it, toce.precisiondigits:Factorial(2.5).N()is3.32335097044784255118,Factorial(200.5).N()is1.1174203734326765163e+376andFactorial(-200.5).N()is5.63699519017856675668e-374at the default precision of 21 digits. Atprecision: "machine"the result is the double, as before. A compiled(1/2 - 1)!, whose constant is folded from.N(), is now1.772453850905516, the double nearest √π, not1.7724538509055159. -
The factorial of an exact non-integer stays symbolic under
evaluate().Factorial(5/2).evaluate(),Factorial(-1/2).evaluate()andFactorial(1 + i).evaluate()gave a float, against the rule thatevaluate()keeps an exact argument exact. They now stay(5/2)!,(-1/2)!and(1 + i)!, asGamma(7/2),Gamma(1/2)andGamma(2 + i)do;.N()and a float operand (2.5!) give the float. Integer factorials are unchanged. -
A factorial too large for the exact product is a big decimal under
.N(). Past the exact digit cap (10⁶ digits),Factorial(n).N()andFactorial2(n).N()overflowed to+oo. Above machine precision they are now computed from Γ toce.precisiondigits:Factorial(10^6).N()is8.26393168833124006238e+5565708,Factorial(10^7).N()is1.20242340051590345614e+65657059andFactorial2(10^15).N()is6.83517080822812242208e+7282852759048381. Underevaluate()they stay symbolic, as before. Atprecision: "machine"they are+oo, since a double cannot hold them. A value whose decimal exponent is past ±2⁵³ ≈ ±9·10¹⁵, the largest exponent a big decimal holds exactly, has no big-decimal value either (the big-decimalexpsaturates to infinity or 0 there). A real value that overflows past that range is+oo, as before and as a double overflow is (Factorial(10^15).N(),Gamma(10^15 + 0.5).N()). A real value that underflows past it now stays unevaluated, since a0reads as an exact zero and Γ has no zeros:Gamma(-10^15 - 0.5).N()andFactorial(-10^15 - 0.5).N()(decimal exponent about −1.5·10¹⁶) were0. A complexGamma,FactorialorBarnesGvalue past that range, in either direction, stays unevaluated: a complex value that is too large has a direction that no infinity of the engine holds. The big-decimal Γ at a high precision now stops at the time limit (ce.withTimeLimit): the Bernoulli table it builds (about 30 s atce.precision = 3000) checks the deadline at every step instead of every 256 steps, and the Stirling series checks it at every term. -
A small part of a complex result of a float input is kept. The machine kernels of the interpreter (
.N()at machine precision; and at every precision the kernels of the special functions and a complex power, root or exponential with a machine operand) set to 0 a part below 10⁻¹⁴ times the modulus of the result, and the compiled complex helpers set to 0 a part below 10⁻¹⁴ and below 10⁻¹⁴ times the modulus. That part is the value at the float input, and is now kept: at machine precisione^{3.141592653589793i}.N()is−1 + 1.22·10⁻¹⁶i(was−1) and2^{10^{-100}i}.N()is1 + 6.93·10⁻¹⁰¹i(was1); compiled with folding off,e^{\ln(−2)}is−2 + 2.45·10⁻¹⁶i(was−2). TheChopoperator removes such a part. An exact input has no such part (next entry):e^{iπ}ande^{\ln(−2)}evaluate to−1and−2. -
An exact multiple of π is reduced exactly under
.N()and in compiled code. The multiple of π is reduced in half-turns with bigints, so no rounding of π reaches a part whose value is0or±1/2. At machine precision,sin(π).N()is0(was1.22·10⁻¹⁶),cos(π/2).N()is0(was6.1·10⁻¹⁷),cos(2π/3).N()is−0.5(was−0.4999999999999998) andsin(π/6).N()is0.5(was0.49999999999999994). At every precision and in every angular unit,e^{2iπ/3}.N()is−0.5 + 0.866iwith a real part of exactly−0.5(was−0.4999999999999998at machine precision and−0.500000000000000000001at 21 digits),e^{i·10^{20}π}.N()is1(was0.919 − 0.394iat machine precision),sinh(iπ).N()andcosh(iπ/2).N()are0(were1.22·10⁻¹⁶iand6.1·10⁻¹⁷), andtanh(iπ/2).N(),coth(iπ).N(),csch(iπ).N()andsech(iπ/2).N()are~oo(wereNaN,±8.2·10¹⁵iand1.6·10¹⁶). Compiled JavaScript, Python, GLSL and WGSL lowersin(πu),cos(πu),tan(πu)ande^{a + iπu}with the angleuin half-turns (JavaScript also over a list,sin(πL)), so the value at an integer or a half-integeruhas an exact zero part,+0: compiledsin(πx)atx = 2is0(was−2.45·10⁻¹⁶),e^{iπx}atx = 1is the plain real−1, ande^{x + iπ}is−e^x. A compiledsin(πx)costs about 25% more per call in JavaScript thanMath.sin(Math.PI * x). -
A negative real base with a float exponent has the interpreter's real root in compiled code. The interpreter reads the rational
p/qthat the double came from, and aqthat is odd gives the real root:(−8)^{0.4}is2.297and(−8)^{0.3333333333333333}is−2at machine precision (−1.99999999999999986137at 21 digits). The compiled complex power gave the principal value (0.710 + 2.185iand1 + 1.732i), the compiled JavaScript real powerx^yof two real variables gaveNaN, and Python gave the principal value ornan. Now JavaScript and Python give the real root (−2for the example) for a constant or a variable exponent (PythoncompileFunctionthrough a helper_ce_pow; a bare lambda ofcompileLambdahas no place for it and keepsx ** y, the principal value, for a variable exponent); GLSL and WGSL give it for a constant exponent, and keeppowfor a variable one, which a shader holds as an f32 and cannot read to 17 digits. An evenq, or no rational ((−3)^{√2}), keeps the principal value (in Python with an exact angle:(−1)^{0.5}isi, was6.1·10⁻¹⁷ + i), andNaNon the JavaScript and shader real lanes. -
An exact complex number divided by an exact number is an exact number literal, and a float complex literal stays inexact. The canonical form and its MathJSON and LaTeX change:
\frac{i}{3}was["Divide", ["Complex", 0, 1], 3]; it is now the literal(1/3)i,["Complex", 0, ["Rational", 1, 3]], as\frac{1}{3}ialready was. Its LaTeX is now\frac{1}{3}\imaginaryI.\frac{3+i}{2}is3/2 + (1/2)i,\frac{i}{\sqrt2}is(√2/2)i, and\frac{i}{3}\cdot 3isi.- A complex divisor folds too:
\frac{2}{i}is-2iand\frac{3+i}{1-i}is1+2i. - A float complex literal is no longer read as an exact value when its
parts are integers.
\frac{1.0i}{3}and\frac{3}{1.0i}keep theDivide,1.0i\cdot 3and(2.0+1.0i)\cdot 3keep theMultiply, and1.0i+3is the inexact3.0+1.0i. Before, the products and the sum folded to the exact3i,6+3iand3+i. This is the rule for a real float, where\frac{1.0}{3}keeps theDivide. - The same rule applies to arithmetic on number values: a sum, product or
quotient of an exact value and a float complex value is a float, also
when the parts of the float are integers.
\frac{1}{3}+1.0i,\frac{1}{3}\cdot(2.0+1.0i),\frac{3}{1.0i}and\sum_{k=1}^{3}\frac{k}{2}\cdot 1.0ievaluate to floats (they were exact), as doce.number([1,3]).add(ce.parse('1.0i'))and the product of(1+i).N()with the exact1-i.
An imaginary factor inside a
Dividewas not seen by the exact reduction of an imaginary multiple of π, so\tanh(\frac{i}{3}\cdot\frac{3}{2}\pi)and\coth(\frac{i}{3}\cdot\frac{3}{2}\pi)gaveNaNunder.N(). They now give~ooand0, as\tanh(\frac{i\pi}{2})and\coth(\frac{i\pi}{2})do. A symbolic divisor (\frac{i}{x}) keeps the division. -
A complex number from the host with integer parts is exact; a complex float is never read as exact.
ce.number(new Complex(2, 3)),ce.number({ re: 2, im: 3 })andce.box(new Complex(2, 3))are now the exact2+3i(isExactistrue, the MathJSON is["Complex", 2, 3]; it was["Complex", {num: "2.0"}, {num: "3.0"}]), asce.number(2)is the exact2. Integer-valuedBigDecimalparts are exact too. A part with a fraction (new Complex(2.5, 3)) gives a float, as before. In return, a float whose parts are integers is no longer taken for an exact value in a computation, so a float operand makes the result a float:\frac{1}{3}\cdot 1.0iand\sqrt2\cdot 1.0ievaluate to floats (they were the exact(1/3)iand√2·i).(1.0+1.0i)^2is the float2.0i(it was the exact2i);(1+i)^2stays the exact2i.\Gamma(1.0i)and\cos(1.0i\pi)evaluate to floats underevaluate()(they stayed symbolic, as\Gamma(i)does).- A float multiple of π that is a special angle stays exact:
e^{1.0i\pi}is-1, as before.
The complex results of the numeric routines stay floats: the conjugate of a float, an even root of a negative number under
.N(), the complex kernels (\Gamma(1.0+1.0i)), and the complex entries of a matrix that holds a complex float (Trace,Transpose,MatrixMultiply,Determinantof[[1.0+1.0i, 0], [0, 1.0+1.0i]]).The results of the numeric routines are now floats also when they are real integers (they were exact):
NIntegrate(1, 0, 2)is2.0(it was2), as are the values ofNLimit,ND,NDSolve(the inner grid points too; an end point that is an exact limit stays exact), the fitted parameters ofFindFitandFindRoot, and the roots thatComplexRootscomputes for a float (ComplexRoots(1.0, 4)starts with1.0). For a matrix with a float entry, the results ofEigenvalues,Eigenvectors,SingularValues,SVD,QRDecompositionand the spectral norm are floats (SingularValues([[3.0, 0], [0, 4.0]])is[4.0, 3.0], it was[4, 3]); a matrix of exact entries keeps the results it had.The eigenvalues of an exact matrix larger than 3×3 (computed by the QR algorithm) are checked with exact arithmetic: an eigenvalue close to a rational number that makes
A − λIsingular is returned exact, andEigenvectorsthen gives exact vectors. A double eigenvalue of a defective matrix, which the QR algorithm gives with an error of about10^{-8}, is now exact: the eigenvalues of[[5,4,2,1],[0,1,-1,-1],[-1,-1,3,0],[1,1,-1,2]]are[4, 4, 2, 1](they were[4.0000000258, 3.9999999742, 2, 1]), with exact eigenvectors.
New Features
-
More reading choices of the lenient grammar report an
ambiguous-*code (the reading does not change):ambiguous-denominatorfor÷:13÷2xis13/(2x), as13/2xis.ambiguous-digit-groupsfor white space before a decimal separator:3 .5is read as 3.5.ambiguous-rangefor...directly followed by a digit after a decimal number:.5...5is the range from 0.5 to 5, and can be.5..and.5.1...5is not reported.ambiguous-function-argumentfor an argument with no parentheses that starts with+:ln+1isln(1).sin -xis not reported.
-
Wolfram Language aliases
EulerPhi,PartitionsPandDet. They are aliases forTotient,NPartitionandDeterminant: the canonical form uses the CE name (["EulerPhi", 12]becomes["Totient", 12], which evaluates to4), and an invalid operand gives the same error as the CE operator. Before,EulerPhi(12)stayed unevaluated with no error.Dethas only the one-operand form:Det[m, Modulus -> n]has no CE equivalent and its second operand is anunexpected-argumenterror.Trgets no alias, because Mathematica'sTrof a vector or of a rank-3 tensor has a different meaning thanTrace. Seedocs/MATHEMATICA-NAMES.md. -
More
ambiguous-*parse diagnostics in the lenient grammar. These inputs were read with no diagnostic, although a person can mean a second reading. The reading does not change, and the strict grammar reports none of these codes. One code is new and eight codes report more inputs:ambiguous-missing-base(new): a_with no base is read as the symbol_(-_1).ambiguous-name-then-number: a run of letters, white space, then a number (xy 0.5, was reported only as a letter run), and∞directly followed by a digit (∞2).ambiguous-implicit-subscript: an unbraced subscript of letters directly followed by a digit (M_max0.5,x_a2), or that ends with a function name (A_maxsin t).ambiguous-delta:Δafter a sign (-Δα,-Δx).ambiguous-exponent-end: a one-letter exponent directly followed by a letter (x^xy,e^xy; these also reportambiguous-letter-run, as before), and a function name after a signed exponent and white space (e^-x sin x).ambiguous-factorial: a!directly after an exponent or a radicand (M³!,2^-k!,√i!), and a superscript directly after a!(n!²).ambiguous-radical: a Latin letter directly before√(t√y), and a one-letter radicand followed by white space and an operand (√a b).ambiguous-function-argument: a power ofeafter the first factor of an argument without parentheses (sin2e^a), and a function name with no argument (y = min,sin*x).ambiguous-constant-name:ii, read as the imaginary unit.
See
docs/plans/2026-10-01-lenient-ambiguity-codes.md, section 5.
Issues Resolved
-
sin\prime(x)in the lenient grammar is the derivative ofsin, assin'(x)and\sin\prime(x)are. The\primewas read as the start of the argument ofsin, and gave anunexpected-commanderror. -
toString()wraps the operand of a factorial when needed. The factorial of5/2printed as5/2!, which reads as 5/(2!), and the factorial ofx/2as1/2 * x!. An operand that is not a name, an unsigned decimal number or one function call is now in parentheses:(5/2)!,(1/2 * x)!,(n + 1)!,(-3)!;5!,n!,2.5!andsin(x)!are unchanged. A factorial used as the base of a power prints as(n!)^2:n!^2did not parse back, since!^is read as one operator. -
A complex
BarnesGvalue outside the double range is no longer0or left unevaluated.BarnesG(0.5 + 30i).N()was0, which reads as an exact zero of G (G has none off the non-positive integers), andBarnesG(30 + 0.5i).N()stayed unevaluated: a complex argument is computed in doubles, and exp(ln G) underflowed or overflowed. When it leaves the range of a double, G is now formed from ln G as exp(Re ln G)·(cos Im ln G + i sin Im ln G) with a big-decimal modulus:BarnesG(0.5 + 30i).N()is1.48501924951e-362 + 2.52832839715e-362i(mpmath:1.48501924950746e-362 + 2.52832839715190e-362i) andBarnesG(60 + 10i).N()is1.1631308292e+1883 - 2.10110905e+1881i. The value keeps only the digits that ln G in doubles gives: its error is about 2⁻⁵² times |ln G| relative to the modulus, so 12 digits at 0.5 + 30i and 10 at 0.5 + 300i, and a part smaller than the modulus keeps fewer (each part is rounded at the last correct digit of the modulus), at anyce.precision, as for the other complex results of the special functions. Atprecision: "machine"the engine has no number that holds such a value, and the result is unchanged (0on underflow, unevaluated on overflow), as fore^{-1000+i}, which is0there. -
A complex
GammaorFactorialvalue outside the double range is no longer0or~oo.Gamma(0.5 + 1000i).N()andGamma(-200.5 + 0.5i).N()were0, andGamma(200.5 + 0.5i).N()was~oo, which reads as a pole, while Γ has no zeros and its only poles are the non-positive integers. As forBarnesGabove, the value is now formed from ln Γ with a big-decimal modulus, keeping the digits that ln Γ in doubles gives:Gamma(200.5 + 0.5i).N()is-4.90792586263e+373 + 2.63318721817e+373i(mpmath:-4.907925862634e+373 + 2.633187218170e+373i) andGamma(0.5 + 1000i).N()is1.57066061e-684 + 1.6251473018e-682i. A complexFactorialtakes the same route:Factorial(300 + 2i).N()is1.22674475486e+614 - 2.78179033693e+614i. Atprecision: "machine"the result is unchanged (0on underflow,~ooon overflow), as fore^{-1000+i}. -
The compiled
StieltjesGammais accurate to a double up to order 30. The JavaScript kernel summed the Euler–Maclaurin form in doubles, where the partial sum and the subtracted power of the logarithm are up to 10⁹ times larger than the result and cancel: the compiledStieltjesGamma(30)was0.003557728327971422(7 correct digits), andStieltjesGamma(30, 0.5)was-0.0035240594494571897(6 correct digits). For a realabetween10⁻¹⁵⁰and10¹⁵⁰the sum is now taken in double-double arithmetic (about 32 digits):0.003557728855573161and-0.0035240626522715247, as mpmath gives. Measured against mpmath for every order up to 30 ata = 1, 0.5, 2.5, 10(and up to 20 ata = 0.001), the largest relative error is2.2e-16. 1000 values ofStieltjesGamma(30, a)take about 35 ms. -
The inverse trigonometric and inverse hyperbolic functions of an argument beyond the double range have a value.
\arcsin(10^{400})wasNaNat every precision, and is nowπ/2 − 921.72718437817821891…i.\operatorname{arcsec}(10^{-320})was computed from the subnormal double nearest10⁻³²⁰, which keeps 5 digits (737.5203880715337i), and is now737.52037693865456…i. At machine precision,\operatorname{arsinh}(10^{400})was+∞and\operatorname{arcsch}(10^{-400})was~oo; both are921.7271843781782. A real argument, exact or a big decimal, outside the range of a normal double now gives a value at the working precision for the twelve heads (Arcsin,Arccos,Arctan,Arccot,Arcsec,Arccsc,Arsinh,Arcosh,Artanh,Arcoth,Arsech,Arcsch), with the side of each branch cut that the engine takes at±10³⁰⁰and±10⁻³⁰⁰. A complex argument with a part beyond the double range gives a value with the digits of a double, as the complex kernels do at every precision, and keeps a part of the value too small for a double (above machine precision,\arctan(10^{400}(1+i))isπ/2 + 5·10⁻⁴⁰¹i). A complex argument with a part too small for a double next to the other (\arcsin(2+10^{-400}i)) takes the side of the branch cut that the sign of that part selects: it wasπ/2 − 1.317i, below the cut, and is nowπ/2 + 1.317i.\arcsin(10^{400}+i)wasNaNand isπ/2 + 921.727i. At a logarithmic branch point the part that grows as ln(1/δ) keeps its value:\operatorname{artanh}(1+10^{-400}i)is460.8635921890891 + 0.785i. An exact real argument next to ±1 is no longer read as ±1:\arcsin(1+10^{-400})was the realπ/2, and isπ/2 − 1.414·10⁻²⁰⁰i; at machine precision\operatorname{artanh}(1-10^{-400})was+∞and is460.86359218908911. At machine precision,Arctanof a complex argument whose double is~oo(\arctan(10^{400}(1+i))) was anincompatible-typeerror and isπ/2; an argument that is~oostays an error. A float argument whose double is subnormal is unchanged at machine precision: the kernels read that double. -
A trigonometric function of an operand with a random draw draws once under
evaluate().\arcsin(1+\operatorname{Random}())evaluated the operand a second time, to look for a special angle, and used up two draws of aWithRandomSeedframe. An operand that is not pure is no longer read again. -
Above machine precision, the inverse trigonometric and inverse hyperbolic functions of a real argument with a complex value have the working precision.
\operatorname{arcosh}(1/2),\arcsin(2),\operatorname{artanh}(2),\operatorname{arsech}(-1/2),\operatorname{arcoth}(1/2)and the others on a branch cut of the real axis had a complex value computed in doubles (16 digits) at every precision. At 50 digits,\operatorname{arcosh}(1/2)was1.0471975511965979iand is now1.047197551196597746154214461093167628065723133125i. A float argument is read as its shortest decimal, as for a real value:\operatorname{arcoth}(0.999999)at 21 digits is7.25432861926204720674 − 1.57079632679489661923i(from the double nearest0.999999it was7.2543286192476694). The sides of the cuts are those of the double kernels. -
A complex value with a
NaNpart from a kernel isNaN. At machine precision\sin(10^{400}+i)reached the double kernel with an infinite real part, and theNaNimaginary part of its value failed an internal assertion. The value is nowNaN. -
Compiled JavaScript keeps a small part of a complex result, as the interpreter does. The compiled complex functions set to 0 a part below 10⁻¹⁴ and below 10⁻¹⁴ times the modulus of the result, so
arcoth(10⁻¹⁰⁰)was−(π/2)icompiled and10⁻¹⁰⁰ − (π/2)iin the interpreter,2^{10⁻¹⁰⁰i}was1compiled and1 + 6.93·10⁻¹⁰¹iin the interpreter, andsin(π + i)at the doubleπlost its real part1.9·10⁻¹⁶. Every compiled complex function now keeps every part: the kernels give an exact 0 for a part whose value is 0, so a real argument in the real domain still gives a plain real number. The complex power is computed by repeated squaring for a small integer exponent, and with the anglecos(πt) + i·sin(πt)exact at the multiples ofπ/2for a base on an axis or a diagonal, so it has an exact 0 part without a removal ((−2.0)^3is−8, was−7.999999999999998).e^zcompiles to the exponential, as the interpreter computes it:Math.E^zlost digits (e^{40}had a relative error of 2·10⁻¹⁵). The compiled complex square root no longer loses a small argument:√(10⁻³⁰⁰·i)was0and is7.07·10⁻¹⁵¹·(1 + i). -
In degrees, an inverse trigonometric value keeps a small imaginary part. The conversion of the angle to the angular unit set to 0 an imaginary part below 10⁻¹⁴:
arcsin(10^{-200}i).N()in degrees was0, and is5.73·10⁻¹⁹⁹i;arctan(10^{-50}i)was0, and is5.73·10⁻⁴⁹i. -
The compiled
~oois∞ + ∞ion the complex lane, at run time and in a folded constant.arctan(±i)andarccot(±i), which are~ooin the interpreter, gave0 ± ∞iat run time (JavaScript, Python and GLSL/WGSL) and the realInfinitywhen the argument was a constant. A constant that evaluates to~ooon a node whose value is complex folded toInfinitytoo. Both now give∞ + ∞i, the valuecot 0andcsc 0already gave on the complex lane. The outputs that change:arctan(±i),arccot(±i),arctan(i)^2and(1 + i) + ~oocompiled with a constant argument (wereInfinity). A~ooon a node whose value is real still compiles toInfinity(1/0,Γ(−2)).
0.146.0 2026-10-02
Behavior Changes
-
A definite integral with only one bound stays unevaluated. A limit that has an upper bound and no lower bound, or a lower bound and no upper bound (
\int^2 y^2\,dy,\int_0 y^2\,dy,Limits(y, Nothing, 2), the flat["Integrate", f, "x", 10]), is neither an indefinite nor a definite integral, and no default is chosen for the missing bound.\int^2 y^2\,dyevaluated to7/3, as if the lower bound were 1, and\int_0 y^2\,dyto1/3; with a second limit the result could contain1 - Nothing^2. They now stay unevaluated, underevaluate()andN()alike, andcompile()falls back to the interpreter. The indefinite integral (no bounds) and the definite integral (two bounds) are unchanged. -
A sum or product with only one bound stays unevaluated, as an integral with only one bound does. No default is chosen for the missing bound, under
evaluate()andN()alike, andcompile()falls back to the interpreter.\sum_{k=1} kevaluated to1: the subscriptk=1was read as the UPPER bound,Limits(k, Nothing, 1). The subscript is now the lower bound,Limits(k, 1, Nothing), and the sum stays unevaluated. The same applies to each index of\sum_{k=1, j=2}, and to\prod.\sum_{k}^{10} kevaluated to55, as if the lower bound were 1. It now parses toLimits(k, Nothing, 10)and stays unevaluated. SoSum(k, Limits(k, Nothing, 10)), which evaluated to55and serialized as\sum_{k}^{10}k, now stays unevaluated and round-trips through LaTeX unchanged. The flat["Sum", "k", "k", 10]is the same sum.\sum^{10} kand\prod^{5} kparsed to["Sum", "k"]and["Product", "k"]: the bound was lost. The bound is now kept with no index, as for\sum_1^9 k:Sum(k, Limits(Nothing, Nothing, 10)), which stays unevaluated and serializes back to\sum^{10}k.- A lower bound with no upper bound was read as a sum to infinity under
N():Sum(1/k^2, Limits(k, 1, Nothing)).N()gave1.6449…. It now stays unevaluated: write the upper bound+∞(\sum_{k=1}^{\infty}) for an infinite sum.
A sum or product with two bounds, over an indexing set (
k \in S), or with no bounds at all (\sum_k f) is unchanged, and so is\sum_{i \le 10} i, which keeps an implied lower bound of 1. -
A definite integral over a pole whose position is a free symbol stays unevaluated.
Integrate((y − a)⁻², Limits(y, 0, 4)).evaluate()gave-1/a - 1/(4 - a), which is wrong for0 ≤ a ≤ 4(the integral is+∞there); it now stays unevaluated. When the declared type of the symbol or an assumption proves the pole outside the bounds, the integral is evaluated as before: withce.assume(a > 4), oradeclaredreal<..<0>, the result is-1/a - 1/(4 - a). The poles examined are the roots of a denominator of degree 1 or 2 (factor by factor for a product), the zero of a logarithm of a linear argument in a denominator, and the poles oftan,cot,sec,csc,cothandcschand the zeros of a circular or hyperbolic denominator, of a linear argument. A quadratic denominator whose discriminant is not proven negative may have a real root, so∫₋₁¹ dt/(t² + m)now stays unevaluated (its roots±√(−m)are inside for−1 ≤ m ≤ 0), and so does∫₀¹ dt/(t² + m²)(a pole at0form = 0) unless the sign ofmis known (withce.assume(m > 0)it isarctan(1/m)/m). In an iterated integral, the integration variable of an enclosing integral is examined over its range:Integrate((y − x)⁻², Limits(y, 0, 10), Limits(x, 3, 4)).evaluate()gave the wrong partial resultint_(0)^(10)(-1/(y - 3) + 1/(y - 4) dy)and now stays unevaluated (the polex = yis in[3, 4]for3 ≤ y ≤ 4), whileIntegrate((y − x)⁻², Limits(y, 5, 10), Limits(x, 3, 4))givesln(2) + ln(6) − ln(7)(ln(12/7)). The same is true when the iterated integral is written as nested integrals:\int_5^{10}\int_3^4 (y-x)^{-2}\,dx\,dygivesln(2) + ln(6) − ln(7)..N()is unchanged.A symbol with no declared type is taken to be real when the position of a pole is proven, as the antiderivatives already assume (
∫ dt/(t − a)isln|t − a|). So∫₀¹ dt/(t + a² + 1)isln|a² + 2| − ln|a² + 1|,∫₁² dt/(t(t² + a² + 1))and∫₀¹ dt/(t² + a² + 1)have their closed forms, and∫₀¹ eᵗ/(eᵗ + a² + 1) dtisln|a² + 1 + e| − ln|a² + 2|. A symbol whose type was only inferred from its uses is taken to be real too, so the result does not depend on what the engine computed before. A symbol declared by the user (complex,number) is not taken to be real. A free symbol beside a pole at a number keeps the closed form when the pole is removable for every value of the symbol:∫₀² a(t² − 1)/(t − 1) dtis4a,∫₋₁¹ a·sin(t)/t dtisa·Si(1) − a·Si(−1), and∫₋₁¹ (sin(t)/t + a) dtis2a + 2Si(1). -
A definite integral with no value is
Indeterminate. When the integrand changes sign across a pole inside the bounds (∫₋₁¹ dt/t,∫₀² tan t dt,∫ from ½ to 2 of dt/ln t), or has poles at both bounds that diverge with different signs (∫₀¹ (1/t − 1/(1 − t)) dt), the integral has no value, not even an infinite one..evaluate()kept these integrals unevaluated; it now givesIndeterminate, orNaNwhen a bound or a number in the integrand is a float (∫₋₁¹ 1.5/t dt)..N()andNIntegrategiveNaN, as before. A pole with one sign is still+∞or−∞, and a divergence whose sign is not established still stays unevaluated. -
A constant
NIntegrateinside a compiled expression is folded to its value.compile(y + NIntegrate(x ↦ x, 0, 1))gavesuccess: false, sinceNIntegratehas no compiled form and the compiler declined to evaluate it ahead of time; it now compiles to_.y + 0.5. The compiler evaluates a constantNIntegratelike a one-dimensionalIntegrate, within the same cost limit, so a nested integral (a user function whose body is anotherNIntegrate) still declines. -
NIntegrateuses quadrature first.NIntegrate(f, a, b)now uses the same methods, in the same order, asIntegrate(f, x, a, b).N(): on a semi-infinite interval the oscillatory quadrature, then adaptive Gauss–Kronrod quadrature (for a compiled integrand and, with a smaller panel budget, for an interpreted one). It used to go straight to Monte Carlo sampling.NIntegrate(x ↦ x², 0, 1)was0.333422and is now0.3333333333333333. A divergent integral is now+∞,−∞orNaN(NIntegrate(x ↦ 1/x, 0, 1)is+∞, and a proven pole strictly inside the bounds gives+∞,−∞orNaN), as forIntegrate(…).N(); before, both gave a sampled finite number. Monte Carlo is still the fallback when the quadrature does not converge, soNIntegratestill reads theWithRandomSeedframe. A seededNIntegrategives different digits than before when the quadrature succeeds, because no sample is drawn. The result is still a plain number (real or complex), not aMeasurement. -
PrimitiveRootignores the sign ofn. The unit group mod−nis the unit group modn, soPrimitiveRoot(-7)is now3; it stayed unevaluated before. This agrees with the newPrimitiveRootList, which also gives[0]forn = 1, asPrimitiveRoot(1)is0.PrimitiveRoot(0)stays unevaluated andPrimitiveRootList(0)is[]. -
MultiplicativeOrderignores the sign ofn, asPrimitiveRootdoes, because the unit group mod−nis the unit group modn:MultiplicativeOrder(3, -7)is6; it stayed unevaluated before. This applies to the form with a list of residues too.n = 0still stays unevaluated.
New Features
-
StirlingS2(n, k)is an alias forStirling(n, k), the Stirling number of the second kind, under its Mathematica name. Its canonical form isStirling, the preferred name:StirlingS2(6, 3)evaluates to90, andStirlingS2(n, k)isStirling(n, k).Stirlingis unchanged. (#395, contributed by enumeratio) -
Modular roots, primitive roots and discrete logarithms.
PowerModList(a, s/r, m)lists everyxin[0, m)withx^r ≡ a^s (mod m)in order:PowerModList(3, 1/2, 11)is[5, 6],PowerModList(1, 1/3, 7)is[1, 2, 4],PowerModList(-1, 1/2, 625)is[182, 443], and ana^sthat is not anr-th power gives[].PowerModaccepts the same rational exponent and returns the least root, as Mathematica does:PowerMod(4, 1/2, 7)is2, where it was anincompatible-typeerror, and it stays unevaluated when there is none (PowerMod(3, 1/2, 7)).PrimitiveRootList(n)lists every primitive root:PrimitiveRootList(25)is[2, 3, 8, 12, 13, 17, 22, 23], and[]whennhas none.MultiplicativeOrder(k, n, [r1, r2, …])is the leastm ≥ 1withk^m ≡ r_i (mod n)for somei:MultiplicativeOrder(5, 7, [3, 11])is2; it stays unevaluated when nor_iis a power ofk.RationalReconstruction(a, m)recovers the fractionp/qwithp ≡ a·q (mod m)and|p|, q ≤ ⌊√((m − 1)/2)⌋(Wang's algorithm):RationalReconstruction(6, 11)is1/2. All five work over bigints (PowerModList(2, 1/3, 2^89 − 1)lists its three roots) and stay unevaluated rather than answer wrongly when the modulus cannot be factored, a discrete logarithm is out of reach, or a list would pass 100000 entries. The integer exponent forms ofPowerModand the two-argumentMultiplicativeOrderare unchanged. When there are too many roots to list,PowerModstill finds the least root if it is below 100000:PowerMod(0, 1/2, 2^200)is0(#395, contributed by enumeratio). -
DirichletEtaandDirichletBeta, the two alternating cousins of ζ, both entire (#395, contributed by enumeratio).DirichletEta(s)is η(s) = Σ (−1)ⁿ⁻¹/nˢ = (1 − 2¹⁻ˢ) ζ(s) andDirichletBeta(s)is β(s) = Σ (−1)ⁿ/(2n+1)ˢ = 4⁻ˢ (ζ(s, ¼) − ζ(s, ¾)). Exact values at the integers:DirichletEta(1)isln 2,DirichletEta(2)isπ²/12,DirichletEta(0)is1/2,DirichletEta(-3)is-1/8;DirichletBeta(1)isπ/4,DirichletBeta(2)isCatalanConstant,DirichletBeta(5)is5π⁵/1536,DirichletBeta(-4)is5/2(Euler numbers). A realsis answered atce.precisiondigits, also next to the pole of ζ:N(DirichletEta(1/2))is0.604898643421630370247, andη(1 + 10⁻³⁰)keeps every digit instead of cancelling the pole. A complexsis answered in doubles:DirichletBeta(0.5 + 14i)is1.5371154384 + 1.3434514269i. Both are1at+∞andIndeterminateat-∞. A realstoo close to 1 for the digits to be reached stays unevaluated. Both compile to JavaScript. -
StieltjesGamma(n, a)is the generalized Stieltjes constant γₙ(a), the Laurent coefficient of the Hurwitz zeta function at its pole, ζ(s, a) = 1/(s − 1) + Σₙ (−1)ⁿ γₙ(a)(s − 1)ⁿ/n!, as in Mathematica and mpmath;StieltjesGamma(n)is γₙ = γₙ(1).StieltjesGamma(0)isEulerGamma,StieltjesGamma(0, a)is−PolyGamma(0, a),StieltjesGamma(2, 1)isStieltjesGamma(2), and a nonpositive integerais a pole (StieltjesGamma(2, -1)isComplexInfinity).StieltjesGamma(1).N()is-0.0728158454836767248606,StieltjesGamma(2, 1/2).N()is0.968864475220290711422, andN(StieltjesGamma(2, 3/4), 40)is0.1193766260185842196972365071220126165487: a reala > 0followsce.precisionby Euler–Maclaurin on lnⁿ(x)/x with exact integer derivatives, the remainder bounded in ball arithmetic so that the digits returned are certified. A complexaor a negative non-integerais computed in doubles (StieltjesGamma(2, 1 + i).N()is0.1703042014685874 + 0.3731771957574952i). Orders past 30 stay unevaluated. The head threads over lists and compiles to JavaScript for a real value (#395, contributed by enumeratio) -
LogGamma,BarnesGandLogBarnesGare new, andPolyGamma(-1, z)isLogGamma(z).LogGamma(z)is the analytic continuation of ln Γ with its branch cut on (−∞, 0], as in Mathematica and mpmath'sloggamma. It is notGammaLn, which is the principal logarithm of Γ(z) and jumps by 2πi across the zeros of Im Γ:GammaLn(-2.5 + 1.5i)is-3.7175 - 1.4299i,LogGamma(-2.5 + 1.5i)is-3.7175 - 7.7131i, andLogGamma(-1.5)is0.86005 - 6.28319i.GammaLnis unchanged.LogGamma(5)isln(24)exactly,LogGamma(1/2)isln(π)/2, and the poles at the non-positive integers are+∞; a real argument followsce.precision, a complex one is a double.BarnesG(z)andLogBarnesG(z)are the Barnes G-function, with G(z+1) = Γ(z)·G(z), and its logarithm continued withLogGammaas in Mathematica.BarnesG(5)is12,BarnesG(10)is5056584744960000(the superfactorial, an exact integer),BarnesG(0)is0andLogBarnesG(0)is−∞. A realzis computed toce.precisionfrom the asymptotic series of ln G at an argument of about twice the digits asked for (the difference of two of its values, so the constant ζ′(−1) is not needed), walked tozby G(u+1) = Γ(u)·G(u), with every product rounded to the working precision:N(BarnesG(1/2))is0.603244281209446206191…, and 1000 digits take under a second. Up to 1000 steps from 1 (N(LogBarnesG(120.5))is23552.5384297242683359); farther, a realzstays unevaluated atce.precisionabove 15 digits. A complexz, and a realzmore than 60 from 1 at machine precision, use an asymptotic series in doubles (about 3e-12 relative for G, measured against mpmath).LogBarnesG(-2.5)is-2.5747 + 18.8496i.PolyGamma(-1, z)follows Mathematica's convention,PolyGamma(-1, -5/2 + 3i/2)isLogGammathere; the other negative orders stay unevaluated (#395, contributed by enumeratio). -
ClausenCl(n, θ)is the Clausen function Clₙ(θ). For an integer order n ≥ 1 and real θ it isIm Liₙ(e^{iθ}) = Σ sin(kθ)/kⁿwhen n is even andRe Liₙ(e^{iθ}) = Σ cos(kθ)/kⁿwhen n is odd (mpmath'sclsinandclcos; Mathematica writes them asIm/ReofPolyLog).N(ClausenCl(2, 1))is1.0139591323607684,N(ClausenCl(3, 2.5))is-0.7606561109685137andN(ClausenCl(2, 3.14159))is1.8393282835451e-6(the expansion of Liₙ at the unit circle, DLMF 25.12.12, with θ reduced mod 2π and moved off π by the duplication formula so the even orders keep their relative accuracy there). A real θ is computed toce.precisiondigits for orders up to 40 and |θ| up to 10¹², the ζ(n − k) being bignum zeta values and exact Bernoulli rationals; outside that, or where the value cancels against its own terms, the head stays unevaluated. The exact points are closed:ClausenCl(2, π/2)is Catalan's constant andClausenCl(2m, π/2)isDirichletBeta(2m),ClausenCl(3, 0)isζ(3),ClausenCl(2, 0)andClausenCl(2, π)are0,ClausenCl(1, 0)is+∞. A non-integer or non-positive order and a symbolic θ stay unevaluated. The compiled JavaScript lane agrees with.N()(#395, contributed by enumeratio). -
DirichletCharacter(k, j, n)andDirichletL(k, j, s)give the Dirichlet characters modulokand their L-functions, in Wolfram's indexing (j = 1the principal character,jup to φ(k)).DirichletCharacter(5, 2, 2)isi,DirichletCharacter(7, 3, 3)ise^(2πi/3)and a character is0wheregcd(n, k) > 1.DirichletLsumsk^(−s) Σ χ(r) ζ(s, r/k)throughHurwitzZeta, so a real value followsce.precision(DirichletL(3, 2, 1.5)is0.703968244868733261668at 21 digits,DirichletL(5, 3, 1.01)keeps every digit next to the pole) and a complex value is at double precision; the odd character mod 4 isDirichletBeta(DirichletL(4, 2, 1)isπ/4); the principal character isζ(s) Π (1 − p^(−s))over the primes dividingk, soDirichletL(5, 1, 1)is~oo; at a nonpositive integer the value is exact from the Bernoulli polynomials (DirichletL(5, 2, 0)is3/5 + i/5,DirichletL(8, 2, -3)is11); within 1/4 ofs = 1a non-principal character with a complex value is summed from its Laurent series in the Stieltjes constants at double precision, since the Hurwitz terms there have poles that cancel (DirichletL(5, 2, 1.01)); a real character at a realstakes guard digits for the cancellation instead (and, closer to 1 than they reach, its Laurent series in certified Stieltjes constants).L(1, χ)of a real character is exact, from the class number formula:DirichletL(3, 2, 1)issqrt(3)/9 * pi(π/(3√3)), the quadratic character mod 5 gives2/5sqrt(5) * ln("GoldenRatio")(2·ln φ/√5),DirichletL(8, 2, 1)issqrt(2)/2 * ln(1 + sqrt(2)); it stays symbolic for a modulus above 1000. A character with complex values at a realsis answered in doubles, its terms computed for a double at anyce.precision. A modulus above 1000 stays symbolic forDirichletL(it sumskHurwitz values). Both heads are listable. (#395, contributed by enumeratio)
Issues Resolved
-
The GLSL and WGSL inverse trigonometric functions of a complex argument are accurate. The shader helpers of
Arcsin,Arccos,Arctan,Arsinh,ArcoshandArtanh(also used byArccsc,Arcsec,ArsechandArcoth) used the logarithm formulas in 32-bit floats, and gave\arcsin(1000)a real part of1.660instead ofπ/2,\arcsin(10^4)a real part ofπ,\operatorname{arsinh}(-10^4)the value-∞,\operatorname{arcosh}(-1.5)a negative real part, and\operatorname{artanh}(10^{20})aNaNreal part. On the real axis outside the domain,\arcsin,\arccosand\operatorname{artanh}took the other side of the branch cut (\arcsin(1.5)wasπ/2 + 0.962i, the interpreter givesπ/2 - 0.962i). The helpers now use the same formulas as the interpreter, written for 32-bit floats, and the side of each cut is the interpreter's. With correctly rounded built-in functions, each part has a relative error below3·10^{-7}(a few units in the last place) for a modulus from10^{-40}to10^{30}. A GPU can be less accurate: GLSL and WGSL allowlogan absolute error of2^{-21}on[0.5, 2]and 3 units in the last place elsewhere, andatanan error of 4096 units in the last place. The helpers do not calllogon an argument near 1 (ln(1 + x)for a smallxis a series).Arccsc,Arcsec,ArsechandArcothhave their own helpers, which do not computew = 1/zand then the function ofw: nearz = ±1the rounding of1/zwas amplified (\operatorname{arcoth}(-1 + 10^{-4}i)had a relative error of6·10^{-5}),1/zoverflowed for a modulus above10^{19}, and a subnormalzgaveNaN(\operatorname{arsech}(10^{-40})is92.80).\operatorname{arsech}(0)is+∞, as in the interpreter; it wasNaN. -
Compiled Python takes the interpreter's side of each branch cut. For a complex argument,
Arcsin,Arccos,Arctan,Arsinh,Arcosh,Artanhand their reciprocalsArccsc,Arcsec,Arccot,Arcsch,ArsechandArcothcompiled tocmathor NumPy routines, which pick the side of a cut from the sign of a zero part:\arcsin(2)of a complex variable gaveπ/2 + 1.317i, the interpreter givesπ/2 - 1.317i. The compiled code now gives a zero part the sign that selects the interpreter's side, on every cut of the twelve functions. Also fixed:Arccotof a complex argument with a negative real part was off byπ(it compiled toπ/2 - \arctan(z), not\arctan(1/z));Artanhof a complex variable with a real value outside[-1, 1]andArsinhon its cut gavenan;\arctan(\pm i)and\operatorname{artanh}(\pm 1)raised aValueError(they are now infinite, as in compiled JavaScript); the reciprocal functions raised aZeroDivisionErrorat0; and a list of complex values takes the same code on each element. A complex-typed argument now always gives a Pythoncomplexfor these twelve functions, also when the value is real:Artanh,ArsinhandArcoshreturned afloatbefore (Arcsin,ArccosandArctanalready returned acomplex). -
A definite integral over a zero of
eᵗ − a,sin t − aor(t − a)^(−n)stays unevaluated.∫₀¹ eᵗ/(eᵗ − a) dtgaveln|e − a| − ln|1 − a|, wrong for1 ≤ a ≤ e, where the integrand has a pole.∫₀¹ cos t/(sin t − a) dtgaveln|sin 1 − a| − ln|−a|, wrong for0 ≤ a ≤ sin 1,∫₀¹ cos t/(sin t − a)² dtgave a finite value where the integral is+∞, and∫₀¹ (t − a)^(−n) dtgave a closed form for everyn. These integrals now stay unevaluated, unless the declared type ofaor an assumption puts the zero outside the interval: withce.assume(a > 3),∫₀¹ eᵗ/(eᵗ − a) dtisln(a − e) − ln(a − 1). The zero of a divisorg(t) − c, wheregis monotone on the interval, is located by comparingcwithgat the bounds. Any other divisor with a free symbol whose zeros cannot be located keeps the integral unevaluated. -
A definite integral with a free symbol in a bound and a pole at a number stays unevaluated.
∫ₐ¹ dt/tgave−ln|a|,∫₋₁ᵇ dt/tgaveln|b|and∫₀ᵇ dt/(t + 1)gaveln|b + 1|: each is wrong when the pole is between the bounds (a < 0,b > 0,b < −1).∫₀ᵃ dt/tgave+∞, wrong fora < 0. They now stay unevaluated unless an assumption puts the pole outside the interval: withce.assume(a > 0),∫ₐ¹ dt/tis−ln(a). A removable or integrable singularity is not a pole:∫₀ˣ sin(t)/t dtis stillSi(x)and∫₀ᵇ dt/√tis still2√b. A pole oftan,cot,secorcsc(or a zero of asinorcosdivisor) with a free symbol in a bound keeps the integral unevaluated:∫₀ˣ tan t dtwasln|sec x|, wrong forx ≥ π/2. -
∫ ln(ax + b) dxis correct.∫ ln(x + 1) dxgave(x + 1)·ln(x) − x + 1, and∫₀¹ ln(x + 1) dxgave+∞. They are now(x + 1)·ln(x + 1) − xand2ln(2) − 1.∫ ln(2x + 3) dxis(2x + 3)·ln(2x + 3)/2 − x. -
.N()of an integral over a pole agrees withevaluate()when the integrand has a constant such asπ..N()of\int_{-1}^1 \frac{\pi}{t}\,dtgave a random Monte Carlo estimate (0.0 ± 3.4); it now givesNaN(evaluate()givesIndeterminate)..N()of\int_{-1}^1 \frac{\pi}{t^2}\,dtgave2.1e154 ± 8.8e143; it now gives+∞, asevaluate()does. -
∫₋₁¹ dt/(eᵃ·t)isIndeterminate, sinceeᵃis never zero and the integrand changes sign across the pole at0. It was0. -
The flat spelling of several integration variables or indexes reads a repeated name as a bound.
["Integrate", ["Multiply", "x", "y"], "x", 0, "y", "y", 0, 1]read asLimits(x, Nothing, 0),Limits(y, Nothing, Nothing),Limits(y, 0, 1), withyan index twice. A name that would be an index twice is now the upper bound of the index before it:Limits(x, 0, y), Limits(y, 0, 1). The same applies toSumandProduct:["Sum", "k", "k", 1, "n", "n", 1, 10], which had threemissingerror operands, isSum(k, Limits(k, 1, n), Limits(n, 1, 10)). -
GammaLnat machine precision is accurate next to 0, 1 and 2.GammaLn(1e-10)had 8 correct digits (23.02585084714237, against23.025850929882735), andGammaLn(1 + 10⁻¹⁰)had about 10: the double kernel formed z − 1 + i, which loses the low digits of a small z, and its absolute error of about 10⁻¹⁶ is a large relative error next to the zeros of ln Γ at 1 and 2. For 0 < z ≤ 7/2 the kernel now uses the Taylor series of ln Γ(1 + e) about 1 with ζ(k) − 1 as its coefficients, and is within a few units in the last place of mpmath there. The newLogGammauses the same kernel for a positive real argument. -
Inverse trigonometric functions are accurate at large and small arguments.
\arcsin(-1000000)gave-1.5707963267948966 + 14.50865012405984i: the imaginary part was correct to six digits only (the correct value is14.508657738523969).\operatorname{arccosh}(-1000000)had the same error.\arcsin(10^{300})gave~ooinstead of1.5707963267948966 - 691.46867507877365i, and\arcsin(10^{-300}i)gave0instead of10^{-300}i. The complex values ofArcsin,Arccos,Arctan,Arsinh,ArcoshandArtanh, and ofArccsc,ArcsecandArccot, now come from formulas that do not cancel and do not overflow, in the interpreter and in compiled JavaScript (\operatorname{arcsec}(10^{-320})wasNaNand is737.52i, and an argument near the largest double, such as1.7·10^{308}(1 + i), no longer gives an infinite part). Each part of the result has a relative error below5·10^{-16}over the whole finite plane. The side of each branch cut is unchanged. At moderate arguments only the last digit of some values changes, and a residual real part such as the-5.55e-17of\operatorname{arsinh}(0.5i)is gone. -
Arcsch,ArsechandArcothare accurate at large and small arguments. For a real argument,\operatorname{arcoth}(10^{100})gave0instead of10^{-100},\operatorname{arcsch}(-10^{-100})gave-\inftyinstead of-230.95165647996451,\operatorname{arcsch}(-10^{6})had 15 correct digits of 21, and\operatorname{arsech}(-10^{-8})gave\infty + \frac{\pi}{2}iinstead of19.113827924512311 + \pi i. For a complex argument, the values lost digits near±1and±iand for a large or small modulus (\operatorname{arsech}(3 - 10^{-10}i)had a relative error of10^{-6}in its real part), and\operatorname{arcsch}(10^{-300}i)wasNaN. The three functions now use the same formulas asArsinh,ArcoshandArtanh, written with(1 ± z)/zin place of1/z ± 1, in the interpreter, with big decimals at a precision above machine precision, and in compiled JavaScript. The side of each branch cut is unchanged. -
Compiled JavaScript keeps a small complex result. The complex helpers of compiled code set to
0any part smaller than10^{-14}in magnitude, so\operatorname{arcoth}(x)compiled to0atx = 10^{20}instead of10^{-20}. A part is now removed only when it is also not larger than10^{-14}times the modulus of the result. The constant folding of these helpers uses the same rule and the same kernels as the run-time helpers. -
More inverse trigonometric functions compile for a complex argument.
Arsinhof a complex argument compiled toMath.asinh, which givesNaN(\operatorname{arsinh}(2i)). It now compiles to a complex helper, asArcoshandArtanhdo. The compiledArccsc,ArcsecandArccotof a complex argument now use the same kernels as the interpreter:\operatorname{arcsec}(-10^{-6})had an imaginary part correct to 5 digits and\operatorname{arccsc}(10^{-300})wasNaN. -
A restriction by an infinite list of conditions no longer runs out of memory. Reading the elements of
When([1, 2, 3], Range(1, ∞))read the whole condition list on the first element, and the process ran out of memory. The conditions are now read only up to the element requested: the result is the three restricted elements, and an element ofWhen(Range(1, ∞), Range(1, ∞))can be read by its index. -
FromDigitsandChineseRemainderno longer round a non-integer element.FromDigits([1.5, 2])gave22andChineseRemainder([5/2, 3], [3, 5])gave3; both now stay unevaluated. A float with an integer value (2.0) is still accepted. -
An infinite list no longer exhausts memory in
ChineseRemainder,FromDigitsandFromContinuedFraction.ChineseRemainder(Range(1, ∞), [3, 5])read every element of the range and crashed the process. These heads now stay unevaluated when a list operand is not known to be finite or has more than 1,000,000 elements. -
An iterated integral with a slowly integrable singularity has the right value and error.
Integrate(y^(−0.999), Limits(y, 0, 1), Limits(x, 0, 2)).N()gave0 ± 1.3e+289; it is now2000.000000002 ± 0.000000024(exact: 2000). Withy^(−1/2)it gave0 ± 9.4e+136; it is now4.000000000000 ± 0.000000000015(exact: 4). The error of the inner levels was averaged over the nodes of the outer level and multiplied by its range, but the nodes are packed next to the singularity, where the inner values and their errors are largest. When the inner values keep one sign, the error added is now their relative error times the magnitude of the result. -
∫₋₁¹ dt/(a·t)with a freeaisIndeterminate. It was0. The pole att = 0could not be confirmed by sampling whileais free. The integrand is now split into a constant factor and a part with only the integration variable ((1/a)·(1/t)), and the pole of that part is confirmed. For everya ≠ 0the integrand changes sign across the pole, and fora = 0it is undefined everywhere, so the integral has no value.∫₋₁¹ dt/(a·t²)was−2/a; it now stays unevaluated, and is+∞whena > 0is assumed and−∞whena < 0.∫₋₁¹ a/t dtwas0; it now stays unevaluated (it is0fora = 0).∫₁² dt/(a·t)is stillln(2)/a. An integrand with a pole at a number and a free symbol that is not a constant factor (1/(t·(t² + a² + 1))over[−1, 1]) also stays unevaluated. -
.N()of an iterated integral with a moving pole and an infinite bound is+∞.Integrate((y − x)⁻², Limits(y, 0, +∞), Limits(x, 3, 4)).N()gave5140000000000000 ± 440000000000000; it is now+∞: for everyxin(3, 4)the inner integral has a pole aty = x. The scan for such poles now places its points on an infinite range with the transform of the quadrature, and cuts an infinite range to a finite one for the pole check. -
Stirling,StirlingS1andEulerianoutside their triangle are0.Stirling(3, 5),StirlingS1(3, 5)andEulerian(3, 5)stayed unevaluated (and their compiled form gaveNaN); they are now0, asBinomial(3, 5)already was.Eulerian(0, 0)is now1. A negative or symbolic operand still leaves the expression unevaluated. -
LogandLncheck their operand count in strict mode.LogandLntake one or two operands (the second is the base;Ln(3, 4)isLog(3, 4)). Withce.strict = true,Log(8, 2, 3)was valid and evaluated to3, andLn(3, 4, 5)becameLog(3, 4, 5). Now the extra operand is an error, as forSqrt(4, 5):Log(8, 2, 3)isLog(8, 2, Error("unexpected-argument", "3")), andLn(3, 4, 5)isLn(3, 4, Error("unexpected-argument", "5")). This applies toce.box(),ce.function()andce.parse()(\log(8, 2, 3)). In non-strict mode the extra operand is kept, as before. -
A redeclared
If,Sum,Block, … compiles as the user definition. Withce.declare('If', { ...ce.lookupDefinition('If').operator, evaluate: () => ce.number(100) }),If(x > 0, sin(x), cos(x))gives100in the interpreter, butcompile()gavesuccess: trueand the code of the libraryIf(0.644…atx = 0.7). The compilation now fails closed (success: false, the result falls back to the interpreter, which gives100), on every target. The check for a user definition that shadows a library operator ran only for the heads that a target lowers through its function or operator table. It now also runs for the heads that the compiler lowers by name (If,Which,When,Match,Loop,Sum,Product,Block,Function, …), for a node that a loop body or a block lowers as a statement, and for the bodies of the user functions that the expression calls. A user definition ofWhichwith its owncompilehandler uses that handler. An unchanged copy of the library definition still compiles as the library operator. -
A chain of a copy under another name keeps its name. With a copy
MyNotEqualofNotEqual(const { name, ...def } = ce.lookupDefinition('NotEqual').operator; ce.declare('MyNotEqual', def)),MyNotEqual(x, y, z)boxed tox != y && y != z, with libraryNotEqualnodes, so anevaluatehandler of the copy did not run. It now boxes toMyNotEqual(x, y) && MyNotEqual(y, z): each node that the library handler builds with the library name gets the name of the copy. An operand that is a libraryNotEqualkeeps its name, also when the handler returns it (MyAnd(And(A, B)), with a copyMyAndofAnd, givesAnd(A, B)). When the handler flattens a library operand (And(And(A, B), C)isAnd(A, B, C)), the call is kept unflattened under the name of the copy:Nand2(And(A, B), C)staysNand2(And(A, B), C), so the libraryAndis not evaluated by the handlers ofNand2. -
A missing operand gives an
Error("missing")operand.ce.box(['Annotated', 'x'])loggederror canonicalizing `Annotated`: Cannot read properties of undefinedand gaveAnnotated(x); it now givesAnnotated(x, Error("missing")).ce.box(['Subscript', 'x'])gaveSubscript(x, [undefined])or threwCannot read properties of undefined, and now givesSubscript(x, Error("missing")). The same fix applies toComplex()(threwExpected one or two arguments; an extra operand is now anunexpected-argumenterror operand),Apply(),CanonicalForm(),Factorial(),Lb(),Lg(),Log2(),Log10(),Lucas()andPrimeNumber(), which threw or built a node with anundefinedoperand. An extra operand ofFactorial,Lb,Lg,Log2,Log10,LucasandPrimeNumberwas dropped, also in strict mode:Lb(8, 3)gaveLog(8, 2), which is3. In strict mode, it is now anunexpected-argumenterror operand (Log(8, 2, Error("unexpected-argument", "3"))); otherwise it is kept, as forSin(1, 2), after the base ofLb,Lg,Log2andLog10(Lg(8, 3)givesLog(8, 10, 3)).CanonicalForm(x, y), with a name that is not a canonical form, loggedInvalid canonical formand now givesCanonicalForm(x, Error("unexpected-argument", "y")). -
A pole whose position comes from an assigned symbol is found. With
q := 1,NIntegrate(y ↦ (y − q)⁻², 0, 2)gave a sampled finite number that changed at each call (13931349, then36076471), andIntegrate((y − q)⁻², y, 0, 2).N()gave1010000000 ± 910000000. Both now give+∞, as they do when the integrand is written with1.Integrate((y − q)⁻², y, 0, 2).evaluate()gave-2(the antiderivative differenced across the pole) and now gives+∞. The pole check now substitutes the values of the assigned symbols of the integrand, which the quadrature and the antiderivative already read; a symbol with no value is not substituted, so its integral is unchanged. -
A pole at a constant such as
πis found.∫₀⁴ (y − π)⁻² dyis+∞.Integrate((y − π)⁻², Limits(y, 0, 4)).evaluate()gave-1/π - 1/(4 - π)(the antiderivative differenced across the pole: a negative value for a positive integrand),.N()gave a sampled value that changed at each call (7000000 ± 3500000), andNIntegrate(y ↦ (y − π)⁻², 0, 4)gave13898520. All three now give+∞, and so do the multi-limit form and(y − e)⁻². A sign change across such a pole (1/(y − π)on[0, 4]) gives no value, as for a pole at a number. The pole check now reads a coefficient with a known real value (π,e, a constant declared with a value) as that number. A pole outside the bounds is unchanged:∫₀³ (y − π)⁻² dyis1/(π − 3) − 1/π. -
A pole at a bound of an integral gives
+∞or−∞.∫₀¹ t⁻² dtis+∞, but.evaluate()gave~∞(ComplexInfinity, the antiderivative−1/tevaluated at0) and.N()andNIntegrate(t ↦ t⁻², 0, 1)gaveNaN(the quadrature found a divergence, with no sign). All now give+∞. The same applies to∫₀^π (y − π)⁻² dy(~∞→+∞),∫₀¹ −t⁻² dt(~∞→−∞),∫₋₁⁰ t⁻³ dt(~∞→−∞) and the multi-limit form.∫₀¹ dt/tgave+∞under.evaluate()andNaNunder.N();.N()now gives+∞too. The integral is the one-sided limit from inside the interval, so the sign is the sign of the integrand next to the bound, inside the interval (negated for reversed bounds):∫₋₁⁰ dt/tis−∞. Poles at both bounds with different signs (∫₀¹ (1/t − 1/(1 − t)) dt) give no value: unevaluated under.evaluate(),NaNunder.N(). An integrable singularity at a bound keeps its value:∫₀¹ t^(−1/2) dtis2,∫₀¹ t^(−0.95) dtis20,∫₋₁¹ dt/√(1 − t²)isπ. The sign is given only when samples of the integrand very close to the bound confirm a pole of order 1 or more; otherwise the result stays as before (unevaluated under.evaluate(),NaNunder.N()andNIntegrate). -
The quadrature no longer takes a logarithmic factor for a divergence.
∫₀¹ t^(−0.95)·ln t dtis−400and∫₀¹ t^(−0.9)·ln²t dtis2000, but.N()andNIntegrategaveNaN: the adaptive Gauss–Kronrod quadrature took the slow growth of the logarithm for a divergence at0. They now give−399.999999912 ± 0.000000039and1999.99999980 ± 0.00000020. The divergence test now also requires the growth of the integrand toward the bound to stop slowing down. A pole is still found:∫₀¹ dt/t,∫₀¹ t⁻² dtand∫₀^{π/2} tan t dtare+∞,∫₋₁⁰ t⁻³ dtis−∞. -
A logarithm in a denominator is a pole site.
∫₀¹ dt/(t·ln²t)gave~∞(ComplexInfinity) under.evaluate()andNaNunder.N(); the integrand is positive and behaves as1/(t − 1)²att = 1, so all routes now give+∞(∫₀^½ dt/(t·ln²t)keeps its value1/ln 2). The pole check now reads the zero of a logarithm of a linear argument in a denominator, and the zeros of each factor of a denominator that is a product. So∫ from ½ to 2 of dt/ln t, whose integrand changes sign at the polet = 1, now stays unevaluated (it wasLogIntegral(2) − LogIntegral(1/2), about1.4238, the principal value) and isNaNunder.N(). A~∞difference of an antiderivative for a real integrand over a real interval now leaves the integral unevaluated instead. -
An iterated integral with a pole that moves with another variable.
Integrate((y − x)⁻², Limits(y, 0, 10), Limits(x, 3, 4)).N()gave a large finite number such as2710000000000000 ± 170000000000000; for eachyin(3, 4)the inner integral has a pole atx = y, and the value is+∞. It is now+∞(−∞for reversed bounds ofy, andNaNfor(y − x)⁻³, which changes sign across the pole). The check runs before the quadrature, so the long quadrature run of such an integral is also gone. -
A nested integral with a divergent inner integral.
\int_0^2\int_0^2 (y-1)^{-2}\,dy\,dxstayed unevaluated under.evaluate()and gaveNaNunder.N(), while the multi-limit form gave+∞. Both routes now give+∞(−∞for reversed outer bounds). The integral of an infinite constant over an interval of nonzero length is now that infinity, negated for reversed bounds:Integrate(+∞, Limits(x, 0, 2))stayed unevaluated under.evaluate()and gaveNaNunder.N(), and now gives+∞. An interval of unknown length ([0, a]) keeps the integral unevaluated. -
The multi-limit form with reversed outer bounds and a divergent inner dimension.
Integrate((y − 1)⁻², Limits(y, 0, 2), Limits(x, 2, 0)).N()gave+∞, while.evaluate()gave−∞..N()now gives−∞: each dimension with reversed bounds negates the integral. -
NIntegrateof a function given by its name.NIntegrate(Sin, 0, 2)wasNaN, because each sample read the function itself instead of its value. It is now1.4161468365471424(1 − cos 2). The interior-pole check also applies to such a function:NIntegrate(Tan, 0, 3)andNIntegrate(Sec, 0, 2)areNaN(the integrand changes sign atπ/2), and withg := x ↦ 1/x²,NIntegrate(g, -1, 2)is+∞. -
A copy of a library operator definition is differentiated and compiled as the library operator. After
ce.declare('Sqrt', { ...ce.lookupDefinition('Sqrt').operator }), the overloading idiom of the guide,D(sqrt(sin(x)), x)gavecos(x) * Apply(Derivative(sqrt, 1), sin(x))and now givescos(x) / (2sqrt(sin(x)));compile()of\sqrt{\sin x}failed (success: false, with a fall back to the interpreter) and now succeeds, with the same code as without the copy. The same applies to every library operator (Sin,Add,Power,Abs,Floor, …), and to a copy used as a compiled callback (Map(Sqrt, xs)). The rule: a definition of a library name is the library operator when itsevaluate,canonical,compile,derivativeandevaluateAsynchandlers are the same function objects as those of the library definition of that name (or are absent in both), and itslazyandbroadcastableflags have the same values, so a copy that changes onlydescriptioncounts too. A copy that changes one of these, such as a copy ofSinwithbroadcastable: false, changes how the operator is evaluated or compiled, and is not the library operator. A copy that replaces one of the handlers, a user function (function Sin(x) { … },Sin(x) := …), a parameter named like a library operator, and a copy declared under another name are not the library operator, as before. A copy made from the definitions of another engine bundle has other function objects, so it is a user definition. -
A copy of a library operator definition under another name keeps its name. After
const { name, ...sin } = ce.lookupDefinition('Sin').operator; ce.declare('MySin', sin),ce.box(['MySin', 'x'])gavesin(x)and now givesMySin(x);MySin(x).evaluate()gavesin(x)and now givesMySin(x). Thecanonicalandevaluatehandlers of most library operators (the trigonometric and hyperbolic functions,Integrate,Matrix, the distributions: about 180 of the 241 library operators that have acanonicalhandler) build their result with the name of their own operator, so the copy turned into the library operator, and a copy with its ownevaluatehandler (ce.declare('MySin', { ...sin, evaluate })) never ran it. Now, when such a handler returns an expression with the name of the library operator that holds it, the result gets the name of the copy. A rewrite to another operator (RationaltoDivide) and a value (MySin(π)is0) are kept. As a result,DdifferentiatesMySqrt(sin(x))as a user function,cos(x) * Apply(Derivative("MySqrt", 1), sin(x)); before, it saw theSqrtthat the handler returned and used the rule ofSqrt. -
A compiled callback that names a host-declared operator falls back to the interpreter. With
ce.declare('MySqrt', { signature: '(real) -> real', evaluate: ([x]) => ce.number(100) })andce.declare('rs', 'list<real>'),compile(ce.box(['Map', 'MySqrt', 'rs']))gavesuccess: true, andrun({ rs: [1, 4] })threwTypeError: _f is not a function. It now givessuccess: false, andrun()gives[100, 100], the value of the interpreter. The same applies to a definition of a library name with its ownevaluate(ce.declare('Sin', { ...sinDef, evaluate })), and to the other callback positions (Filter,Reduce, …). An operator whoseevaluateis a function literal, and a library operator, still compile. An operator that the compiled code can apply, because its definition has acompilehandler or thefunctionsoption ofcompile()maps it, compiles as(p) ↦ MySqrt(p), asMap(Function(MySqrt(t), t), rs)does: withfunctions: { MyOp: (x) => 10 * x },Map(MyOp, rs)compiles andrun({ rs: [2, 3] })gives[20, 30]. -
Derivative()with no operand logged "error canonicalizingDerivative" and stayedDerivative(); it is nowDerivative(Error("missing")), the standard form of a missing required operand. -
The flat MathJSON form of a definite integral reads its bounds for any bound expression.
["Integrate", ["Power", "y", 2], "y", 0, "Pi"]readPias a second integration variable and evaluated to-1/3 * pi; it is nowIntegrate(y², Limits(y, 0, Pi)), which evaluates to1/3 * pi^3. The Epsil call form∫(y^2, y, 0, π)gives this MathJSON. After the variable, an operand that is not a variable name (a number, a constant such asPi, or an expression) starts the bounds, and the next operand is the upper bound, whatever expression it is. A variable name after the variable is still the next integration variable:["Integrate", f, "x", "y", "z"]is the triple indefinite integral, as before. -
NIntegratedeclares the bounds it reads. The signature ofNIntegratewas(function, limits:(tuple|symbol)?) -> number, but only the formNIntegrate(f, lower, upper)was evaluated:NIntegrate(x ↦ x², (0, 2))stayed unevaluated. The signature is now(function, lower:number, upper:number) -> number, and aTupleof bounds or a missing bound is an error operand (incompatible-type,missing). For a multiple integral, useIntegrate(f, Limits(x, …), Limits(y, …)).N(). -
A numeric integral with a slowly integrable singularity at a bound is correct, with an error that covers the true error. A large part of such an integral is closer to the bound than a floating-point number can be (
∫₀^δ x^(−0.999) dxis about 500 forδ = 1e-300), so the quadrature gave a wrong value with a tight error. The value of that part is now found by extrapolation of the integrals over the shells that the quadrature cuts next to the bound (Wynn's ε-algorithm and the Levin u-transform). The extrapolation is used only when the shells decrease as the terms of a convergent series do, two extrapolations agree, and the error is at most 1e-3 of the value. A converged result next to a singular bound is checked against the tanh-sinh (double-exponential) rule, and is kept when tanh-sinh does not converge (a smooth integrand with a narrow peak at a bound, such as∫₀¹ dx/(10⁻¹² + x²) = 1570795.3267948966, keeps its value). The compiledIntegrateand the levels of a multiple integral use the same correction.Integrate(…).N()andNIntegrate(compiled integrand / integrand that does not compile):∫₀¹ x^(−0.999) dx = 1000:508.24896298688 ± 0.00000000053/18.9 ± 4.4→1000.0000000014 ± 0.0000000061/1000.0000000009 ± 0.0000000045.∫₀¹ x^(−0.99) dx = 100:99.922310890258 ± 0.000000000098/89.72 ± 0.86→100.00000000000 ± 0.00000000011/100.000000000005 ± 0.000000000093.∫₀^½ dx/(x·ln²x) = 1/ln 2 = 1.4426950408889634:1.44131049670385 ± 0.00000000000014/1.43831 ± 0.00016→1.44269504088899 ± 0.00000000000021(both).∫₀¹ (1 − t)^(−0.95)·ln(1 − t) dt = −400:NaN(both) →-399.9999998 ± 0.0000021.∫₀¹ (1 − t)^(−0.95)·ln(10⁻⁶·(1 − t)) dt = −676.3102111592855:-380 ± 110(1.3 s) /-236 ± 84→-676.3102109 ± 0.0000043(2 ms).∫₀¹ t^(−0.995)·ln t dt = −40000: the integrand that does not compile gave-41.1 ± 6.9and now gives-39999.99995 ± 0.00025. The compiled one still givesNaN.∫₀¹ dx/√(1 − x²) = π/2:1.5707963111 ± 0.0000000073→1.57079632679493 ± 0.00000000000047.∫₁^∞ x^(−1.01) dx = 100: the integrand that does not compile gave2.71 ± 0.44and now gives100.000000003 ± 0.000000036, as the compiled one does.
A quadrature result that did not converge and is kept instead of the Monte-Carlo estimate now reports an error of at least the Monte-Carlo standard error,
|estimate|/√samples:∫₀¹ sin(1/x) dxgives0.50407 ± 0.00016(was± 0.000051); the same floor applies to a level of a multiple integral. Smooth integrands converge before any of this runs, and take the same time as before.Next to a singular bound that the extrapolation could not resolve, where the integrand keeps one sign, the quadrature result is kept with its widened error, and is never replaced by a Monte-Carlo estimate, which under-weights the neighborhood of the singularity: ∫₀^0.01 dx/(x·(−ln x)·ln²(−ln x)) = 1/ln(ln 100) = 0.6548… gave
0.3220 ± 0.0063and now gives0.503 ± 0.018. When the shells decrease as a power of their index (1/(x·(−ln x)^q)), only the Levin u-transform is used: ∫₀^½ dx/(x·(−ln x)³) = 1/(2·ln²2) = 1.0406844905… gave1.040684324 ± 0.000000012and now gives1.040684485 ± 0.000000050. A part of the interval where the integrand has no value makes the error of the quadrature infinite, so the estimate of the other parts is not kept as a value. A compiled integral that runs inside another quadrature has no Monte-Carlo fallback (it would draw 1e7 samples at each node of the enclosing quadrature):.N()of\int_0^{10}\int_3^4 (y-x)^{-2}\,dx\,dy, whose inner integral has no value foryin[3, 4], ran for more than 200 s and now givesNaNin about 2 s.An integral whose integrand oscillates without a limit next to a bound has no value, and
.N(),NIntegrateand the compiledIntegratenow giveNaNfor it:∫₀¹ sin(ln x)/x dxgave-0.11112857219 ± 0.0000000014with a compiled integrand, and a Monte-Carlo value otherwise. The same applies to∫₁^∞ sin(ln x)/x dx,∫₀¹ sin(ln(1 − x))/(1 − x) dxandsin(ln x)/x^pon[0, 1]forp ≥ 1. -
∫₀^∞ sin x/x dxisπ/2to 1e-11. The oscillatory quadrature skipped[0, 1e-8], wheresin x/xis0/0, and gave1.57079631675 ± 0.00000000025, wrong by 1e-8; it now integrates the first lobe from 0 with the adaptive quadrature, which resolves a singularity at 0, and gives1.57079632675 ± 0.00000000025.∫₀^∞ sin x/x^1.5 dx = √(2π) = 2.5066282746310was2.50658840820 ± 0.00000000013(true error 4.0e-5) and is now2.50662827461 ± 0.00000000014. -
A compiled semi-infinite oscillatory integral uses the oscillatory quadrature. The compiled
Integrate(_SYS.integrate) used only the adaptive quadrature, which does not converge on such an integral: with a parameterp,∫₀^∞ sin x/x^p dxgaveNaNforp = ½and2.5237forp = 1.5. It now gives1.2533141372613(√(π/2)) and2.5066282746098(√(2π)), as.N()does. -
A semi-infinite oscillatory integral with reversed bounds has the right sign.
∫_∞^0 sin x/x dxgave+π/2under.N()andNIntegrate(the infinite lower bound was read as−∞), andNaNwhen compiled. All three now give−π/2. -
The numeric integral of a small integrand has a relative error. The adaptive quadrature stopped when its error was below an absolute tolerance of
1e-12, so an integral of a small integrand was "converged" with any relative error:∫₀¹ 10⁻³⁰⁰·x^(−0.999) dx = 10⁻²⁹⁷gave9.76e-300 ± 8.1e-300. The absolute tolerance is now at most1e-10times the sum of the magnitudes of the panel values, andNIntegrategives9.9999999997e-298. -
A pole at a bound of large magnitude is found. Next to a bound such as
10⁶, the quadrature reaches the spacing of the doubles after about 29 bisections, too few for its divergence test:∫ dx/(x − 10⁶)on[10⁶, 10⁶ + 1]gave22.7 ± 0.58. It now givesNaN, and a convergent integral there (∫ (x − 10⁶)^(−½) dx = 2) keeps its value. -
The flat form of
SumandProductis read like the flat form ofIntegrate.["Sum", "k", "k", 1, 10]gaveError("missing"), as did the Epsil call∑(k, k, 1, 10), which produces it. It is nowSum(k, Limits(k, 1, 10)), which is55;["Product", "k", "k", 1, 5]is120, and["Sum", "k", "k", 1, "n"]isSum(k, Limits(k, 1, n)), withnfree (its simplification is(n² + n)/2). An index followed by one bound (["Sum", "k", "k", 10]) has that bound as its upper bound, and several indexes can follow each other (["Sum", f, "i", 1, 3, "j", 1, 2]). TheLimits,ElementandTupleforms are unchanged.
0.145.0 2026-10-01
Behavior Changes
-
A
vars-mapped symbol with a declared type compiles as if it had no value. The compiled code reads the input, but the compiler used to analyze the expression with the symbol's stored value. Withadeclaredrealanda := 4,compile(\sqrt{a}, { vars: { a: '_.a' } })gave the real-onlyMath.sqrt(_.a), which returnsNaNfor a negative input. It now gives_SYS.csqrt(({ re: _.a, im: 0 })), the code for anawith no value. This applies on every target (javascript,python,glsl,wgsl,interval-js); on a direct custom target (compile(expr, { target })) the value is no longer folded into the code. The value is hidden for the duration of the compilation and restored afterwards, also when the compilation throws. A value put in force by an assumption is hidden the same way: withassume(a = 4),\lfloor a\rfloor + xcompiled to_.x + _.a(the assumption typedaas an integer) and now compiles to_.x + Math.floor(_.a). A symbol whose type was inferred from its value (ce.assign('u', 4)with no declaration) keeps its value during the compilation, but it is no longer folded on a direct custom target, where\sqrt{u}withvars: { u: '_.u' }compiled to2. -
An integral over a
vars-mapped input keeps its closed form.\int_0^x a t\,dtwithamapped byvars: { a: '_.a' }compiled to the quadrature_SYS.integrate((t) => (_.a * t), 0, _.x); it now compiles to the closed form(0.5 * _.a * (_.x * _.x)), as it does for an unmappedawith no value. The input stays live: the closed form reads_.aat run time. The closed form is still skipped when the integral reaches a mapped symbol that has a visible value (one whose type was inferred from its value), when the mapping is not a plain read (an identifier such asu_aor a member read such as_.a), or when it reaches a function overridden with thefunctionsoption. A host that used avarsmapping to force quadrature must now map the symbol to source that is not a plain read, for example{ a: '(_.a)' }. -
The shader and Python targets check a
varsmapping to a bare identifier for reserved words. On GLSL,{ in: 'in' }compiledx + \sin(\mathrm{in})tox + sin(in), which no driver accepts, while the free symbolindeclined. The mapping now declines with the same error, and so does a mapping to a WGSL reserved word (loop) on WGSL. A mapping to another name ({ in: 'u_in' }) or to source that is not a bare identifier (u.in) is unchanged. -
The Python target declines a free symbol named after a Python keyword.
x + \sin(\lambda)compiled tox + np.sin(lambda), which is a Python syntax error; it now declines with"lambda" is a reserved word in python. The same applies to the other keywords (in,if,class,yield, …), to avarsmapping to one, and to a keyword used as aSum/Productindex, a comprehension variable or a function-literal parameter. Map the symbol to another name withvars({ lambda: 'lam' }) to compile it. -
GLSL:
struct,class,lowp,mediumpandhighpare reserved words. A free symbol with one of these names compiled to invalid GLSL; it now declines likeinorsample. -
Four parse diagnostic codes of 0.144.0 are renamed into the
ambiguous-*group, so that a host refuses every reading with a second common reading on one prefix:letter-run-splitis nowambiguous-letter-run,implicit-product-in-denominatoris nowambiguous-denominator,spaced-digit-groupsis nowambiguous-digit-groups, andletter-before-decimalis nowambiguous-letter-decimal. A host that matched one of the first names must use the new one. -
A name in parentheses is a factor, never the head of a function application, in the strict and the lenient grammar.
(k)(x - 1)is nowk·(x - 1)(["Multiply", "k", ["Add", "x", -1]]), was the application["k", ["Add", "x", -1]].(a)(b)is nowa·b, was["a", "b"].y = (m)(x) + bis nowy = m·x + b.(k)(x, y)is nowk·(x, y), was["k", "x", "y"].(g)(3)^2withgundeclared is now9g, wasg(3)^2. A name that the library or the host declared a function keeps the application reading ((\sin)(x)issin(x), and(f)(3)^2isf(3)^2whenfis declared a function); a function type that the engine only inferred from an earlierf(x)does not count.k(x - 1), with no parentheses aroundk, keeps the rules it had. -
In the lenient grammar,
+-is±and-+is∓when there is no white space between the two signs.x = 2 +- 0.1is now["Equal", "x", ["Measurement", 2, 0.1]](as2 ± 0.1and2 \pm 0.1), was2 + (-0.1).a+-bis nowMeasurement(a, b), wasa + (-b).y = +-sqrt(x)is nowMeasurement(0, √x), was-√x.a -+ bis now["MinusPlus", "a", "b"](asa \mp b), wasa - b. The strict grammar keeps+then-. -
A prefix
∓has a reading.\mp 1and∓1areMinusPlus(0, 1), as a prefix\pm 1isMeasurement(0, 1). Strictx = \mp 1was anunexpected-commanderror. In the lenient grammar a prefix-+is read the same way:-+xisMinusPlus(0, x), wasNegate(x). -
The lenient grammar reads five more function names before a parenthesis:
mod(Mod),pow(Power),trunc(Truncate),Re(Real) andIm(Imaginary). These inputs change:mod(x, 2)was an application of an unknown functionmod, andmod(7, 3)evaluated to itself. It is nowMod(x, 2), andmod(7, 3)evaluates to1.pow(x, 2)was an application of an unknown functionpow; it is nowx^2.trunc(x)was an application of an unknown functiontrunc; it is nowTruncate(x), andtrunc(2.5)evaluates to2.Re(z)andIm(z)already had the canonical formReal(z)andImaginary(z); only the raw form (form: 'raw') changes, from["InvisibleOperator", "Re", ["Delimiter", "z"]]to["Real", "z"](and the same forIm).
Without a parenthesis these words keep their reading as letters (
7 mod 3is a product). The strict grammar is not changed. -
A call through a field of a constant that names an operator is the direct call, also without named arguments. With
bobdeclared withisConstant: trueand the value{S -> bob_S},bob.S(3)(Apply(Field(bob, "S"), 3)) was kept asApply(Field(bob, "S"), 3); it is nowbob_S(3), and prints and serializes asbob_S(3)(in Epsil too). The operator's argument checks now apply when the call is built, not when it is evaluated, as forbob_S(3)written directly. A call through a field that holds a function literal is unchanged without named arguments; with named arguments its callee is the literal. -
A named call through a variable whose declared type gives no parameter names is an error. The names of a call such as
alias(3, factor: 5), wherealiasis a variable that holds a function, are now matched only against the DECLARED type ofalias, never against the function it holds now. When the declared type gives no parameter names (the barefunctiontype, or a signature with no names such as(number, number) -> number), the call is the errorargument-names-unavailable, with a message that suggests positional arguments or a declared signature with parameter names. Example: withbob_Sdeclared(x: number, factor: number) -> numberandlet alias: function = bob_S,alias(3, factor: 5)was15and is now the error; the call was stored asalias(3, 5), so afteralias = other, withotherdeclared(factor, x)and computingfactor - x, a stored call gave-2while the names ask for2. Withalias: (number, number) -> number, the error code wasargument-name-unknownand is nowargument-names-unavailable. A variable declared with a signature that names its parameters, a variable with no declaration (whose inferred type is used), and a function defined withf(x) := …orfunction f(x) {…}are unchanged. A program that used names through a variable declaredfunctionmust call it positionally or declare the variable with a signature that names the parameters. -
LaTeX serialization uses standard spellings for
LCM,Log2,Log10, the number sets andDegrees(#345, contributed by enumeratio).LCM(a, b)was\lcm(a, b)and is now\operatorname{lcm}(a, b);Log2(x)andLog10(x)were\mathrm{Log2}(x)and\mathrm{Log10}(x)and are now\log_{2}(x)and\log_{10}(x);Degrees(30)was30\degreeand is now30^{\circ}(a compound operand is parenthesized,(x+1)^{\circ});Integers,RationalNumbers,RealNumbers,ComplexNumbers,NonNegativeIntegersand the sign-restricted sets were\Z,\Q,\R,\C,\N,\R_{>0}, … and are now\mathbb{Z},\mathbb{Q}, …,\mathbb{R}_{>0}.\lcm,\degreeand the short set commands (which only MathLive defines) are still read, and each new spelling parses back to the same expression. A call with the wrong number of arguments is written as a function call,\mathrm{Mod}(a, n, 1)and\mathrm{Interval}(\bigl\lbrack0, 1\bigr\rbrack), whereMod(a, n, 1)was""andInterval(List(0, 1))ended in a stray comma. TheimaginaryUnitserialization option, which only the parser honoured, now also sets howImaginaryUnitand complex numbers are written, and the newexponentialEoption does the same forExponentialEandExp; both default to\imaginaryIand\exponentialE.
New Features
-
The lenient grammar reports each reading choice that has a second common reading. The list of reading choices, their codes, and the choices that have one common reading is
docs/plans/2026-10-01-lenient-ambiguity-codes.md; a reading choice with a second common reading and no code is a defect. The codes added in this release. -
The lenient grammar reports more reading choices that have a second common reading. With
ce.parse(text, { strict: false, diagnostics: true }), each of these choices now reports a parse diagnostic. The reading does not change, and the strict grammar reports none of them:ambiguous-exponent-end: where an unbraced exponent ends.e^2piise^2·πandx^2yisx^2·y(an operand directly after the exponent);e^i pi,e^-x yande^2 pi i(an operand after white space when the exponent is a name or is signed, or the base ise);e^x/2,e^-x/2,x^pi/2andx^1/2(a/after an exponent that is a name, is signed or is 1).x^2 yandx^3/2are not reported.detail: { exponent }.ambiguous-implicit-subscript:x2,θ2,α1are read asx_2,θ_2,α_1, andx_1yis read asx_1·y.atan2,log2andlog10are not reported.ambiguous-name-digits: letters and digits that are not a library function, before a parenthesis:atan3(y)is read asarctan(3·y).ambiguous-function-argument: a function name without parentheses and an argument of more than one factor:sin x yissin(x·y), alsosqrt 2 x,ln 2 x,exp 2 x,abs 2 x; andlog 2 x, read aslog_2(x).sin 2xis not reported.ambiguous-function-without-parentheses: a symbol declared as a function, followed by an operand:f xand2 f xare products.ambiguous-name-then-number: a name, white space, then a number:x 2andθ 2are products.ambiguous-delta:ΔorDeltafollowed by a letter:Δx,Delta xandQ = m c ΔTreadΔas a factor.ambiguous-constant-name: a library constant alone on the left of=:e = 1.6e-19,i = V/R,pi = 3.14,inf = 2.ambiguous-lookalike-letter: a Greek letter that looks like a Latin letter (the capitals Α Β Ε Ζ Η Ι Κ Μ Ν Ο Ρ Τ Υ Χ, and ο).ambiguous-unknown-character: a character that is not math, read as a string:y = ж.ambiguous-radical: the extent of√without braces or parentheses:√2π,√2x,√xy(√2·π),√x²and√x²+y²((√x)²), and3√8(3·√8).ambiguous-absolute-value: bars that pair two ways:|x|y|z|is read as|x|·y·|z|.
-
More reading choices of the lenient grammar report an
ambiguous-*parse diagnostic. Withce.parse(text, { strict: false, diagnostics: true }), these inputs keep their reading and now also report a code, so that a host can refuse a line that a person could have meant another way:Code Example Reading Second common reading ambiguous-equation-numbery = x^2 (2),x = 4 (m)a product an equation label or a unit ambiguous-group-product(x)(1,2)a scalar times a point a call ambiguous-factorial5!=1205 ≠ 1205! = 120ambiguous-arrowx <- 2x < -2an assignment arrow ambiguous-equal-chainx = y = 0nested equations an assignment to several names ambiguous-elementy = x in [0,1](y = x) ∈ [0,1]y = xforx ∈ [0,1]ambiguous-range1..10..2first, second, last first, last, step ambiguous-percenty = 50%y = 50(%starts a comment)a percentage ambiguous-comma1,5a sequence the decimal number 1.5 ambiguous-list-label1. y = x,(1),(iv),a) y = xmath the label of a list item ambiguous-number-notation1_000,0x10a subscript and a product a number ambiguous-date2026-10-15,555-1234,7-11a difference or a quotient a date, a phone number or a range Ordinary math is not reported:
3/4,24/7,2-1,x = 2,1..10,[0,1],f(x, y),(1, 2),0 < x < 1,x != y,5! = 120,x in [0,1]. The strict grammar reports none of these codes. -
Three more
ambiguous-*parse diagnostics in the lenient grammar, and a widerambiguous-constant-name. Withce.parse(text, { strict: false, diagnostics: true }), these readings now report a diagnostic. The reading does not change, and the strict grammar reports none of them.ambiguous-log-base:logwith two arguments in parentheses.log(x, 2)isLog(x, 2), the base second as in Python and in spreadsheets, and other tools put the base first. Also the namelg(lg(x)is base 10 by ISO 80000-2, and base 2 in computer science).detail: { name }.ambiguous-engine-operator: a one-letter library operator written as a plain letter before a parenthesis, read as a call of the operator:N(x)(numeric evaluation),D(x)(derivative). A person writing plain text usually means a function of their own.\operatorname{N}(x)is not reported.detail: { name }.ambiguous-interval: afterin,\in,∈or\notin, a bracket pair followed by an operator, which is then read as a list or a tuple, not as an interval:M in [0,1]^2isElement(M, Power(List(0, 1), 2)). AlsoM in (0,1)^2,M in [0,1] + 1and a range,M in [0..1]^2.M in [0,1]andx in [0,1)are read as intervals and are not reported.ambiguous-constant-namealso reports a library constant followed by a parenthesized group on the left of=:pi(x) = xis read asπ·x = x, and a person can mean the definition of a functionpi.f(pi) = 3is not reported.
-
Two more
ambiguous-*codes.ambiguous-inverse-function: in plain text,sin^-1(x)is read as the inverse function, and a person also means1/sin(x)(\sin^{-1}(x)is not reported).ambiguous-rangealso reports a range with one..next to an operation:1..5/2is the range from 1 to 5/2, and a person can mean(1..5)/2;-1..5is not reported. -
The
onAmbiguityparse option.ce.parse(text, { strict: false, onAmbiguity: 'error' })puts anErrornode in place of the smallest expression that holds the source span of each parse diagnostic whose code starts withambiguous-. The error code is the diagnostic code, and the error holds the source text of the span:ce.parse('y = 2 3', { strict: false, onAmbiguity: 'error' })is["Equal", "y", ["Error", "'ambiguous-digit-groups'", ["LatexString", "'2 3'"]]]. The option works withoutdiagnostics: true, and for everyambiguous-*code. When the parser cannot find an expression with that span, theErrornode replaces the whole result. The default,'report', keeps the reading and reports the diagnostic whendiagnostics: true. In strict mode the option has no effect. -
The
ambiguous-signparse diagnostic (lenient grammar only). It reports a prefix±(also spelled\pm,\plusmnor+-) with no left operand:x = ±1is read asMeasurement(0, 1), and a person often means the two values1and-1. It also reports two signs in a row:--x,x - -y,a<--b,a + -b,-+x, anda -+ b(read asMinusPlus(a, b)).detail: { signs }.a +- bwith no white space is read asMeasurement(a, b)and is not reported. The reading does not change. -
#393 LaTeX notation on a running engine.
LatexSyntax.addEntries(entries)adds LaTeX dictionary entries after construction; the next parse or serialization uses them. WithIntegerModRingdeclared,ce.box(['IntegerModRing', 5]).latexwas\mathrm{IntegerModRing}(5); afterce.latexSyntax.addEntries([entry])it is\mathbb{Z}/5\mathbb{Z}, and parsing\mathbb{Z}/5\mathbb{Z}gives["IntegerModRing", 5]. The default dictionary and the array given as thedictionaryoption are not modified. An engine created without thelatexSyntaxoption has its ownLatexSyntax, so the entries apply to that engine only; an instance given to several engines is shared, and the entries apply to all of them. LaTeX notation stays separate from library definitions: aLibraryDefinitionstill has no LaTeX entries. -
#393 Load a library on a running engine.
ce.loadLibrary(library)takes the sameLibraryDefinitionas thelibrariesconstructor option and declares its definitions in the global scope, asce.declare()does. An expression boxed before the call uses the new definitions:ce.box(['Sq', 3]).evaluate()gaveSq(3), and after loading a library that definesSqasx ↦ x²it gives9. Each library inrequiresmust already be loaded. The call throws, and declares nothing, for a library name that is already loaded, a missing required library, a standard library (select those with thelibrariesoption), or a definition name that is invalid, repeated, defined by another library, or already declared in the global scope. A definition thatce.declare()rejects (for example for an unknown key) also makes the call throw, and the definitions declared before it are removed: for any failure, the call declares nothing. (The exception is a call made while an evaluation or a declaration batch is in progress, where the definitions before the rejected one stay declared.) When a library inrequiresis a standard library, the error says to select it with thelibrariesoption. A checkpoint taken before the call (ce.checkpoint()) undoes it. -
#393
ce.libraryOf(name)returns the name of the library that definesname:'trigonometry'forSin, thenameof a caller library given to the constructor or toce.loadLibrary(), andundefinedfor a name declared withce.declare(), a name nothing defines, or a library name that a declaration in the current scope shadows. -
#394
ce.declare(name, patch, { extend: true })extends the operator definition that is visible fornameinstead of replacing it. Before, a redeclaration replaced the whole definition:ce.declare("SetMinus", { signature: "(value, value*) -> set" })madeSetMinus(5, 2)valid, but removed theevaluateandcollectionhandlers and the description, soSetMinus(Set(1, 2, 3), Set(2))no longer evaluated. In extend mode:- The engine builds a new definition from the visible one and the fields of
the patch. A field the patch does not name keeps its value. A field the
patch names replaces the old value: when two extensions give the same
handler, the later one wins. With the patch above,
SetMinus(5, 2)is valid andSetMinus(Set(1, 2, 3), Set(2))still evaluates toSet(1, 3). signaturereplaces the signature. The new fieldaddSignatureadds an overload: the signature becomesold & new. The new signature must be a subtype of the old one, so that every call that was valid stays valid:(any*) -> anycannot replace(value+) -> value, and the call throws. This does not apply when the old signature was only inferred (an operator declared without a signature, or a function whose signature is inferred from its body).- An extension of a standard-library operator that keeps its
evaluate,canonical,compileandderivativehandlers is still the library operator: afterce.declare("Sin", { description: "…" }, { extend: true }),D(Sin(x), x)iscos(x)andSin(x)compiles. An extension that replaces one of these handlers is a user definition with the library name, as with a plaince.declare(). - A name that is already declared in the same scope can be extended: two
libraries that extend
Basisboth succeed, and the second one sees the fields of the first. A plaince.declareof that name still throws. - It is an error to extend a name with no operator definition, a name declared as a value, or a function defined by clauses.
- The old definition is not changed: an expression boxed before the extension keeps it. A checkpoint restore undoes an extension like any declaration.
- The third argument of
ce.declare()can now be an options object{ scope?, extend? }. A scope as the third argument works as before.
- The engine builds a new definition from the visible one and the fields of
the patch. A field the patch does not name keeps its value. A field the
patch names replaces the old value: when two extensions give the same
handler, the later one wins. With the patch above,
-
#393 An operator definition can give its own derivative with the new
derivativekey. Before, the key was refused (unexpected key "derivative"), and the derivative of an operator whoseevaluatehandler answers only for numbers stayed symbolic:D(Sq(x), x)gaveApply(Derivative(Sq, 1), x). The key gives the partial derivative with respect to each argument, andDapplies the chain rule:ce.declare('Sq', {signature: '(number) -> number',evaluate: ([x]) => (isNumber(x) ? x.mul(x) : undefined),derivative: [['Function', ['Multiply', 2, 'x'], 'x']],});ce.box(['D', ['Sq', 'x'], 'x']).evaluate(); // 2xce.box(['D', ['Sq', ['Power', 'x', 2]], 'x']).evaluate(); // 4x^3ce.box(['Sq', 'y']).evaluate(); // Sq(y), not unfoldedThe value is either an array with one function literal for each argument (each literal takes all the arguments as parameters), or a handler
(ops, { engine, argument }) => Expression | undefinedthat returns the partial derivative with respect to argument numberargumentatops. A partial that the key does not give stays symbolic.Derivative(Sq),Sq'(3),Apply(Derivative(F, 1, 0), 2, 3)and the compiled form ofD(Sq(x^2), x)use the key too. The key has precedence over the body of a function-literalevaluatehandler; an operator of the standard library keeps its own derivative rule. -
#393 The options passed to an
evaluatehandler have a newprecisionfield: the number of significant digits requested. InsideN(x, p)it isp, for the evaluation ofxand of everything evaluated inside it, also whenpis lower thance.precision. Under any other numeric approximation (x.N(),N(x)) it isce.precision. In an exact evaluation it isundefined. A handler can then compute to the requested digits without readingce.precision.N(x, p)withpabovece.precisionstill raisesce.precisionand leaves it raised, as before.
Issues Resolved
-
A pure imaginary factor in a product was serialized in parentheses that are not necessary:
i·xwas(\imaginaryI)xande^{iπ}was\exp((\imaginaryI)\pi). They are now\imaginaryI xand\exp(\imaginaryI\pi), and2i·xis2\imaginaryI x. A factor with a leading sign ((-2\imaginaryI)x) or with a real part ((1+2\imaginaryI)x) keeps its parentheses. The old and the new output read back as the same expression. Two related power-base spellings were wrong or misleading:Power(Complex(0, 1.0), x)was1.0\imaginaryI^{x}, which reads back as1.0·i^x, and is now(1.0\imaginaryI)^{x}; andPower(Complex(1/2, 0), x)was\frac{1}{2}^{x}and is now(\frac{1}{2})^{x}, as for a plain1/2. -
#390 A call through a field of a record or a dictionary ignored the declaration of the function in the field. With
bob_Sdeclared(x: number, factor: number?) -> number, the callbob.S(3, factor: 5)(MemberCall(bob, "S", …), orApply(Field(bob, "S"), …)) gave the errorargument-names-unavailable, whilebob_S(3, factor: 5)gave 15. Two kinds of receiver now take named arguments:- A constant receiver:
bobdeclared withisConstant: trueand the value["Dictionary", ["Tuple", "'S'", "bob_S"]], orconst bob = {S -> bob_S}in Epsil. Its field cannot change, so the call is made the direct call:bob.S(3, factor: 5)wasargument-names-unavailable, and is nowbob_S(3, 5), which evaluates to 15. The operator'slazyflag applies too: withy := 10and a lazybob_Hthat returnsHoldof its argument,bob.H(y * z)gaveHold(10z)and now givesHold(y * z), asbob_H(y * z)does. A field that holds a function literal is applied with the names matched against the literal's parameters. The canonical form of such a call is the direct call, so it prints asbob_S(3, 5). - A receiver whose type is a record that gives the field a signature with
named parameters (
record{S: (x: number, factor: number?) -> number}). The names are matched against that signature:bob.S(factor: 5, x: 3)wasargument-names-unavailable, and is nowApply(Field(bob, "S"), 3, 5), which evaluates to 15. The receiver can be a variable, because the match reads its type, not its value.lazyis not honored on this route, since a signature type has nolazyflag:bob.H(x: y * z)givesHold(10z).
A variable typed
dictionary<function>gives no parameter names, so a named call through it is stillargument-names-unavailable, and its value is not read:lazyis honored only through a constant receiver. - A constant receiver:
-
Inside the call of a user function, a free symbol of the caller with the name of a parameter is an unknown. With
f = (x, y) ↦ x = y,f(y, x)answeredFalseinstead of stayingy = x,x ≠ yansweredTrue, andIf(x = y, 1, 0)answered0. The cause was.unknowns: it looked up each symbol by name, and during the call the namexalso denotes the parameter, which has a value..unknownsnow resolves a symbol the way evaluation reads its value, so thexof the argument is an unknown and the parameterxused in the body is not. The same defect caused a performance regression since 0.142.0: theevaluate()of a call whose arguments contain aFloor(alsoCeil,Round,Truncate) was 300 to 6,000 times slower than in 0.141.0. Withf = (x, y) ↦ \{\sqrt{x^2+y^2} \le 1: 1, 0\}, the callf(⌊31x⌋/31, ⌊31y⌋/31)took about 60 s, and now takes about 10 ms: each sign of⌊31x⌋tried to order the free31xand1as two constants, with a higher precision and a symbolic proof. -
A library list with
corebut withoutcontrol-structurescan build function literals. Withnew ComputeEngine({ libraries: ['core', 'arithmetic'] }), every function literal, and every caller library with anevaluateformula, printed "Cannot read properties of undefined (reading 'bindings')": a function literal is made canonical with aBlockbody, andBlockwas in thecontrol-structureslibrary.Blockis now in thecorelibrary, and the library reference lists it undercore. -
ComputeEngine.getStandardLibrary()with a list of categories threw for every category exceptcore:getStandardLibrary('physics')gave "Library "physics" requires "arithmetic", which is not available", because the list held only the requested libraries. Each requested library now comes with the libraries it requires, directly or indirectly:getStandardLibrary('physics')givescore,arithmetic,unitsandphysics, in load order. -
A
ce.declare()of an operator that threw (for example acanonicalhandler together with thecommutativeflag) left the name bound to a placeholder value of typefunction. The name is now restored to the binding it had before the call, or is not declared at all. -
The numeric value of a definite integral whose integrand does not compile now uses adaptive quadrature. Before, such an integrand (for example an operator declared with only a JavaScript
evaluatehandler) went directly to a Monte-Carlo estimate with 1e4 samples. WithSqdeclared so thatSq(x)isx²for a numberx,Integrate(Sq(x), x, 0, 1).N()was0.3356 ± 0.0030; it is now0.3333333333333333 ± 6e-21, with 240 evaluations ofSqinstead of 10 000. The integrand is now integrated with the adaptive Gauss–Kronrod quadrature that a compiled integrand uses, with a smaller panel budget (at most 9 660 evaluations), and Monte Carlo is used only when the quadrature does not converge and its error bound is not better than the estimate of Monte Carlo. A divergent integral is nowNaN, as for a compiled integrand: withInv(x)=1/x,Integrate(Inv(x), x, 0, 1).N()was10.9 ± 1.4and is nowNaN. -
#394 Redeclaring an arithmetic operator with a copy of its definition changed its canonical forms and types, even when no field of the copy changed. This is the idiom the guide documents to extend a standard operator:
ce.declare('Sqrt', { ...ce.lookupDefinition('Sqrt').operator, evaluate }). It affectedAdd,Multiply,Negate,Square,Sqrt,Exp,Ln,Log,Power,RootandDivide. Afterce.declare('Add', { ...oldAdd }):Add(2, x, 5)gave2 + x + 5and now givesx + 7;Add(1, "s")was valid and is now invalid;Add(1, ImaginaryUnit)was typedintegerand is now typedcomplex; in1 + w,wstayedunknownand is now inferrednumber. After the same copy ofSqrt:Sqrt(8)stayedsqrt(8)and now gives2√2, and√2·√2now gives2. The definitions of these operators now have acanonicalhandler, and a copy takes it along. The standard canonical form applies only to a copy declared under the SAME name whose signature,lazyflag andassociative/commutative/idempotent/involutionflags are unchanged; a copy that changes onlyevaluateor metadata (description,wikidata,examples) is such a copy. A copy with another signature or another flag, or a copy declared under another name, does not use the standard canonical form: the copy keeps its own head and its ownevaluate. WithMyPowerdeclared as a copy ofPowerwithevaluate: () => 5,MyPower(x, 3)gavex^3(the head was replaced byPower, which had acanonicalhandler before this change) and now staysMyPower(x, 3)and evaluates to5. A definition that is not a copy (function Add(a, b) { … }, or a declaration with its owncanonicalhandler) still replaces the standard operator, as before.
0.144.0 2026-10-01
Behavior Changes
-
The
attributesentry ofAboutis a list, and it says when an operator is lazy. It was one string with the flags separated by spaces:About(Add)gave"commutative associative idempotent". It is now a list of strings, the algebraic flags and thenlazywhen the arguments of the operator are passed to it unevaluated:About(Add)gives["commutative", "associative", "idempotent", "lazy"], andAbout(Hold), which had noattributesentry, gives["lazy"]. A program that read the entry as a string must read it as a list. -
#397
Solveanswers an identity with a free parameter, and stays unevaluated when it finds no candidate root.Solve(x = x, x),Solve(0 = 0, x)andSolve(2(x + 1) = 2x + 2, x)were[]("no solution"); they are now[t], one solution for every value of the fresh parametert, the same form as the parametric answers of the integer and congruence solvers. An equation for which no strategy of the solver gives a candidate root (a·x⁵ + x + 1 = 0,sin(x) = x³ + eˣ, which has a real root) and an equation whose answer depends on another unknown (Solve(a = 0, x)) were also[]; they now stay unevaluated. A contradiction (x + 1 = x + 2) and an equation whose candidate roots are all rejected (√x = -1,sin x = 2,eˣ = 0) are still[]. The.solve()method is unchanged. A program that read[]as "no solution" for these equations gets the unevaluatedSolveor[t]instead. -
#397
Rangewith an exact rational bound or step enumerates exact values.Range(0, 1, 1/3)was[0, 0.333…, 0.666…, 1]; it is now[0, 1/3, 2/3, 1], as in Mathematica, and the two-sample form[1 + 4/d, 1 + 8/d...5]withd = 500starts at126/125, not1.008. A float bound or step, and a step that is a constant expression (π/4), still give floats, and.N()of the range gives floats.Sum(Range(0, 1, 1/3))is now2, not1.9999999999999999. -
#397
simplify()combines or splits logarithms only when their arguments are provably non-negative.ln(x) + ln(y)wasln(xy)andln(x/y)wasln(x) − ln(y)for any unconstrainedxandy, which is wrong for negative values: atx = y = -1,ln(x) + ln(y)is2πibutln(xy)is0. They now stay as they are, as doesln(1/x), which was−ln(x). An argument is used when it is non-negative by its value (ln(2) + ln(3)is stillln(6)), by its type, by an assumption (withassume(x > 0)andassume(y > 0),ln(x) + ln(y)isln(xy)), or because it is an absolute value. The same applies tolog_c.Solvestill combines the logarithms of an equation, and checks each root against the original equation:Solve(ln(x + 1) + ln(x − 1) = 0, x)is[√2]. The policy is indocs/SIMPLIFY.md.
These changes apply to non-strict parsing only
(ce.parse(latex, { strict: false })). Strict parsing is unchanged.
- An unbraced run of letters after
_is the whole subscript.x_maxwasx_m·a·xand is now the symbolx_max, the same asx_{max};T_maxisT_max, andy = x_1 + x_maxisy = x_1 + x_max. The run ends at the first token that is not a letter. A run of two letters followed by_,^or a digit keeps the previous reading, so that adjacent indexed symbols stay apart:a_nb_nis stilla_n·b_nanda_kx^kis stilla_k·x^k. Input that changes:x_ijwasx_i·jand is now the symbolx_ij, anda_nxwasa_n·xand is now the symbola_nx(writea_n xfor the product). - An unbraced exponent is one whole operand. As a run of digits already was
(
x^12), the exponent is now a number with its decimal part (x^2.5isx^{2.5}, wasx^2·0.5), a word read as one symbol (x^piisx^π, wasx^p·i;x^thetaisx^θ), or a bare function call (e^sin(x)ise^{sin(x)}, wase^s·i·n·(x)). The result is the same as the braced spelling. White space ends the exponent (x^2 yis stillx^2·y), and where the extent is not clear the reading is unchanged:x^2yisx^2·y,e^2piise^2·π,x^abisx^a·b. - A sign directly after
^starts the exponent. The exponent is the sign and one operand:e^-xise^{-x}(wasSuperminus(e)·x),2^-xis2^{-x},e^-sin(x)ise^{-sin(x)},e^-(x)ise^{-(x)}, ande^+xise^{+x}(wasPseudoInverse(e)·x). A second superscript is the same double-superscript error as ine^{-x}^2. When no operand follows the sign directly, the reading is unchanged:\Z^+is still the positive integers,A^+is still the pseudo-inverse, andx \to 0^+is still a one-sided limit point.x^-2is stillx^{-2}. - The words
inandinfinityare read as math.M in [0,1]isM \in [0,1](Element(M, Interval(0, 1)), wasM·i·n[0,1]), andinfinityisPositiveInfinitylikeinf(was the product of its letters).inis read this way only as a separate word between two operands:index,ink,intandxinkeep their previous reading.
New Features
-
A parse diagnostic for a letter run read as a product. In non-strict mode, with
diagnostics: true, a run of two or more letters that is not a known word and is read as a product of its parts now gives oneletter-run-splitdiagnostic:eps(e·p·s),sinx, a word of prose, orxpi(x·π). Its span is the run, anddetailis{ run, parts }, for example{ run: "eps", parts: ["e", "p", "s"] }. An explicit product (a*b*c) and a run read as one name (sin(x),alpha,foo(x)) give no such diagnostic, and neither do the differentials ofdy/dx. An unbraced exponent or subscript that takes only the first letter of a run is reported too:e^xyise^x·yand(x)_abis(x)_a·b. The diagnostic does not change the parse result. -
Three new opt-in parse diagnostics for non-strict input. With
ce.parse(latex, { strict: false, diagnostics: true }), the result'sparseDiagnosticsnow also reports:implicit-product-in-denominator: the denominator of a/is an implicit product, which binds tighter than/.1/2x,1/2 x,pi/2xandx/2 yare read as1/(2x),π/(2x)andx/(2y), which is not what every reader intends.1/2 * x,1/(2x),dy/dxand a call such as1/f(x)are not reported.spaced-digit-groups: white space between digits was read as one number (2 3is 23,1 000is 1000). The digit group separators\,and{,}are not reported.letter-before-decimal: a symbol directly followed by.digits(x.5) is read as the productx \cdot 0.5.
The readings do not change, and strict mode reports none of these.
Issues Resolved
-
Solvefound no root of a linear equation whose coefficient of the unknown is a sum:x − ax + a = 0,πx + x = 1andx = e(x − 1)gave[]. Only the shapeax + bwas recognized. They are now solved from their coefficients (1/(π + 1),e/(e − 1)). -
Solvefound no root of an equation with one logarithm of a non-linear argument, or with several logarithms of one base:ln(x² + 2x) = 3,log_2(x) + log_2(x + 2) = 3andln(x) − ln(x − 1) = 1gave[]. They now give−1 ± √(1 + e³),[2]and[e/(e − 1)]. The roots are checked against the original equation, so a root where a logarithm is not defined is rejected (log_2(x) + log_2(x + 2) = 3has no root−4). -
#397 Thirty-seven Wikidata ids, in the library and in
OPERATORS.json, named an unrelated item:PlanckConstantwasQ524(Mount Vesuvius),NorwasQ189561(narcolepsy). They now name the concept of the head, and a test checks thatOPERATORS.jsonagrees with the library. -
#397
LerchPhiat a negative integer order and exact operands stayed unevaluated where its value is 0:LerchPhi(-1, -1, 1/2)is now0, also under.N(), as in Wolfram. Fors = -n(n ≤ 12) and exactz ≠ 1anda,LerchPhi(z, s, a)is now the exact rational value of its closed formaⁿ/(1 − z) + Σⱼ C(n, j)·aⁿ⁻ʲ·Li₋ⱼ(z):LerchPhi(3, -4, -5/2)is-5725/32. -
Solve(eˣ = 3, x)gave the float1.0986…instead ofln(3), andSolve(e^(2x) = 5, x)gave[]. TheSolveoperator replaced the constantExponentialEby its float value before solving. A constant that keeps its value until.N()(ExponentialE,Pi) now stays symbolic, and the answers are[ln(3)]and[ln(5)/2]. -
#398
Aboutnow reports theexamplesandkeywordsof a definition, each as a list of strings:About(Sin)includes"examples" -> ["Sin(Pi / 6)", "Sin(1)", "N(Sin(1))"]and"keywords" -> ["sine"]. The boxed operator and value definitions did not keep theexamplesof the definition they were made from. They now keep them as a list of strings; a definition that gives one string is stored as a list of one string. -
#388 A function literal in compiled JavaScript was wrapped in a broadcast dispatch even where no argument could be a list. Two cases now compile to the bare arrow function. A callback fed the elements of a
Rangewhose start and step are literal numbers (Map(q ↦ …, Range(1, Length(s)))) receives a finite number at every call, so the wrapper and theNaNand absence tests on its parameter (q === q,typeof q === 'number') are gone. AnApplyof a literal to arguments that are scalars by construction (a number, a declared scalar input, a loop index) no longer builds the wrapper and its closure at each evaluation. A list argument still broadcasts, as in the interpreter. -
#387 On the JavaScript target, a compiled
Rangeis built in a loop, and aMap, aFold/Reduceor aSum(Map(…))over a finiteRangebuilds no range at all. ARangecompiled toArray.from({length: n}, (_e, i) => a + i * s), which is about 18 times slower on V8 than a preallocated array filled by a counted loop. The compiled code now calls a run-time helper,_SYS.range(a, b, s), that fills the array in a loop. AMapover a finite range, aReduceover one (Foldis its canonical form) and theSumorProductof aMapover one now walk the range with the counted loop that predicates over a range already use (issue #373).Fold((acc, k) ↦ acc + k, 0, 1..n)at n = 1000 went from about 38 µs to under 1 µs. The values are unchanged: the element count is the interpreter's own, elementiisa + i × s, the elements are folded in range order, a seedless fold over an empty range isNaN, and an infinite bound at run time still throws aRangeError. A range that is shared by common-subexpression elimination, or has a non-finite bound, keeps the array lowering. -
#393 A library given in the
librariesconstructor option with norequireslist loaded before the standard libraries listed before it. The libraries were sorted so that every library with no dependencies came first, whatever its place in the list. A caller library whose definitions use a function literal (evaluate: ["Function", …]) then madeBlocka plain symbol beforecontrol-structurescould define it: the engine printed "Duplicate operator definition: Block" and stayed broken (x := 2; x + 1gave{2; 3}). The libraries now load in the order of the list, and each library loads after the libraries in itsrequireslist. The order of the standard libraries does not change. -
#393
Ddid not differentiate an operator defined by a library given in thelibrariesconstructor option: withSqdefined asx ↦ x²,D(Sq(x), x)gaveApply(Derivative("Sq", 1), x). Such a library is installed in the same scope as the standard library, soDtook its operators for built-in ones.D(Sq(x), x)now gives2x, as it does whenSqis declared withce.declare(). -
Dused the library rule for a user function with the name of a library function: after\operatorname{Sinh}(x) := 3x,Sinh(2)is6butD(Sinh(t), t)gavecosh(t). It now gives3. -
#394
SetMinus,Length,Count,IsEmpty,Contains,AppendandSlicevalidated their operands against a copy of their signature text, not against the signature of their definition. A host that redeclared one of them with a wider signature and kept its handlers (ce.declare('SetMinus', { ...ce.lookupDefinition('SetMinus').operator, signature: '(value, value*) -> set' })) still had the old validation:SetMinus(5, 2)stayed anincompatible-typeerror. These operators now read the signature of the definition in effect. A stock engine gives the same results as before. -
The
resolveApplicationhook sees every name before a parenthesis in non-strict mode. A multi-letter name (foo(x)) or a spelled-out Greek letter (gamma(x),alpha(x+1),theta(x)) was read as a name before the hook could see it, so a host could not choose the reading. The hook is now called for these names with the same precedence rules as forf(x): explicit declarations, function parameters andresolveSymbolfacts take precedence, and a name with a library definition (Gamma(x),pi(x),sin(x)) is not submitted. When the hook returnsundefined, the reading is unchanged. Reported by a host. -
Parsing a deeply nested exponent in non-strict mode is fast. Each
e^{…}was read twice, so the time doubled with each level of nesting ine^{-(e^{-(…)})}. Each exponent is now read once. -
The bare names
signandsgnare the sign function. In non-strict mode,sign(x)andsgn(x)were read as an undefined functionSgn. They are nowSign:sign(-2)evaluates to-1. -
A chain of
<=,>=or!=parses as a chain.0.1 <= M <= 5gaveLessEqual(0.1, Equal(Less(M, Error(missing)), 5)): the right operand of the first<=read the<of the second<=asLess, because<binds tighter than<=. The parser now considers only the longest operator that matches the input, and stops the operand when that operator binds too loosely.0.1 <= M <= 5isLessEqual(0.1, M, 5), the same as0.1 ≤ M ≤ 5and0.1 \le M \le 5, and5 >= M >= 0.1isGreaterEqual(5, M, 0.1), the same as5 ≥ M ≥ 0.1. This applies in strict and non-strict mode. A chain of\ge,\geq,\geqslant,>or\gtis also one flat expression in the raw form now (5\ge M\ge 0.1isGreaterEqual(5, M, 0.1), it wasGreaterEqual(5, GreaterEqual(M, 0.1))), as a chain of\leor<already was. The canonical form is unchanged. -
More plain-text operator spellings in non-strict mode.
x ÷ 2isDivide(x, 2), asx / 2andx \div 2are.a =< bisLessEqual(a, b)anda <> bisNotEqual(a, b). In strict mode these spellings are not operators, as before. -
√(x+1)isSqrt(x+1)in non-strict mode, assqrt(x+1)is. It was the juxtaposition of theSqrtfunction and the parenthesized group. Strict mode is unchanged. -
Parentheses in a fraction or an exponent are not written twice. A structural or raw expression that kept the parentheses of its source, such as
y = 1/(1+x^2), was writteny=((1+x^2))^{-1}. It is now writteny=\frac{1}{1+x^2}: the\fracarguments and the superscript braces already group their content, sox^(1/2)is writtenx^{\frac{1}{2}}, notx^{(\frac{1}{2})}. Parentheses elsewhere are kept. -
\operatorname{arccot}(x)is the inverse cotangent. It was a call of an undefined functionarccot; only the spellingarcctgwas read asArccot. Both spellings are nowArccot, also as\mathrm{arccot}. -
Scientific notation with the base written
{10}.2\times{10}^{-1}is the number0.2, the same as2\times10^{-1}, and4.35\times{10}^2,2\cdot{10}^{3}anda/2\times{10}^3read the same as the spellings without braces.{10}^{3}with no mantissa, a symbolic exponent (2\times{10}^{n}) and another braced base (2\times{11}^{3}) are unchanged.
0.143.0 2026-10-01
Behavior Changes
-
A symbol declared with a nested list type is the matrix it describes.
list<vector<integer^3>^2>(two rows of three integers) was not a subtype ofmatrix<integer^(2x3)>, so a function declared(x: matrix<T^(MxN)>) -> N where T, M, Nadmitted it without reading its lengths (the call was typedinteger<1..>instead of3), and a square constraintmatrix<T^(NxN)>admitted a 2×3 list. The nested spelling is now read as its flat shape at a target with two or more dimensions. A value changes with it: withxdeclaredlist<list<integer^2>^2>,x^2is now the matrix power ([[7,10],[15,22]]for[[1,2],[3,4]]), as it is for a matrix literal, where it was the square of each element. -
Sumof a list of points or rows has the type of a point or a row.Sum(pts)withptsdeclaredlist<tuple<real, real>>was typednumber, but its value is a point:Sum([(1, 2), (3, 4)])is(4, 6). It is now typedinteger | tuple<real, real>(a list with no length may be empty, and the sum of an empty list is0). A list of rows sums to a row and a matrix to the row of its column sums:Sum([[1, 2], [3, 4]])is typedvector<integer^2>. A consumer that used thenumbertype to read the compiled value as a scalar was wrong for such a sum. -
MaxandMinof a dictionary are anincompatible-typeerror. They walked the dictionary as a collection and compared its keys with its values:Max({"a" -> 3, "b" -> 5})wasmax(5, "a", "b"). An entry is not a number, soMax,Min,SupremumandInfimumof a dictionary that has an entry now give the error thatSumandMeangive, which names the first entry:Error(incompatible-type, number, tuple<string, integer>). A program that read the unevaluatedmax(…)result gets the error instead. An empty dictionary contributes no value, as an empty list does. A dictionary that is an ELEMENT of a list gives the error that names the whole dictionary, asSum([1, d])does. The error is the answer whatever the order of the operands:Max(NaN, d)wasNaNwhileMax(d, NaN)was the error. -
Sumof one element that is not a number is an error. A sum of one element answered the element itself:Sum([{"a" -> 3}])was{"a" -> 3},Sum([True])wasTrue, andSum({"a" -> 3})(a dictionary of one entry) was the entry("a", 3), while two such elements gave anincompatible-typeerror. One element now gives the same error as two. A point or a row is still summed:Sum([(1, 2)])is(1, 2).
Issues Resolved
-
Registering a chain of functions that each call the next one twice took a time that doubled with each level: the effects inference walked the body of a called function once per call, so the last function of a chain of 12 was walked 2¹² times. A function already walked for the same definition is now skipped, and the work grows polynomially with the depth: 5 666 reads of the declared signatures at depth 12 (from 89 794) and 20 962 at depth 20.
-
The compiled
SumandProductof a list of points, rows or matrices with complex entries gave wrong values behindsuccess: true: the sum of the points[(1+i, 2), (3+i, 4)]was["0[object Object][object Object]", 6]and a product of complex rows or matrices hadNaNentries. They now matchevaluate(). The compiler chooses a real-only, a complex, or (for a shape known only at run time) a dispatching form of the element-wise helper from the type of the operand, as for the other linear-algebra helpers. -
A compiled expression over the
Sumof a list of REAL points read each coordinate as a complex number:Sum(P) + (1, 1)gaveNaNcoordinates whereevaluate()gives(5, 7). It is now correct. -
Productof a list of rows or square matrices was typednumber; it is now typed as a row or a matrix (Product([[1, 2], [3, 4]])is[3, 8]), and a compiledProduct(Q) + 1no longer adds1to the matrix as if it were one number.SumandProductof an abstract collection or a set of rows or matrices, and of a matrix whose size is not known, are typed by shape too, and so is the sum or product of elements typedbroadcastable<T>. -
Shapes the JavaScript target cannot compute now refuse to compile instead of giving
NaNor a string: a scalar times theSumof points (2·Sum(P)), a complex parent over a fold of complex rows or matrices,Multiplyof two complex rows or matrices read withAt,Productof matrices known not to be square, andSumover elements that are abstract collections. With the default fallback,evaluate()answers them. -
Sum()andProduct()with no operand logged an internal exception during canonicalization. The operand is now reported missing, as forMean(). -
#386
ReplaceAt,DeleteAtandInsertdid not compile to JavaScript:compile(ReplaceAt(s, 2, 9))failed with "target 'javascript' has no lowering for it", so aFoldwhose step replaced one element of a list did not compile. Each now compiles to a copy of the array with the interpreter's index rules (1-based, a negative index counts from the end,Inserttakes positions 1 to n + 1). An index for which the interpreter leaves the expression unevaluated (zero or out of range) throws aRangeErrorat run time. A string operand is walked as its characters, as in the interpreter:DeleteAtanswers a string,ReplaceAtandInserta list of characters. -
#386
ReplaceAt,DeleteAtandInsertalso compile to Python, with the same index rules. An index for which the interpreter leaves the expression unevaluated raises anIndexError. A string operand does not compile to Python, as for the other list operators. -
Compiled statistics of a list of lists gave wrong values. On the JavaScript target
Max,Min,Mean,Median,Variance,Mode,Quartilesand the other statistics of[[1, 2], [3, 5]]answerednullor a wrong list, andMean([2, 3], [5, 7])answeredNaN; on the Python targetMeanandMedianof a list of lists answered a number. The interpreter answers 5 forMax, 17/4 for the two-listMean, and anincompatible-typeerror forMean([[1, 2], [3, 5]]). These no longer compile, so the interpreter answers. A JavaScriptReduceorScanwithAddorMultiplyover a list of lists now adds or multiplies the rows element by element, asSumdoes (Reduce(s, Add)joined two rows as the string"1,23,5"); a built-in fold over points, strings, sets or dictionaries, and every built-in fold over rows on the Python target (where+joins two lists:Scan(s, Add)gave[[1, 2], [1, 2, 3, 5]]), no longer compiles. A fold whose step was typed for a list of numbers by its uses (Sum(acc)) but whose seed is a list of lists no longer compiles, because the step's arithmetic would join rows as strings. A row type given through a type name (list<Row>) is recognized by all these checks. A fold step whose element parameter is namediwas compiled as ifiwere the imaginary unit:Reduce(L, (acc, i) => acc + i, [0, 0])over rows answered[{re: null}, {re: null}]; it now answers[4, 7]. -
.N()of aReducewith no initial value over a list of lists wasNaN:Reduce([[1, 2], [3, 4]], Add).N()is now[4, 6], asevaluate()gives. The compiled code of the same expression, which used that value, was the constantNaN. -
#386 A compiled
Foldwhose step replaced elements of a list accumulator copied the whole list at each step, so a fold that visits each element once took time proportional to the square of the length (730 ms for 40,000 elements). When the step can only return the accumulator or a chain ofReplaceAton it, and reads it nowhere else except through an element read or an aggregate (At,Length,Sum, …), the compiled fold now copies the seed once and updates that copy in place (1.7 ms for 40,000 elements). A step that swaps two slots,ReplaceAt(ReplaceAt(acc, i, acc[j]), j, acc[i]), reads both old values before it writes. Any other step keeps the copying form. The result is the same, and the caller's list is not changed. This applies to the JavaScript and Python targets. -
The compiled call of a function with an annotated parameter did not check an absent argument.
function f(p: tuple<number, number>) { p[1] + 1 }called withfirst(filter([(1, 2)], c => c[1] > 9))(no element passes, so the argument is absent) is anincompatible-typeerror in the interpreter, but the compiled JavaScript answeredNaN; alist<number>parameter threw aTypeErrorfrom inside the body. The compiled call now stops the run with aTypeErrorthat names the parameter and its type, for a parameter annotated with a type that is not numeric and has nomissingmember. A numeric parameter still reads an absent value asNaN, as the interpreter does. -
HurwitzZeta(s, a).N()of an integer order at anamore than about 10⁶ left of the imaginary axis stayed unevaluated:HurwitzZeta(2, −10¹² + i)is now−0.0739998067554724…, computed with the polygamma reflection ζ(s, a) = (−1)^s·ψ⁽ˢ⁻¹⁾(a)/(s − 1)! for an order from 2 to about 10⁴, and with the Bernoulli polynomial ζ(−n, a) = −Bₙ₊₁(a)/(n + 1) for an order −n ≤ 0. The cost of both does not depend ona.Zeta(s, a)of an even order follows. An order that is not an integer still stays unevaluated there.PolyGamma(1, −5 + 10²⁰i).N()ran for minutes, and now answers at once. -
#385 The JavaScript compilation of
MaxandMinover an operand typed as an abstract collection (collection,collection<integer>, a set, orcollection<any> | number) gaveMath.max(w), which isNaNfor a list, withsuccess: true.evaluate()gives the maximum.Max,Min,Length,Count, and the collection form ofSumandProductneed neither the positions nor the order of the elements, so such an operand now compiles. At run time it accepts a list, a JavaScriptSetor a numeric typed array, and a value that is none of these stops the run with aRangeError.MaxandMinalso stop with aTypeErroron an element that is not a real number (a complex value has no order). A single number is read as a collection of one element where the interpreter accepts one: at a parameter typedcollection<any> | number, or whose typecollectionwas inferred from its uses (k(L) := Sum(L),k(4)is4).LengthandCountof such an operand were refused before; they now compile.At,Reverseand the other operators that read positions or order still refuse an abstract collection, and all of them refuse a dictionary. -
The compiled
SumandProductof a list of complex values whose type is not known at compile time (the result of a function typedunknown) combined the elements with+, and the sum of[1+2i, 3+4i]was the string"0[object Object][object Object]". They now add and multiply complex values. -
w.mul(ce.Zero)andce.Zero.mul(w)folded to0for a variablewwith an assigned value, whilew.mul(0)andce.box(['Multiply', 0, 'w'])keep the product0w. Withw := NaN, the folded0hid theNaN. All the spellings now keep0w, which evaluates with the valuewholds then:NaNforw := NaN,0afterw := 4. A free symbol still folds:0xis0. -
Join(orAppend) of a dictionary and a value that is not a key-value entry reported an error that named the internal symbolContinuationPlaceholder:Join(Dictionary(x: 1), [2, 3])gaveError(incompatible-type, tuple<string, unknown>, "symbol ContinuationPlaceholder"). The error now names the element:Error(incompatible-type, tuple<string, any>, 2). -
simplify()leftMax(x, NaN)andMin(x, NaN)unchanged, whileevaluate()givesNaN.simplify()now givesNaNtoo. -
A function declared with a parameter that admits an absent value could not be assigned a function literal with a bare parameter:
ce.declare('f', { signature: '(string | missing) -> unknown' })followed byce.assign('f', ce.box(['Function', ['IsMissing', 's'], 's']))threw "not compatible". A bare parameter accepts an absent value, so the assignment is now accepted, andf(Missing)isTrue.
0.142.0 2026-09-30
Behavior Changes
-
A rounding function,
Fract,Mod,Sign,Heavisideor a comparison of an exact constant expression is decided by raising the precision.Floor(π·10³⁰)stayed unevaluated and its.N()was the 21-digit float3.14159265358979323846e+30; it is now the exact integer3141592653589793238462643383279, as in Mathematica, andFract(π)isπ − 3. A comparison of exact constants was decided on 21-digit values, which cancellation can make wrong:√(10⁶⁰ + 10⁴⁰) − 10³⁰ > 5·10⁹wasTrueand is nowFalse(the value is4.99999…·10⁹). The engine computes the operand with an error bound at the working precision plus 10 digits, then with more digits, up to 100 more, until the result is certain. When it is still not certain, the value is very probably at the jump:evaluate()leaves a rounding function,SignorHeavisideunevaluated, a comparison is equal, and.N()uses the value at the jump (Floor((√2 + √3)² − 2√6).N()was4and is now5). -
.N()of a rounding function,Fract,Mod,Sign,Heavisideor a comparison uses the exact value of an exact operand..N()approximated the operand first, and near a point where the result jumps, a tiny error changed the result by a finite amount:Floor((25! − 1)/24!).N()was25(the value is just below25, and its 21-digit approximation is25), and(1/2 − 10⁻³⁰ < 1/2).N()wasFalse. They are now24andTrue, as in Mathematica, whereN[Floor[(25!-1)/24!]]is24...N()promises a result that is correct to the working precision, not that every intermediate step is a float. The operand is still approximated first; its exact value is used when it is an exact number (an integer, a rational, or a rational multiple of a square root), or when its approximation is within about10⁻¹⁰(relative) of a point where the result jumps. The result of a rounding function under.N()is a float, as before:Floor(25! − 1).N()is25! − 1approximated to the working precision.Fract((25! − 1)/3).N()was0and is now0.666666666666666666667. A comparison with a float operand is still decided at the precision of the float:(1/2 − 10⁻³⁰ < 0.5).N()isFalse. -
Two exact numbers are compared exactly, with no tolerance. The comparison operators and the methods
.isEqual(),.isLess(),.isLessEqual(),.isGreater()and.isGreaterEqual()compared two numbers whose difference was less thance.tolerance(10⁻¹⁰) as equal, also when both were exact, butLessandGreatersometimes compared exactly:Equal(1/2 − 10⁻³⁰, 1/2)andLess(1/2 − 10⁻³⁰, 1/2)were bothTrue. Two exact numbers (integers, rationals, rational multiples of a square root) are now equal only if they have the same value:Equal(1/3, 3333333333333/10^13)wasTrueand is nowFalse, and exactly one of<,=and>holds for two exact numbers, as in Mathematica. The tolerance still applies when a float is involved:Equal(1/10, 0.1)isTrue..is()keeps the tolerance for exact numbers too. -
The interval target encloses
BinomialandFactorialas functions of real operands. It used to round each operand to the nearest integer, so its enclosure did not contain the value the interpreter and the JS target give at a non-integer point:Binomial(5.5, 2)gave[15, 15]instead of12.375,Factorial(2.5)gave[6, 6]instead ofΓ(3.5) ≈ 3.323, andFactorial(-0.5)was empty instead of√π. A plot ofBinomial(x, 2)orx!showed steps. NowFactorial(x)is enclosed asΓ(x + 1), with a pole at each negative integer.Binomial(n, k)is enclosed through the falling factorialn(n−1)⋯(n−k+1)/k!for an integerk ≥ 0. Wheren + 1,k + 1andn − k + 1are positive over the box,C(n, k)is monotone innand log-concave ink, so the enclosure is the true range (Binomial(n, k)overn ∈ [4, 6],k ∈ [1, 3]is[4, 20]). Elsewhere it is the product of enclosures ofΓ(n+1),1/Γ(k+1)and1/Γ(n−k+1). For a negative integerkthe result is a jump:C(n, k)is0except at the negative integersn ≥ k. Integer points stay exact. What is lost: an interval ofnthat contains a root of the polynomial gets a wider enclosure (Binomial(n, 2)over[0, 3]is[−1.5, 3]; the true range over the reals is[−0.125, 3]), theΓproduct is wider than the true range (Binomial(5, k)overk ∈ [2, 7]is[−67.8, 67.8], true range[−0.022, 10.87]). Anninterval that contains a negative integer, together with akinterval or a non-integerk, is reported as a pole:Cis unbounded there. The interval enclosure of the falling factorial (kup to 4096) no longer excludes the value when a partial product overflows:Binomial(n, 1000)overn ∈ [1100, 1100.5]gave[1.8e308, ∞]for a value of1.4e144. -
evaluate({ materialization: true })gives every element of a finite lazy collection (#380, reported by enumeratio). The option was documented as "iftrue, and the collection is finite, it is fully materialized", buttruegave the display preview: the first five and the last five elements with aContinuationPlaceholderbetween them.Range(1, 20)evaluated to[1, 2, 3, 4, 5, …, 16, 17, 18, 19, 20]and now evaluates to the 20 elements. A finite collection with more elements thance.maxCollectionSizestays in its lazy form, and an infinite collection, or one whose finiteness is not known, still gives the preview.toString(),.latexandtoLatex()without amaterializationoption still print the preview. To get the preview fromevaluate(), passmaterialization: [5, 5].toLatex({ materialization: true })now prints every element, as its documentation says. -
A function that annotates some of its parameters enforces only the annotated ones at a call. With
function k(p, n: number) { p[1] + n }, the type ofpin the reported signature,indexed_collection<number>, is inferred from the usep[1]. One annotation made the whole derived signature a contract, so that inferred type was enforced at every call, while the same function with no annotation is not validated at all. A call such asfill(a, b, c, 2, xs)withctypedmissing | tuple<number, number, number>(afirst(filter(…))result) was refused at an inferred slot although the interpreter runs it. A bare slot now admits any argument, as it does when nothing is annotated; an annotated slot is enforced as before, and the reported signature is unchanged. What is lost: an argument that cannot fit a bare slot of a partly annotated function (k(5, 1)) is reported when the call runs, not when it is boxed. Annotate the parameter to keep the static check. The same rule holds for a function declared with placeholder slots and then assigned its body, which is how a host declares a function before its body is known: withfdeclared(unknown) -> unknownand assignedp ↦ p[1] + 1, the reported signature is(indexed_collection<number>) -> number, andf(First(xs)), whose argument is typedmissing | tuple<…>, was refused at boxing. It is now admitted, answers the value when it is present andNaNwhen it is absent, as the same body does with no declaration. A slot declared with a type ((unknown, number) -> unknown) is enforced as before. -
An argument whose components are typed
unknownis admitted at a declared parameter. A whole argument typedunknownwas always admitted, since it claims nothing a declaration can refute; a composite withunknowncomponents was refused.[(A[1], A[2], A[3], 1)], typedlist<tuple<unknown, unknown, unknown, integer>>while the type ofAis not known, is now accepted at a parameter declaredlist<tuple<number, number, number, number>>, and checked when the call runs. A component typedanyis still refused (it admits the absence markers), and so is a known component that does not fit. A generic signature ((tuple<T>) -> T where T: number) admits the same arguments as the ground one; signature subtyping is unchanged. -
An absent argument at an annotated parameter of a function is an error. With
function f(p: tuple<number, number>) { p[1] + 1 }, the callf(first(filter(xs, c => c[1] > 9)))has the argumentMissingwhen the filter finds nothing. It answeredNaN(Missingwhen the body returns a point): a function whose parameters are all numbers or collections answered the absence marker without running its body. It now answersError(ErrorCode("incompatible-type", "tuple<number, number>", "missing"), "Missing"), as the same function declared withce.declare("f", { signature: … })already did. The rule applies to a function literal with at least one annotated parameter (an Epsilfunction, a lambda(p: T) ↦ …). An absent value at a parameter annotated with a type that has nomissingmember is an error. A parameter annotated with a numeric type (number,integer,real) reads the absent value asNaN, the absence marker of a numeric domain, sof(x: real) = x + 1still answersNaNforf(Missing). At a parameter with no annotation the body runs with the absent value, as before. The type of such a call no longer has amissingmember (list<…>, notlist<…> | missing), soCountandLengthover its result compile. What is lost: a call that relied on the quietNaNorMissingfor an absent point, list or string argument. To accept absence, annotate the parameterT | missingand testisMissing(p), or remove the absent case at the call (first(…) ?? fallback). At boxing, an argument typedmissing | Tis still admitted at a parameter annotatedT; the literalMissingat a parameter that is not numeric is anincompatible-typeerror at boxing. Library operators are not affected (Sin(Missing)isNaN). -
A user function accepts
NaNat a parameter typedintegerorreal.function f(n: integer) { n + 1 }called with aNaNvalue (a restricted number whose condition fails,first(filter(xs, p))over numbers when the filter finds nothing) answeredError(ErrorCode("incompatible-type", "integer", "NaN"), NaN), whilef(n: number)answeredNaN, and so did the same body declared withce.declare("f", { signature: "(integer) -> unknown" }). The literalf(NaN)was refused at boxing on both routes. A user function now acceptsNaN(andIndeterminate) at every parameter typed with a numeric type, and its body computes with it: all of these answerNaN. A number that is notNaNis checked as before (f(1.5)is an error forn: integer). Library operators keep their ownNaNpolicy. What is lost: a call that relied on the error to detect aNaNargument at anintegerorrealparameter; testisNaN(n)in the body. -
The Epsil static check reports an argument that may be absent at an annotated parameter. With
f(p: tuple<number, number>), the callf(first(filter(xs, p)))is reported before the program runs: "expectedtuple<number, number>, gotmissing | tuple<…>". The program still runs. Nothing is reported at a parameter with no annotation, at a parameter annotatedT | missingor with a numeric type, or when the absent case is removed with??. The engine (ce.box,ce.parse, compilation) admits the argument as before. A function literal bound withletorconstis checked as afunctiondefinition is, and the inferred type of its bare parameters is no longer enforced by the static check:let k = (p, n: number) => p[1] + nfollowed byk(5, 1)is reported when the call runs. -
An assignment statement stores the elements of a lazy collection that reads a variable.
Filter,Map,Scan,TakeWhileand a comprehension are lazy, and their value kept by name each variable it reads. So afterlet ys = filter(xs, c => c > 1), a laterxs = [7, 8]changedys;xs = filter(xs, c => c > 1)madexsread itself and every later read (xs,length(xs),first(xs)) stayed unevaluated;xs = [c * 2 for c in xs]overflowed the stack; and infor k in 1..3 { xs = filter(xs, c => c > k) }the predicate keptkby name after the loop. An assignment statement (Assign,Declarewith a value: the Epsil=andlet, the LaTeX:=) now stores the list of the elements (a set for a collection over a set) when the collection is finite and reads a variable, computed with the values the variables have at that statement. This is what compiled code already did. Two cases stay lazy: a collection with no last element (filter(1..oo, p), still a live view of the variables it reads), and one that reads no variable (map(f, 1..10^6)). The host functionce.assign()is not changed: it stores the value it is given, so a host that defines one name from another keeps the live view. A lazy collection over a dictionary also stays lazy (it is a dictionary, not a list). What is lost: a program that relied onysfollowingxsafterlet ys = filter(xs, p); a large finite lazy collection that reads a variable is computed at the assignment, not at its first read; and a callback with an effect, in such a collection, runs for every element at the assignment and not at each later read. A lazy collection over a literal source (let s = map(x => f(x), [1, 2, 3])) reads no variable and keeps the documented laziness: the callback runs only for the elements that are read. -
A declared scalar parameter is checked against the evaluated argument. For a function declared with
ce.declare(name, { signature })and then assigned its body, an argument was checked at boxing, against its static type. When that type is not known before evaluation, a scalar parameter accepted any value:fdeclared(integer) -> unknownwith bodyx ↦ x + 1answered2.5for the value1.5, a declared(real)accepted1 + 2i, a declared(string)receivedMissingand a declared(boolean)received5. The evaluated arguments are now checked against the scalar parameter types of the declaration, and a value that does not fit answersError(ErrorCode("incompatible-type", "integer", "1.5"), 1.5), as an Epsilfunctionwith the same annotation does. In a broadcast over a list the error is in the cell that does not fit. Not changed: a list, a range or a point at a scalar parameter is still broadcast (f([1, 2, 3]),f((1, 2))); a symbolic argument is left alone; a slot declaredunknownoranychecks nothing;NaNand an absent value at a numeric parameter answerNaN. What is lost: a call that relied on a declared scalar type being advisory. -
First,Second,Third,LastandAtwith a literal index are typed without an absent member when the element exists.First([(1, 2), (3, 4)])was typedmissing | tuple<integer, integer>andFirst([7, 8, 9])wasinteger | nan. They aretuple<integer, integer>andinteger: the absent member is stated only when the access can find nothing. The element is proved to exist from a list type with a length (list<T^3>, which is the type of a list literal), from a string literal, or from aRangewith literal bounds.First(Filter(xs, p)),First(xs)forxsdeclaredlist<T>,xs[i]with a variable index and an index past the end (At([7, 8, 9], 99)) keep the member. What to check: a pinned printed type of such an access.type.matches("number")answers as before. -
A parenthesized derivative operand is delimited by its parentheses (#336).
\frac{d}{dx}(x)+1parsed asD(x + 1, x): the operand of a Leibniz fraction took the whole sum that followed it, so the+1moved inside the derivative, and a product or quotient written after the parentheses (\frac{d}{dx}(x)\cdot 2) was absorbed the same way. An operand that starts with a parenthesis, plain or sized ((…),\left(…\right),\bigl(…\bigr)), now ends at that parenthesis:\frac{d}{dx}(x)+1isD(x, x) + 1and evaluates to2,\frac{d}{dx}(x)\cdot 2is2·D(x, x). A superscript, prime or factorial attached to the parenthesis stays inside the operand (\frac{d}{dx}(x+1)^2is stillD((x+1)^2, x)). The rule covers\frac{\partial}{\partial x}(…)and the higher-order\frac{d^n}{dx^n}(…)forms. What changes meaning: a factor juxtaposed after the parenthesis is now outside the derivative,\frac{d}{dx}(x+1)(x-1)isD(x+1, x)·(x-1)where it wasD((x+1)(x-1), x); write\frac{d}{dx}((x+1)(x-1))for the derivative of the product. An operand without a parenthesis keeps its term extent:\frac{d}{dx}x^2+1is stillD(x^2 + 1, x), the\int … dxintegrand convention, and so does an operand in another enclosure (\frac{d}{dx}|x|+1isD(|x| + 1, x)). -
A
Range, aLinspace, a comprehension and aTabulateare typedlist<T>; they wereindexed_collection<T>. A symbol declaredlistorlist<number>refused every one of them:ce.declare("L", "list")followed byce.assign("L", ce.box(["Range", 0, 5]))threw a type error, and the only declaration that accepted a document's ranges wasindexed_collection, which also admits a tuple and a string.Range(0, 5)andRange(0, n)withnan integer are nowlist<integer>,Range(0, 1, 0.1)islist<real>,Linspace(0, 1, 5)islist<real>(it was a bareindexed_collectionwith no element type), a comprehension is a list of its body type and a tabulation is a list of its generator's result. An index span (Range(1, 5), therangetype) is now a subtype oflist<integer>, so it is accepted by the same declarations; it was a sibling oflist. Thelisttype makes no claim about the length:Range(1, +oo)is a lazylist<integer>, exactly as the lazyMapover it,Repeat(x),Cycle(xs)andIterate(f, x)already were; whether a collection is finite is a property of the value (isFiniteCollection), never of the type. What changes for a host: a check such astype.matches("indexed_collection")still holds for every one of these values (list <: indexed_collection), while a check that a value is NOT a list, or a pinned printed type such asindexed_collection<integer>, reads differently. (Tycho row 337.)
New Features
-
A type variable in a collection's length slot (#364, proposed by enumeratio). A
wherevariable may now name a length:(a: vector<real^N>, b: vector<real^N>) -> real where Nrejects a call with mismatched lengths at the call site (dot([1,2,3], [4,5])reportsexpected vector<real^3>, got vector<integer^2>on the second operand), and(matrix<T^(MxN)>, matrix<T^(NxP)>) -> matrix<T^(MxP)> where T, M, N, Pstates the shape of its result. The same variable at a parameter or result position is the integer with that value, bound from a literal argument:(n: N, x: vector<T^N>) -> T where T, Nacceptsfoo(3, [1,2,3])and rejectsfoo(4, [1,2,3]), and(list<T^N>) -> Ntypeslen([1,2,3])as3. A length variable is solved by equality across positions, may carry an integer bound (where N: integer<2..>), and an operand whose type states no length is admitted as it is at a literal length. A nominal type may take a length parameter:type permutation<N> = list<integer^N>makespermutation([2,1,3])apermutation<3>, unrelated topermutation<4>; an unsolved length prints as the familypermutation<integer<1..>>. In the Epsil language the same spellings work on afunctionhead, with a trailingwhereclause or the<N>binder. Not supported yet: arithmetic between lengths (^(M+N)). The parameter kind is a general value kind, so boolean and string parameters can follow once those literals carry singleton types. One reading changed with it: a dimensioned pattern now binds its element variable after peeling as many axes as it states, so(x: matrix<T>) -> T where Ton a 2×2 matrix bindsTto the scalar element (integer) rather than to a row; no built-in signature was affected. -
More spellings of the arc-minute and arc-second markers in DMS angles (#338, contributed by yelliver). After a degree marker (
°,\degree,^{\circ},^\circ), the minutes now accept',\prime,^{\prime}and^\prime, and the seconds accept",\prime\prime,\doubleprime,^{\doubleprime},^{\prime\prime}and^\doubleprime(and, added alongside, the ASCII''). The superscript spellings were parsed as products of primes (9^{\circ}30^{\prime}wasDegrees(9) · Prime(30)); every spelling of9°30'15"now parses to the exact2281/240degrees. Thesiunitxangle units\arcminuteand\arcsecondare accepted as markers too (9^\circ 30\arcminute 15\arcsecond); they gave anunexpected-commanderror. Thesiunitxcommands\minuteand\secondare deliberately not accepted: in that package they are the time units, not the angle units. -
ce.conformsTo(type, protocol): a public conformance query (#362, contributed by enumeratio). Answers whethertypeconforms toprotocol, inheritance and conditional conformance included, without calling one of the protocol's members and readingprotocol-implementation-missingas "no".typemay be a plain type string, parsed the wayce.type()parses one; an unknown protocol name answersfalserather than throwing.
Issues Resolved
-
.N()of an exact constant raised to a large integer power is correct to the working precision. The error of the approximated base was multiplied by the exponent:(π^1000000).N()was7.45923232449144786258e+497149, wrong from the 18th digit; it is now7.45923232449144786349e+497149. The base is computed with aboutlog₁₀|k|more digits for an exponentk. -
HurwitzZetaand the two-operandZetaare accurate at a complex order with a large imaginary part, stay unevaluated where the double-precision result would be wrong, and no longer hang on an extreme argument.HurwitzZeta(-0.93+28.85i, 0.1334).N()was1.5e8 + 5.4e9iand is now-10.482 - 5.546i;HurwitzZeta(-0.22+12.28i, -6.93-4.78i).N()was off by 1e−9 and now staysHurwitzZeta(…);HurwitzZeta(0.7+1e7i, 0.3).N()took two seconds andHurwitzZeta(1e9, 0.5).N()more than a minute, and both now return at once. -
LerchPhipast the unit circle evaluates in many cases that stayed symbolic: a realawithRe(s) ≥ 1/2at a large|z|(LerchPhi(-187652.08, 2.5913, 1).N()is0.00097720268138322), a very negative order with a negativea, and a complexa(on a random sweep withRe(s)from −30 to 1 and|z|up to 1e7, 980 of 1197 points now evaluate, up from 594). Every value is within 1e−11 of the reference, and the slow cases are up to ten times faster. -
The GLSL and WGSL
HurwitzZeta,LerchPhiandPolyLogare more accurate.HurwitzZeta(s, a)withs < 1/2used a sum whose terms cancelled:HurwitzZeta(1/2, 0.3)was 2.3e−5 off andHurwitzZeta(-23, 1/4)returned 0.016 for −0.000215. Every value is now within 1e−5, orNaNnext to a zero of the function where single precision cannot resolve it (some values that were returned before, inaccurately, areNaNthere now).LerchPhi(z, s, a)andPolyLog(s, z)withz < 0ands ≤ −3return a value in most cases that wereNaN, for exampleLerchPhi(-1, -6.5, 1.3)is0.10992. -
A
Pipestage that uses no topic slot and whose value is not a function is an error. The piped value would be ignored.[1, 2] |> 10 + 3was already anincompatible-typeerror, but[1, 2] |> 10 + [3, 4][1]gave13and[1, 2] |> 10 + ([3, 4] |> _ * 2)gave[16, 18]: the stage was evaluated, was not a function, and was applied as a constant. Both are now the sameincompatible-typeerror, on the MathJSON, LaTeX and Epsil routes. So is5 |> Max(3), a complete call whose value3is not a function (it gave3). A stage that is a function is unchanged: a function symbol (xs |> Sum), a call missing its first argument (xs |> Take(1)), a lambda, a stage with a topic slot (xs |> _ * 2), and a body whose unknowns become its parameters (5 |> y + 1is6). A_inside a nestedPipestage belongs to that stage, not to the enclosing one.Applykeeps treating a value that is not a function as a constant (Apply(3, 5)is3). -
Binomial(n, k).N()for a hugenand a non-integerk. From aboutn = 10²⁰,Γ(n + 1)overflows, and the quotientΓ(n+1)/(Γ(k+1)·Γ(n−k+1))gaveNaN:Binomial(1e300, 0.5).N(),Binomial(1e20, 0.5).N(),Binomial(1e15, 0.25).N(). The value is now computed in log form with the Stirling series ofln Γ(a) − ln Γ(a − d):Binomial(1e300, 0.5).N()is1.1283791670955125739e+150. Withce.precision = 'machine',Binomialuses the same kernel as the compiled JavaScript target. -
The interval enclosures of
GammaandGammaLncontain the true value. They widened the scalar kernel's value by one ulp, but the kernel is off by up to about 300 ulps (Γ(148.6)), and more close to a pole on the negative axis, so an enclosure could miss the value:Gamma(3.5)gave[3.3233509704478443, 3.323350970447845], above the true3.32335097044784255…. Each value is now widened by a measured bound on the kernel's error. Two otherGammaLndefects are fixed: an interval that contains the minimum ofΓnear1.4616was reversed ([0.5, 2]gavelo = 0.572,hi = 0, while the true minimum is−0.1215), and every interval on the negative axis gaveNaN. AGammainterval that contains several poles now reports the first one. -
Compiled
BinomialandChoosegive the interpreter's values outside0 ≤ k ≤ n(#384, reported by enumeratio). The JavaScript target's runtime was a Pascal-triangle table lookup:Binomial(3, -1)andBinomial(2, 5)gaveundefinedinstead of0, and a negative or non-integern(Binomial(-3, 2),Binomial(5.5, 2)) threw. The runtime now followsevaluate():0fork < 0ork > n ≥ 0, the extension to a negativen(Binomial(-3, 2) = 6,Binomial(-3, -5) = 6), the Γ ratio for a non-integer operand,0orInfinityat the poles of the Γ factors, the limits at an infinite operand, andNaNfor aNaNoperand. Integer results below 2^53 are exact. The interval target used the same table and threw for an interval that contains a negativen; it now uses the same kernel. For a very wideninterval, its fallback bound built a table withn²/2entries; it now computes the one bound it needs, and gives the whole real line when the interval contains a negativen. An enclosure that contains a value above the largest double now extends toInfinity; before, the overflowed grid values were skipped and the upper bound was too low. -
HurwitzZeta(-n, a)at a non-positive integer order is the Bernoulli polynomial −Bₙ₊₁(a)/(n+1) for a symbolic or a complexa, not only for a rationala.HurwitzZeta(-1, a)stayed unevaluated and now evaluates to-1/2 * a^2 + 1/2 * a - 1/12, for orders up to 12. The two-operandZeta(-1, a)gets the same polynomial only whenais known to be positive: for a negativeaits terms withk + a < 0are|k + a|^n, soZeta(-1, -5/2)is109/24where the polynomial gives-107/24. At a complexa,HurwitzZeta(-60, 2+3i).N()had a real part of-9.1894…e+29, wrong from the third digit, and now gives-9.19246731053263160872e+29. A floatawith many digits is also fast:HurwitzZeta(-100, 1e-300).N()took about a minute and now takes a few milliseconds, andHurwitzZeta(-50, 1e-200000).N()did not finish and now takes about 10 ms. A tinyais no longer taken for a pole:HurwitzZeta(2.5, 1e-2000).N()was~ooand is now1e+5000. (#374, contributed by enumeratio) -
Floor,Ceil,Round,TruncateandFractof an exact number are exact (#382, reported by enumeratio). They rounded a numeric approximation of the operand (21 digits by default, a double withprecision: 'machine'), so a large exact operand lost digits:Floor((25! − 1)/24!)was25and is now24,Floor(25! − 1)was25!,Floor(√2·10^40)was correct only to 21 digits, andFract((25! − 1)/3)was−1/3and is now2/3. A rational or a rational multiple of a square root is now rounded with integer arithmetic;Roundstill rounds a tie away from zero. -
A
Functionliteral with no parameter list no longer takes the_1,_2… slots of a nestedFunctionliteral as its own parameters (#381, reported by enumeratio).["Function", ["Add", "_1", [["Function", ["Multiply", "_1", "_2"]], 2, 3]]]had the parameters_1and_2, so applying it to5gave a function and not11. The search for slots now stops at a nestedFunctionwith no parameter list, whose slots belong to it. A nested literal with a parameter list owns only the names it declares, and its other slots still refer to the enclosing literal. The same correction applies to the shorthand callbacks ofMap,FilterandApply, to aPipestage (Pipe([1, 2], Map(Function(_ + 1)))gave a function and is now[2, 3]), to the JSON serializer and to the_slot of the Epsil|>operator. -
serializeEpsiltakes linear time in the nesting depth (#379, reported by enumeratio). The formatter computed the cost and the layout of each nested block again every time its parent asked, so the time grew by a constant factor at each nesting level: a 10-deepAddchain took 42 s. Each block now keeps these values per starting column. A 200-deep chain serializes in less than a millisecond, and the output is unchanged. -
PolyLog, the hypergeometric functions,AppellF1,JacobiTheta,DedekindEtaandEisensteinEthread over a list operand, asZeta,LerchPhiand the elliptic integrals already did.PolyLog(2, [0.1, 0.2])was anincompatible-typeerror and is now[0.1026177910993911, 0.2110037754397048]; a scalar call is unchanged (#374, contributed by enumeratio). -
LerchPhiandPolyLoganswer to the engine precision for real arguments with |z| < 1. They answered in machine precision whateverce.precisionwas, whereHurwitzZetagave the requested digits. Withce.precision = 50,PolyLog(2, 1/3).N()was0.3662132299770634and is now0.36621322997706348761674629766426276380206341558968. A value outside the machine range now also gets a numeric value:LerchPhi(0.5, -159.5, 1).N()stayed unevaluated and is now2.62784929555984310206e+309. Outside the disk |z| < 1, or for a complex argument, the answer keeps machine precision (#374, contributed by enumeratio). -
A collection operand that can be absent as a whole compiles on the JavaScript target (issue #383). With
x: list<list<integer>>, the row readAt(x, i)is typedlist<integer> | missing(the index can be past the end), andLength(At(x, i)),Count(At(x, i))and every other collection operator over it refused to compile ("operand is not an indexed collection"), where the same read of atuple<list<integer>, list<integer>>compiled. The same held for a restricted listxs{c}and for a symbol declaredlist<integer> | missing. The compiled code now binds the operand once and tests it for the absent value:Length(At(x, i))runs to the row's length, and toNaNwhen the row is absent, as the interpreter answersNaNforLength(Missing);Reverse,Take,Sort,Join,Map,Filter,Reduce,Any,Dot,Cross,ListFrom,Lastand the other collection operators follow the same rule, with the absent object (undefined) as the marker of a non-numeric result.SumandProductof such an operand answeredundefinedand now answerNaN. The rule applies on the JavaScript target only; the Python and shader targets keep their decline. -
Sumof a list of points, or of a list of lists, compiled to a complexNaN.Sum(pts)withpts: list<tuple<real, real>>folded each point as a number and answered{ re: NaN, im: NaN }, where the interpreter answers the sum point(4, 6). The compiled sum and product now combine the elements element-wise, as the interpreter does;Productof a list of points declines, as the interpreter reports an error (there is no product of two points). -
Lazy collection operators over an operand that can be absent as a whole stayed unevaluated in the interpreter.
Filter(At(x, 7), p),Reduce(At(x, 7), Add, 0),Any(At(x, 7), p)andScan(At(x, 7), f), withxa list of lists that has no seventh row, stayed unevaluated, whereLength(At(x, 7))isNaN;Map(f, At(x, 1)),Sum(Map(f, At(x, 1)))andProduct(xs{c})stayed unevaluated for a present row as well. They now answer the marker of their codomain (Missing,NaN) for an absent operand and compute over the present collection.Reduce(At(t, 1), f, 0)andLength(Filter(At(t, 1), p))withta tuple of lists also evaluate now. Underevaluate({ materialization: true }), a view over such an operand was materialized as aSetbuilt from the walk of the unevaluated view (Append(At(x, 1), 2)gaveSet(3, 1, 2), the duplicate lost;Reverseof an absent restricted list gaveSet(NaN)): the view is now evaluated first, so the result is the list, orMissing. -
LerchPhiwith a complexaoutside the unit circle returned a wrong value in part of theaplane. The continuation past the circle took the principal branch of an incomplete gamma function, which is the wrong sheet when the argument crosses its cut; for a realz > 1everyawith a positive imaginary part was affected.LerchPhi(3, 2.5, 1.38+0.86i).N()was−0.23906 − 0.28351iand is now−0.71263 − 0.72673i. On 7000 random points, 575 values were wrong; none is now. (mpmath'slerchphihas the same defect, so do not check these values against it.) -
LerchPhiandPolyLogno longer return wrong digits inside the unit disk, next to the circle, next toz = 1, or for a very negative order past the circle. Inside the disk the direct series lost up to 10 digits for a large negative order (LerchPhi(0.9693+0.1294i, -5.84-1.357i, 6.235+0.793i), 2.5e−10 relative); next to the circlePolyLog(-0.9, -0.9999).N()was−0.27647181622385925and is now−0.27647181621858134; within 1e−2 ofz = 1the logarithm ofzhad only 8 correct digits (1178 of 3000 values off by up to 1.4e−7); and past the circle a very negative order returned wrong values where the previous version stayed unevaluated (PolyLog(-19.5168, -941.72), wrong in the fifth digit). Every value thatLerchPhireturns is now within 1e−11 of the reference on about 20 000 points, and everyPolyLogvalue within 1e−12 on 21 000 points. -
LerchPhi,PolyLogand the incompleteGamma(s, x)now return a value next to a pole of the gamma function, andLerchPhiwith a negative order returns a value for most arguments outside the unit circle.LerchPhi(2, 3.000001, 3.5).N(),PolyLog(3.000001, 2).N()andGamma(-0.999999, -3).N()stayed unevaluated and now evaluate (0.0091 − 0.0667i,2.7621 − 0.7547i,3.2386 + 3.1416i);LerchPhi(-900, -2.5, 0.5).N()is−4.6136259e−5(unevaluated before). For|z|from 200 to 10⁴ with a reala, 3 of 400 random points answered before and all do now.PolyLoginside the unit disk answers every point the kernel computes accurately (it declined about half of them), and past the diskPolyLog(2.5, -1000000).N()is−220.36048147359768(unevaluated before). -
Numeric
Zeta,HurwitzZeta,PolyLogandLerchPhiare accurate next to a negative order close to 0 or to a negative even integer, left of the critical strip, and for a complexawith a negative real order. The compiledZeta(s)ats = -1e-9was−0.4999999577and is now−0.4999999990810612; ats = -1e-17it was−Infinity.HurwitzZeta(-11.801, 0.4265-1.271i).N()was2.2188 + 50.186iand is now5.6234 + 46.077i;HurwitzZeta(-12, 1.5).N()was−0.00024414062499909618and is now exactly−2^-12;HurwitzZeta(-200.5, 0.3).N()was off by 1176 units in the last place and is now within 5. On 10 000 random points with a complexaand a negative real order, about a third were off by more than 1e−12 (some by 20 orders of magnitude); none is now, and a call costs the same or less. -
WGSL shaders that use NaN or infinity now compile in browsers. Every WGSL shader that contained a NaN or infinity constant, including those for
Gamma,Zeta,HurwitzZeta,LerchPhiandPolyLog, was rejected by the browser's shader compiler (value nan cannot be represented as 'f32'). Both shader targets now spell them as_gpu_nan()and_gpu_inf(). GLSL shaders that useLerchPhiorPolyLogdid not compile either (a GLSL keyword was used as a variable name); they do now. -
The GLSL and WGSL targets compute
LerchPhiandPolyLogpast the unit circle, and more accurately for a negative order. For real operands (zbelow −1,z = −1withs < 0,zclose to 1) they return the real value where they returned NaN:LerchPhi(-3, 1.5, 0.7)NaN → 1.10658,PolyLog(2, -1000000)NaN → −97.0791,PolyLog(2, -1e30)NaN → −2387.50. Every value returned is within 1.5e−5 of the reference, measured on the GPU; the previous helpers returned values off by up to 5.6e−4 for a negative order (LerchPhi(-0.645, -10, 8.99): 1155547136 → 1154901726). -
ListFrom,SetFromandTupleFromof a very large collection were aninternal-error.ListFrom(Range(1, 300000))answeredError(ErrorCode("internal-error", "Maximum call stack size exceeded", …)): the elements were passed to one function call as separate arguments, and a few hundred thousand of them exceed the argument limit. The elements are now added one at a time. -
A list variable reassigned in a loop could be typed
integer | list<…>. Infor gap in [(A, C), (A, C)] { circles = g(gap[1], 2, circles) }, a first reading of the call's type, taken before the types of the loop were settled, wasnever.nevermatches every type, so the type recorded forcirclesgained anintegermember that no assignment gives it, and the JavaScript target declinedLength(circles)andCount(circles, …)("operand is not an indexed collection") while the interpreter computed the value. A value typednevernow records nothing. -
The JavaScript target answered a wrong value for a function declared in the compiled program and called with a list of points. With
function f(p: tuple<number, number>) { p[1] + 1 }inside the program andxsa list of two points, the interpreter appliesfto each point and answers[2, 4]. The compiled call passed the list whole and answered the string"1,21". The compiled call now maps over the points, as it already did for a function defined on the engine (ce.assign). When the parameter has no annotation and its type is only inferred as a point, the call fails to compile with a message that names the argument, and the interpreter answers. -
A recursive Epsil program that builds a list through a local copied from an untyped parameter compiles.
function fill(p, q, r, depth, acc) { let out = acc; for c in … { out = [...out, c]; out = fill(…, out) } … }declined on the JavaScript target with "Could not compileListJoin: operand 1 is not an indexed collection", and the smallerfunction f(acc) { let out = acc; out = [...out, 1]; out }compiled but broadcastf([0])over the list. Two causes: the local copied from the parameter kept the parameter untyped, so a use of the local as a collection taught the parameter nothing; and a self-call inside afunctionbody was typed as a broadcast (broadcastable<unknown>) from the barefunctiontype the recursion knot declares, which widened the local past the list its spread gives. Now the first use that narrows such a local narrows the parameter it was copied from, and a self-call typesunknownwhile the clause canonicalizes and installs. The natural recursive spelling of the Apollonian gasket compiles and computes its 224 circles in a few milliseconds; the interpreter takes about 16 seconds. A self-recursive function whose base clause reads its parameter whole now derives that clause's result (-> number) instead of-> broadcastable<unknown>. (Tycho row 339.) -
A self-recursive function that builds a list is typed
list<T>. WithFdeclared(unknown, unknown) -> unknownand assigned(n, K) ↦ { n = K - 1: [n], otherwise: join([n], F(n + 1, K)) }, the function derived-> collection<number>, since the recursive call read the declared resultunknownwhile the body was typed, and anunknownoperand ofJoinmay hold a set. The derivation now re-types the body under the hypothesis that the result is a list and keeps it only when that pass reproduces it, soFderives-> list<number>andF(0, 5)compiles; a base clause that builds a set still derives-> set. (Tycho row 338.) -
A predicate over a finite
Rangecompiles to a counting loop on the JavaScript target; the range is no longer built.Count(Filter(1..n, k ↦ k mod 3 = 0))compiled to anArray.fromof the whole range, a.filterinto a second array, and a.length: about 27 ms at n = 10⁶, where a loop that counts runs in about 2 ms. The compiled code now walks the range with a countedforloop and calls the predicate on each element, allocating nothing. The same walk servesLength(Filter(range, p)),Count(range, p),CountIf(range, p),Any(range, p)andAll(range, p), which stop at the first decisive element, the collection form ofSum/Productover a filtered range, and a bareFilter(range, p), which pushes the selected elements into one list. The values are unchanged: the element count is_SYS.rangeCount, the interpreter's own, and elementiisstart + i × step, so an empty, reversed, zero-step or float-step range answers what the array lowering answered. The loop is not used when the range or theFilteris re-mapped by the caller, shared by common-subexpression elimination, or has a non-finite or non-number bound; those keep the array lowering. ARangeconsumed any other way (Map,Sum(range), indexing) still materializes. (Issue #373.) -
A list of numbers plus a point is typed
error. WithL := [0, 1, 2, 3],L + (1, 1)was typedtuple<integer, integer> | vector<integer^4>(earlierindexed_collection<integer>) while it evaluates to a list of fourincompatible-typeerrors, as2 + (1, 1)is one such error. TheAddtype handler sent the pair to its tuple branch and widened the two operand types together. A point, or a list of points, added to or subtracted from a collection of numbers at any depth is now typederroron the expression and the descriptor routes, as[1, 2] + "a"already was; a matrix literal plus a point and a list of points plus a list of numbers are covered by the same check. A genuine point broadcast ([(1, 2), (3, 4)] + (1, 1),[0, 1, 2, 3] · (1, 1)) is unchanged, and a symbol later assigned a list of points types as one again. Values are unchanged. -
.N()of a convergent series whose tail is not an integer power of1/Nevaluates.\sum_{n=1}^\infty 1/n^{1.5}(= ζ(1.5) = 2.6123753487),\sum 1/n^{2.5}and\sum \ln(n)/n^2stayed unevaluated under.N(): the Richardson tableau that accelerates the partial sums extrapolated in integer powers of1/Nonly, so a tail inN^{1-p}for a non-integerp, or with a logarithmic factor, never certified. When that tableau does not certify, the tail exponent is now fitted from the partial sums (a repeated exponent absorbs aln Nfactor), and the values are accurate to about 1e-13. Acceptance stays gated: the fitted route needs an error estimate at or undermax(1e-12, 1e-11·|v|), agreement between two independent sample sequences and a positive fitted exponent, so\sum 1/n,\sum 1/\sqrt{n},\sum (-1)^nand the like still stay unevaluated. The infinite product takes the same route (\prod (1 + k^{-1.5})evaluates). Series that already evaluated give the same values as before. -
A destructuring
letinside a loop body compiles.let n = 0; while n < 4 { let (a, x) = (2, 3); n = n + a }; ndeclined on the JavaScript target with "Could not compile a destructuring declaration in value position" wherever theletwas in the body, although the interpreter answered4: the loop-body lowering desugared a destructuring assignment but not a destructuring declaration. A loop body's statements now go through the same desugaring as a block's, in statement position. -
Complexkeeps thenumbercontract of its components on the literal route.Complex("str", 2)boxed to the floatNaN, whereAdd("str", 2)is anincompatible-typeerror: a component that is not a number literal is now built as the sumre + im·ithrough theAddoperator, which checks the contract. A symbol component (Complex(x, 2)isx + 2i) and two number literals (one complex literal) are unchanged. -
A set literal that spreads an absent operand is absent.
{...Missing, 0}wasSet(Missing, 0)where[...Missing, 0]isMissing(an operator over an absent collection is absent, decision of 2026-09-26):SetFrom,ListFromandTupleFromnow propagate an absent collection (SetFrom(Missing)was anincompatible-typeerror), and the set literal reads an absent spread operand as a segment, as the list literal does. -
Length(Range(0, n))withntypedintegeris typedinteger. It wasinteger | signed_infinity: theLengthtype handler admitted the infinite length for any bound that is not a number literal. A bound or step typed finite (integer,real) cannot make the range unbounded. Static type only; no value changes. -
A connective over an operand that failed is that error.
Or(Sin(P) < [1, 2], Sin(P) > 0)withP = [5, 6, 7]was a list of threeincompatible-dimensionserrors, the error of the first operand copied into every cell of the second.And,Or,Nand,NorandImpliesnow answer the error itself, unless another operand decides the result by itself (And(False, e)is stillFalse).XorandNotalready did. -
PolyGammaof a very high order stays symbolic instead of running for minutes, and the big-decimal kernel is correct at high orders.PolyGamma(100000, 2.5).N()did not finish at the default precision (the big-decimal kernel had no order limit and built the exact factorial), and answeredNaNat machine precision (the double factorial overflows above 170). The real kernels now share the order limit of the complex kernel (10 000) and stay symbolic above it; the machine kernel reaches the complex kernel, which carries the factorial in scaled form, when its own intermediates overflow (PolyGamma(200, 300.5)at machine precision is now a value, notNaN). Two wrong values found on the way are fixed: the big-decimal kernel shifted the argument only to the working precision, so its asymptotic series diverged when the order was larger (PolyGamma(1000, 150)was wrong by a factor 10⁶), and its tail stopped on an absolute tolerance, so a small result kept 4 digits (PolyGamma(200, 300)is−2.03430·10⁻¹²³, was−2.03471·10⁻¹²³). Order 10 000 atx = 2.5now takes 0.2 s (was 0.7 s to 2.5 s). -
A
NaNliteral pattern matches aNaNsubject.match NaN { NaN => 1, _ => 2 }answered2: a number pattern compared withisEqual, which follows IEEE. A pattern or subject that isNaNorIndeterminatenow compares structurally (NaNmatchesNaN,IndeterminatematchesIndeterminate, neither matches the other). The MathJSON spelling of the pattern (MatchCase(NaN, …), alsoPositiveInfinity,NegativeInfinityandComplexInfinity) never matched either, because the raw pattern keeps the name as a symbol while the subject is the number; those five names are now compared as their number. -
ce.number([n, 0])is~ooforn ≠ 0, asce.box(["Rational", n, 0])is; it wasNaN.ce.number([0, 0])isIndeterminate. -
simplify()propagates aNaNorIndeterminateoperand.ce.box(["Sin", "NaN"]).simplify()stayedsin(NaN)whereevaluate()answersNaN. The simplifier now applies the operand'spropagateNaN policy as evaluation does, with the same exclusions (lazy operators, user functions, a broadcast over a collection, arejectposition), reading number literals only.Binomial(NaN, 0).simplify()isNaN. -
An exact
0times a float is the exact0in both operand orders of.mul().ce.number(2.5).mul(ce.Zero)was the float0.0whilece.Zero.mul(ce.number(2.5))was the exact0, as theMultiplyoperator answers in both orders. -
Integrateof aNaNorIndeterminateintegrand has no value on both routes.Integrate(0/0, x)wasNaNunderevaluate()but stayed unevaluated under.N(), andIntegrate(NaN, x, 0, 1)stayed unevaluated on both.Integrateis lazy, so the NaN-policy step of evaluation does not run for it; its handler now answersIndeterminatefor anIndeterminateintegrand with exact bounds, andNaNotherwise and under.N(), for indefinite, definite and multiple integrals. -
A list bound whose length does not match is the same error under
.N()as underevaluate(). Two list bounds of different lengths, or a list integrand and a list bound of different lengths, gave theincompatible-dimensionserror underevaluate()but left the integral unevaluated under.N(); both routes now give the error, naming the lengths in the same order. -
Atwith an infinite index isNaN.At([1, 2], +oo)andAt([1, 2], ~oo)stayed unevaluated whereAt([1, 2], 1.5)andAt([1, 2], NaN)answerNaN(a read with no position). An infinite index names no position either. -
BoxedSymbol.mul(0)no longer reads a variable's assigned value at canonicalization. Withw := NaN,ce.symbol("w").mul(0)was theNaNliteral, frozen across a laterw := 4. For a variable that holds a value the product now staysMultiply(0, w)andevaluate()reads the value the variable holds at that time (IndeterminatewhilewisNaN,0afterw := 4). A constant with an infinite value still givesNaN; a symbol with no value still folds to0. -
Remainderis typed withnanwhen its divisor may be zero, and the statistics that subtract the mean answer the indeterminate form for an infinite datum.Remainder(5, 0)evaluates toIndeterminatebut was typedinteger; it now has a type handler modelled onMod's: a divisor that is provably zero givesnan, one that may be zero gives the operands' kind joined withnan(Remainder(n, r)withr: realisnan | real), and a nonzero literal divisor leaves the polytype result.Variance([+oo, 1]),StandardDeviation,PopulationVariance,PopulationStandardDeviation,KurtosisandSkewnessansweredNaNfrom the machine kernel where the deviations∞ − ∞are the indeterminate form: they now answerIndeterminatefor an infinite datum with no float datum,NaNwith a float datum or under.N(), asMean([+oo, -oo])does. -
src/math-json/OPERATORS.jsonandCATEGORIES.jsonare regenerated from the current library (the tracked files were behind by the operators added since their last generation and by theexamplesfields). -
The
materializationoption no longer truncates the operands of an eager operator.Length(Range(1, 5000)).evaluate({ materialization: true })was11, andIndexOf(Range(1, 5000), 4000)andContains(Range(1, 5000), 4000)under the same option were0andFalse: the option, which describes the result ("if the result is a lazy collection, materialize it"), was forwarded to the evaluation of each operand, and a lazy operand with noevaluatehandler was materialized to the display preview (the first five and the last five elements with a placeholder between them) before the operator's handler read it. Operands now reach the handler in their lazy form on both the synchronous and the asynchronous route, and a lazy view a handler answers (Insert(Range(1, 200), 2, 99),Partition(Range(1, 300), 3)) is materialized after the handler instead, and the option still descends into a container literal ((Take(xs, 3), 1)is a pair holding the materialized list). A held conditional over a list ([5, 10] {0 < t}) is left as the held form. -
A
Foldthat builds a list compiles, and so doLengthandAtof its result (#369, reported by enumeratio).Fold((acc, i) => Join(acc, [2 p[i]]), [], 1..Length(p))evaluates to[6, 2, 4]forp = [3, 1, 2]but declined on the JavaScript target with "Could not compileJoin: operand 1 is not an indexed collection" (fixed in 0.141.0 by the run-time array check of an inferred collection parameter), andLengthorAtof the fold, or of a block local holding it, still declined: the engine types the foldcollection<any>, from the bare accumulator's inferred type. A seeded fold whose seed is an array and whose combiner returns one is now read as an array, andAtof a block local typed as an inferred collection reads it through the same run-time checkLengthuses. An ANNOTATED accumulator ((acc: list<integer | nan>, i) => …) declined as "the type of the value it receives is not provable" on both the JavaScript and the Python targets: the accumulator receives the seed, then the combiner's own result, so it is now judged against the join of the two types, and admitted when both provably satisfy the annotation. An annotation the body does not provably return is still declined, and the diagnostic now names the argument type (list<integer | nan>:p[i]may be out of range). A fold whose combiner is a function defined in the same program (function add(a, x) { a + x }thenFold(add, 0, xs)) declined as "the combiner has no compiled function form": the combiner is now resolved through the compiled block's own bindings, as a call of that function already was. -
A fold's accumulator is typed from its seed and its combiner's result (user decision 2026-09-29, from the investigation of #369). A bare accumulator was typed from its uses only, so
Fold((acc, i) => Join(acc, [2 p[i]]), [], 1..n)was typedcollection<any>(aJoinalso accepts a set), and a view over it followed the kind of a value the fold node did not hold:Map(x => x + 1, fold)evaluated toSet(7, 3, 5)andFilter(fold, x => x > 3)toSet(6, 4)where the fold's value is the list[6, 2, 4]and the compiled result was the array[7, 3, 5]. The accumulator is now typed to the fixpoint of the seed's type and the combiner's result type (list<integer | nan>here), as an INFERRED type: the printed literal is unchanged, nothing is enforced at apply time (a fold whose accumulator changes type mid-fold,1 → 1/2 → 1/6, still evaluates), and the same views now evaluate to the lists[7, 3, 5]and[6, 4].Scanand the seedless folds are typed the same way. A fold whose accumulator already infers a precise type (a numeric fold) is not re-canonicalized, so its cost is unchanged; a list-building fold costs about one extra canonicalization of its combiner. What changes for a caller: the type of such a fold, and aMap/Filter/Takeover it answers a list instead of a set. -
IndexOfandIndexWhereno longer answer0for a collection they cannot search (#368, reported by enumeratio).IndexOf(xs, 0)for a symbolxswith no value, for an unknown functionf(1, 0)or forRange(1, n)answered0, a claim that the element is absent, whileContains(xs, 0)andIndexWherestayed unevaluated: the handler read the "cannot search" answer of the element scan as "not found". Both operators now stay unevaluated unless a finite source was walked to its end. A set is refused asincompatible-type, asFirstandAtrefuse it, instead of answering0for every value. An unbounded source still answers a match (IndexOf(Repeat(5), 5)is1) and a refutation (IndexOf(Repeat(6), 5)is0); a search that finds nothing within the iteration limit stays unevaluated instead of walking forever. -
A DMS angle may omit the minutes.
9°30"(9 degrees 30 seconds) was anexpected-closing-delimitererror,9°30''and9°30\prime\primeparsed as the derivative of9°30', and9°30\doubleprimeasDegrees(9) · Prime(30, 2): the parser tested the minute marker first, and'and\primeare the first half of''and\prime\prime. The second marker is now tested first, and every spelling parses to the exact1081/120degrees;x°30"isx deg + 30 arcsec. -
A protocol member's
Selfbinds to the conformance target dispatch selects, not the first argument's static type (#362, contributed by enumeratio). WithCompare: "(Self, Self) -> number"implemented onreal,Compare(5, 1/2)andCompare(3, 2.5)were rejected asincompatible-typewhileCompare(1/2, 5)andCompare(2.5, 3)passed — acceptance depended on which argument happened to already have the exact declared type.Selfnow binds to the most specific conformance edge the receiver's type admits (realhere) at every call, statically and at runtime, so both argument orders and any mix ofinteger,rationalandrealare accepted alike; a-> Selfresult type binds the same way. A nominal type that conforms to nothing is still refused in either position. When one applies, the binding reads only the edges dispatch can select — those carrying an implementation of the member — so a block-lesstype integer is PartialOrderthat inheritsreal's implementation does not narrowSelftointegerfor the static check while the run time serves the call throughreal; when none is selectable yet, it reads every declared conformance, pending ones included. -
JavaScript compilation: a
Rangewith a computed start or step no longer captures a user variable namedior_e(#367, reported by enumeratio). The runtime-length range spliced its compiled start and step into anArray.fromcallback whose index parameter wasiand whose element parameter was_e, so a start that read a user variable of either name read the callback's parameter instead: aMapoveriwhose body counted thej > iwithp_i > p_joverRange(i + 1, Length(p))compiled to[0, 0]forp = [2, 1]whereevaluate()gives[1, 0], while the same program overkcompiled correctly. Every operand is now passed as an argument of a small function, outside the callback, the shape the impure-operand branch and the Python target already used. -
A protocol member call no longer re-parses the member's signature. A signature that names
Selfbypasses the type parser's shared cache, so every dispatched call parsed it twice, and the first attempt threw and built an error on the way. The parsed signature is now kept per protocol, member and receiver type until the declarations change. A member call on a scalar receiver went from about 12× the cost of an equivalent plain function to about 1.3× (#363, contributed by enumeratio).
0.141.0 2026-09-29
Behavior Changes
-
A restriction with a list of conditions pairs an ordered collection of infinite or unknown length over the conditions' length (user decision 2026-09-29).
When(Range(1, ∞), [True, False])was[Range(1, ∞), Missing], the whole collection in each cell, and is now[1, NaN]; only the elements needed are read, with one walk. A collection that ends before the conditions do is truncated, as a finite one was. When the elements cannot be read, the restriction stays unevaluated where it repeated the whole collection: a range with a free bound, and a symbol declaredlist<number>with no value ([P, Missing, P]is now the held restriction). The static type of a restriction over a carrier declaredindexed_collection<T>islist<missing | T>(list<number>for a numericT), where it waslist<indexed_collection<T> | missing | T>. A tuple held by a symbol declared as an ordered collection of numbers is paired element by element (P = (1, 2)gives[1, NaN]); a value held under a type that admits a point or a string (tuple<number, number> | list<…>) is still one value. Acollection<T>orset<T>carrier keeps the wider type, because it can hold an infinite set. -
A list literal with a spread is always a list (user decision 2026-09-29). Its canonical form is the new operator
ListJoin:[...a, 0]isListJoin(a, [0]), where it wasJoin(a, [0]). Withg(a) = [...a, 0],g({9, 8})is[9, 8, 0], where it wasSet(9, 8, 0).ListJoinis lazy, keeps the elements of each operand in their order, and keeps duplicates that come from different operands..json,toString()and LaTeX show the new head; Epsil writes it back as[...a, 0]. A storedJoin(a, [0])keeps its meaning: the result takes the kind of its operands.[...Missing, 0]isMissing, where it was[Missing, 0]. -
JoinandAppendover an operand that may hold a set have a type that admits it, and their value no longer depends on the materialization option (user decision 2026-09-29). WithPdeclaredcollection<number>,Join(P, [5])is typedcollection<number>, where it waslist<number>; withPholdingSet(3, 1),evaluate({materialization: true})givesSet(3, 1, 5), asevaluate()does, where it gave[3, 1, 5]. The kind of the result follows the values the SOURCES hold: every operand ofJoin, the first operand ofAppend(Append(acc, s)withsholding a set stays a list whenaccholds a list). Compiled JavaScript that reads such a join (Length(Join(a, [0]))in a function body) still compiles. -
A user function that passes its parameter, alone, to an operator that reads a collection whole receives a list argument whole (user decision 2026-09-29). It applies to
Mean,Median,Variance,StandardDeviation,Mode,Quartiles,Flatten,SetFromandTupleFrom, when the bare parameter is the only operand. Before, the function was applied to each element of the list.function h(xs) { mean(xs) }applied to[1, 2, 3]gives2, where it gave[1, 2, 3];flattenover[[1], [2, 3]]gives[1, 2, 3];mean(xs) + xsgives[3, 4, 5], where it gave[2, 4, 6]. A function that only calls such a function is bound whole too, and once one parameter is bound whole a list given to another parameter is bound whole as well (h(xs, k) = mean(xs)·kapplied to[1, 2, 3], [1, 10]gives[2, 20]). A scalar argument gives what it gave (h(5)is5), and the function accepts every argument it accepted. Unchanged, still applied element by element: a use with several operands (mean(x, 1)over[1, 2, 3]is[1, 3/2, 2]), a use through another operator (mean(xs^2)),norm(p)(one norm per point over a list of points),String(p),max(xs), and every parameter with a declared type. The compiled JavaScript agrees with the interpreter on each of these. -
An element-wise operator over an operand declared with an abstract collection type is typed
broadcastable<R>(user decision 2026-09-29). WithPdeclaredcollection<number>and no value,Sin(P),P + 1andP > 1were typednumber,collection<number> | integerandboolean; they arebroadcastable<number>,broadcastable<number>andbroadcastable<boolean>, the scalar or an indexed collection of it, becausePcan hold a list, which the operator maps over, or a set, which it does not. APthat holds a value is typed from that value. What was accepted stays accepted: a symbol declarednumbercan still be assignedSin(P), and a condition such asSin(P) > xis still read as a condition byElement,Solveand the rules. The full suite showed one pinned type and no value change. One evaluation does change:Sin(P) = [1, 2]withPvalueless stays unevaluated, where it expanded to[sin(P) == 1, sin(P) == 2]on the assumption thatSin(P)is a number. -
A user function that passes its parameter to a collection operator with a callback types that parameter as a collection.
function f(xs) { filter(xs, x => x > 1) }applied to[1, 2, 3]returned[Filter(1, …), Filter(2, …), Filter(3, …)]and returns[2, 3]; the same body withreducereturned a list ofReduce(…)and returns6. These operators hold their operands, and a held operand was never checked against the operator's signature, so the parameter stayedunknownand a function with anunknownparameter is applied to each element of a list argument. An untyped symbol at the collection operand ofMap,Filter,Reduce,Scan,Fold,Any,All,TakeWhile,DropWhile,FlatMap,MaxBy,MinBy,ArgMax,ArgMin,DeduporDifferencesis now typed from that operand's declared type, as it already was forFind,CountorSort. The function's type changes from(unknown) -> …to(collection<unknown>) -> …. A symbol that has a declared type or a value is not changed. The same holds for a pipe stage:[1, 2, 3] |> (_1 ↦ Map(k ↦ k², _1))applied the stage to each element and ran the innerMapover an integer (typedlist<collection<number>>); the stage now receives the whole list and the result is theMapof squares over it (typedcollection<number>). The inference is limited to those sixteen operators: the base of a compound subscripta_{n+1}stays untyped. -
A collection operator over a symbol declared with an abstract collection type follows the value the symbol holds. With
Pdeclaredcollection<number>(alsocollection<any>or barecollection) and holding a list or a range,Map,FilterandScanoverPwere sets:Map(x ↦ 2x, P)forP = [3, 1, 1]wasSet(6, 2), so order and duplicates were lost. The result kind was read from the static type of the lazy node, and an abstractcollection<T>is not indexed. It is now read from the value each collection operand holds, so the result is[6, 2, 2]. Such a result also takes part in broadcasts (Map(x ↦ 2x, P) + 1is[7, 3, 3], it stayed symbolic) and is accepted where an indexed collection is required (Last(Map(x ↦ 2x, P))is2, it was a type error). A symbol that holds a set still gives a set, and the static type is stillcollection<T>. -
A derivative with several variables keeps every variable in LaTeX.
D(s^2t^2, s, t)serialized as\frac{\mathrm{d}(s^2t^2)}{\mathrm{d}s}, droppingt, andD(x^3, x, x)serialized as a first derivative, so a round trip through LaTeX changed the value (Tycho item 333). ADwith two or more variable operands now uses the partial-derivative spelling:\frac{\partial^{2}(s^2t^2)}{\partial s\,\partial t}and\frac{\partial^{3}f}{\partial x^{2}\,\partial y}. Variables keep their order, and only consecutive repeats are grouped (\partial x\,\partial y\,\partial x). ADwith one variable keeps the\mathrm{d}spelling, and a nestedDis folded into its order only when it has the same single variable (D(D(f, x, y), x)losty). A variable operand that is not a symbol falls back to\operatorname{D}(…). The parser now accepts\,between\partialterms, and a differentiand after\partial^{n}in the numerator that is not a plain symbol (\partial^{2}x^3,\partial^{2}(xy),\partial^{2}f(x,y)); those inputs gave amissingerror or aDivide. -
An element read by a literal index from a collection whose length is not known stays unevaluated instead of giving
NaN. Withndeclaredintegerand no value,[0...n][1],At(Range(1, n), -1),First(Range(0, n)),Last(Range(0, n)), a gather such asAt(Range(0, n), [1, 2]), and the same reads throughTake,Drop,Reverse,Map, a broadcast such as(0...n)^2, or aPointListof such ranges gaveNaN(At(Linspace(0, 1, n), 1)gaveMissing), although the element exists for most values ofn. They now stay symbolic underevaluate()and.N(), as the read by a symbolic index already did, and give the element oncenhas a value. Reads that are provably out of range are unchanged: index 0, an index past a known length ([0...5][7]isNaN), the end of an infinite collection, and a FINITE walked collection that runs out (Tycho item 334). One more read changes with the same rule: a read that gives up at the iteration limit over a source not known to be finite stays unevaluated, where it answered the absence marker.First(Filter(Range(1, ∞), x ↦ x < 0))wasNaNandSecond(Dedup(Cycle([1, 1])))wasMissing; a walk that stops at the limit does not prove the element absent (with the predicatex > 10^6the first element exists beyond the limit, and was answeredNaNtoo). Both calls still return after one bounded walk. -
A closure created in a nested block keeps that block's locals, and a closure created in a loop captures the loop variable of its own iteration.
function mk(n) { if n > 0 { let k = n * 10; () => k } else { 0 } }gave closures that answered the symbolk; a closure created in aforloop saw only the loop's last value (inside a function, the escapedibecame the imaginary unit); a counter declared in a nested block did not count. A closure now captures the nested block's binding when it is created (the binding, not a copy of the value: a later write in the same run of the block is visible, and two closures made in one run share the local), and a loop orSumindex is captured per iteration, as a comprehension index already was:for i in [1, 2] { fs = [...fs, () => i] }gives closures that answer 1 and 2, where they answered 2 and 2. Code that relied on a loop closure reading the loop's final value now reads the value of its own iteration (found with Tycho item 330). -
The name
Indeterminateis reserved. It is now the library constant for theIndeterminatevalue (see New Features):ce.box("Indeterminate"),\operatorname{Indeterminate}and the Epsil wordIndeterminateare that value, where they were a free symbol typedunknown(and in LaTeX a product of letters). A host symbol of that name changes meaning, anIndeterminate := 2assignment is refused as for any constant, and in Epsil the word is a literal likeNaN(let Indeterminate = 2is areserved-worderror; the verbatim form`Indeterminate`still names a binding). The type grammar readsIndeterminateas the value type of that literal, andNumberFrom("Indeterminate")reads it back. -
An exact indeterminate form evaluates to
Indeterminate, notNaN. An exact question whose answer has no value now answers the exact valueIndeterminate(see New Features);NaNstays the answer of a floating-point computation that failed. The printed answer changes fromNaNtoIndeterminatefor:- the arithmetic forms, at canonicalization and at evaluation:
0/0,∞/∞,0·∞,0·~oo,∞ − ∞,0^0,∞^0,1^±∞,(−1)^±∞,i^∞(an exact base on the unit circle other than 1, to±∞),∞^(imaginary),0^(imaginary),Root(0, ∞),Root(∞, ∞),Log(1, 1)andLog(∞, ∞). The ones that fold at canonicalization (["Divide", 0, 0],0^0,∞/∞,∞^0,1^∞) now have the canonical form"Indeterminate"; Mod(x, 0)andRemainder(x, 0)(Mod(5, 0), andMod(x, 0)for a symbolx) andFract(±∞);- the special functions at an infinite point where the limit does not exist:
Gamma,GammaLn,Factorial,Factorial2,Digamma,Trigamma,PolyGammaandZetaat−∞and~oo; the Bessel functions,AiryAi,AiryBiand their derivatives at~oo, andAiryAiPrime,AiryBiPrimeat−∞;Betaat−∞or~oo; the upper incompleteGamma(s, z)atz = ~oo,s = ~ooor two infinite operands;GammaRegularizedata = −∞, at~ooor at two infinite operands; the points ofBinomialandPochhammerwith no limit;ErfInvat every infinity;SinIntegral,CosIntegral,SinhIntegral,CoshIntegral,ExpIntegralEiandLogIntegralat~oo;Real,ImaginaryandArgumentof~oo(soAbsArg(~oo)is(+∞, Indeterminate));Beta(+∞, −∞),Beta(+∞, ~oo)andBeta(−∞, +∞)(two infinite operands with no limit), whileBeta(+∞, +∞)is now0(it wasNaN;B(a, b) ≤ 1/aforb ≥ 1); - three library answers that reach one of these forms: the sample
Variance([5])andStandardDeviation([5])of one datum (0/0), the cells ofMatrixPowerthat compute0·∞(MatrixPower([[∞, 0], [0, 1]], 2)is[[+∞, Indeterminate], [Indeterminate, 1]]), the cells of a broadcast ([0, 1]/0is[Indeterminate, ~oo],[0, 1]·∞is[Indeterminate, +∞],[∞, 1] + [−∞, 2]is[Indeterminate, 3]), andMean([+∞, −∞]). The.add()method follows theAddoperator:ce.PositiveInfinity.add( ce.NegativeInfinity)isIndeterminate.
What does not change: a float operand anywhere in the form gives
NaN(0.0/0.0,0/0.0,0.0·∞,0·2.5·∞,Mod(5.0, 0),1.0^∞); an absent operand givesNaN(Missing + 2,Missing·0,Length(Missing)); aNaNoperand givesNaN;.N()andevaluate({numericApproximation: true})of every form above giveNaN, including the cells of a list or tuple result; the compiled routes answer the target's IEEENaN; a pole is unchanged (5/0is~oo); a point where the value has a direction that depends on the other operand keepsNaN(Gamma(2, −∞),Beta(∞, −1/2)); an anonymous infinity such as∞ + igivesNaN;ce.number([5, 0])andce.number([0, 0])are stillNaN. Code that tested the answer withx.isSame(ce.NaN)no longer matches an exact indeterminate form: usex.isNaN, which istruefor both values, orx.isIndeterminate.IsMissing(0/0)is nowFalseandCoalesce(0/0, 5)isIndeterminate: an indeterminate form is a value, not an absent entry. The operators whose handler runs without the evaluation gate (the lazy operators other thanAddandMultiply, and the operators with aninertorhandlepolicy other than the reducers) answerNaNfor anIndeterminateoperand. The expectations changed in the test suite: 67 tests in 13 files, 19 inline snapshots. - the arithmetic forms, at canonicalization and at evaluation:
-
0/∞through.div()is0.ce.Zero.div(ce.PositiveInfinity),.div(ce.ComplexInfinity)and.div(Infinity)answeredNaN, where theDivideoperator answers0; they now answer0. A float numerator over an infinity is the float0:Divide(2.5, ∞)andDivide(0.0, ∞)were the exact0and are0.0. A float base1.0to±∞answered0and is nowNaN(the float twin of the form1^∞). -
An integer-valued float is written with a fraction part, so it reads back as a float. A float whose value is an integer serializes as
{num: "2.0"}in MathJSON and2.0in LaTeX (a large one as1.0e+800and1.0\cdot10^{800}); it was2, which reads back as the exact2, soSin(2.0)numericized while its round tripSin(2)stayed symbolic (user decision 2026-09-28). An exact integer is still written2. A float in LaTeX with a negative exponent now keeps its fraction part too (123.0\cdot10^{-3}), which read back as an exact rational before. WithfractionalDigits: 0(LaTeX) ordigits: {fractional: 0}(MathJSON) the integer spelling is kept, as requested. -
An Epsil decimal literal keeps its value, and a literal with a fraction part is a float. The Epsil parser summed the fraction digits one float at a time, so
0.75read as0.7500000000000001,0.3as0.30000000000000004and0.000001as0.0000010000000000000002. It now reads the decimal exactly. It also dropped the decimal point while normalizing, so1.0,2.0and1.5e3were the exact integers1,2and1500; they are now floats, as on the LaTeX route (the 0.139.0 rule that a literal with a fraction part is a float).1e3,3and2.(a point with no digit after it) stay exact. A literal a double cannot hold keeps its digits:1e-400was the exact0, and5e-324lost digits. -
The negation of a product folds its sign into the numeric factor.
-(2x)(MathJSON["Negate", ["Multiply", 2, "x"]]) canonicalizes toMultiply(-2, x); it wasNegate(Multiply(2, x)).-\frac{x}{2}is nowMultiply(-1/2, x), the same expression as-\frac{1}{2}x, so the sign of a fraction written in front of it reads back unchanged (#345). -
A float
Measurementerror evaluates.Measurement(5, 0.2) + 3was8 ± sqrt(0.2^2)underevaluate()and is now8.00 ± 0.20. An exact error stays exact (Measurement(5, 1) * Measurement(2, 1)is10 ± √29). -
Rounding a float gives an exact number.
Round,Floor,CeilandTruncateof a float now give an exact integer underevaluate():Round(2.5)was the float3.0and is the integer3;Floor(2.7)is2,Ceil(2.2)is3,Truncate(-2.7)is-2.Round(x, n)of a float gives an exact rational:Round(3.14159, 2)was3.14and is157/50, andRound(1234.5, -2)is the integer1200. A list of floats rounds to a list of exact integers too. This follows Mathematica (Round[3.14159, 10^-2]is157/50) and reverses, for the rounding family only, the 0.140.0 rule that a float operand makes a numeric result a float. Under.N()the result is still a float, and±∞andNaNare unchanged. (#351)
Issues Resolved
-
The MCP
compiletool resolves the library spellings of an Epsil program.sqrt(2)(orpi,sin(x)) declined with "Unknown operatorsqrt": the tool boxed the parsed program without turning the Epsil spellings into the library names, asevaluateandcheckdo. It now compilessqrt(x) + pitoMath.sqrt(x) + Math.PI. -
A function a program defines is not one of its free symbols. For a
Blockholding aDefineFunctionstatement (the Epsilf(x) = x + 1),unknownsand a compilation'sfreeSymbolslistedfbeside the genuine inputs, anddefinesdid not list it; the generated code declaresfas a local.Block(DefineFunction(f, …), f(z))now reportszas its only unknown and free symbol, andfas defined. -
A cancellation's cause is no longer rendered as the site of the error. A program stopped by its deadline was reported as "Runtime error: Timeout exceeded at
timeout": the second operand of the error value,["Error", "Timeout exceeded", "timeout"], is the machine-readable cause, which the description read as a site. It reads "Runtime error: Timeout exceeded" now, for every cancellation cause. -
A broadcast call of a user function reports its element count without evaluating. With
S := x ↦ (x, x²)andLa counted list,S(L)andd·S(L)typedlist<tuple<number, number>>yet answeredcount: undefined, whiled·Lanswered the length; a caller deciding whether to evaluate had to walk the call to learn its size (Tycho item 326). The count is now the mapped argument's, read through the same participant rule the type derivation uses, for a function literal with scalar parameters called with a collection. A numeric-tuple argument, participants of different lengths, a lone scalar-or-collection union, a declaredbroadcastable<T>or point-typed slot, a generic signature, and an argument count the signature does not admit still answerundefined. -
Definite integrals of
|u|andsgn(u)withulinear (#352). The antiderivative of such an integrand can have asgn(u)term, which jumps whereuchanges sign, and the value of the integral included that jump.\int_{-\pi}^{\pi} |x|\cos x\,dxevaluated to-2; it is now-4.\int_{-1}^{1} |x|\cos x\,dxhad the value2(\cos 1 + \sin 1); it now has the value2(\cos 1 + \sin 1) - 2(printed as-2 - \sin(-1) + \sin(1) + \cos(-1) + \cos(1)).\int_0^{\pi} |x|\cos x\,dxwas-1; it is now-2. The integral is now split at the points whereuchanges sign. This also gives a value to integrals that stayed unevaluated before:\int_0^2 |x-1|\,x\,dx = 1,\int_{-1}^{1} \operatorname{sgn}(x)\,x\,dx = 1, and the nested\int_{-1}^{1}\int_{-1}^{1} |x-y|\,dx\,dy = 8/3. When a bound is symbolic and the sign change could be between the bounds, the integral now stays unevaluated instead of returning a wrong value:\int_{-a}^{a} |x|\cos x\,dxwas\sin(a)|a| + \cos(a)\operatorname{sgn}(a) - \ldots. -
|u|with a non-real coefficient. The antiderivativeu|u|/2of|u|is valid only for a realu.\int_{-1}^{1} |x+i|\,dxgave\sqrt2; the value is\sqrt2 + \operatorname{arsinh}(1), and the integral now stays unevaluated (.N()gives2.29559).\int |x+i|\,dxalso stays unevaluated. -
Antiderivative of a function of an argument that does not depend on the variable.
\int x\sin(0x+1)\,dxgave\tilde\infty\cos(1),\int x\cos(2x-2x)\,dxgave\operatorname{NaN}and\int\cos(2x-2x)\,dxgave0: the coefficient of the variable was0(or only the last linear term was read) and was used as a divisor. They now have the values\sin(1)\,x^2/2,x^2/2andx. -
.N()of a definite integral when the integration variable has a value. Withx := 5,\int_{-\pi}^{\pi} |x|\cos x\,dxgave about0under.N(), because the integrand was compiled withxread as5, which removed the|x|. It is now-4, asevaluate()already gave. -
Integration by parts with a sine or cosine of a linear argument.
\int x\sin(-x)\,dxand\int x\sin(2x)\,dxstayed unevaluated; they are nowx\cos x - \sin xand-\frac12 x\cos(2x) + \frac14\sin(2x). -
#354:
Hypergeometric2F1with an integerb − aorc − a − bis accurate to about 12 digits. Whenb − ais an integer, the formulas that continue ₂F₁ outside the unit disk through1/zand1/(1−z)have a removable singularity; whenc − a − bis an integer, so do the ones through1 − zand1 − 1/z. When no other formula applied (typically with both differences integral), the value was the average of two evaluations at nearby parameters, which kept as few as 4 correct digits:Hypergeometric2F1(-0.5, 0.5, 1, 1.17 + 0.45i)was0.67816275 − 0.23183000i(relative error 5e-6) and is now0.67816104159303 − 0.23183311013602i. These cases now use the logarithmic limit of those formulas (DLMF 15.8.8–15.8.11). Over a grid of such parameters and arguments outside the unit disk, the largest relative error against mpmath went from 3e-4 to 1.2e-14. A difference up to 400 is evaluated this way (relative error below 5e-13 at 400); a difference that rounding moved off the integer (2.3 − 0.3is1.9999999999999998) counts as that integer. -
Hypergeometric2F1returnsNaN(stays symbolic) instead of wrong digits. Every formula it uses is a sum whose terms can cancel, and none was checked: a result is now rejected when its largest term (for a formula with two parts, the larger part) exceeds it by more than a factor of 1e3 (1e6 for the machine series with a realzfrom −1 to 1/2), and another formula is tried; when no formula meets that bound, the best one is used if its factor is at most 1e4 (about 11 correct digits), and the result isNaNotherwise. Over 1,000 random real arguments (a,bfrom −25 to 25,cfrom −25 to 45,zfrom 0.5 to 1 or below −1), 936 have a value, 5 of them with a relative error above 1e-12 (the largest 2e-11).Hypergeometric2F1(14.31, 4, 42.19, 0.752)had 7 correct digits at machine precision. A series whose third parameter is negative was also stopped at a small term just beforen = −c, after which its terms grow again:Hypergeometric2F1(6.066, -9.19, 38.584, 0.5193)had 6 correct digits.Hypergeometric2F1(0.5, 60.5, 1.5, 2.5 + i)was1.3e-18 + 1.3e-18iand is now0.0132129072203 + 0.0686091095322i. A series that shrank and then grew again (a negative third parameter, or large parameters withznear 1) was stopped at its first small term; with real argumentsHypergeometric2F1(102, 1, 2, 0.9)was1.15e87at machine precision and2.05e98at the default precision, instead of1.10e99. The big-decimal series adds working digits when its terms cancel, and so does the big-decimal connection formula when its two parts cancel (Hypergeometric2F1(26.91, 7.09, 38.36, 0.689)at precision 30 had 23 correct digits). The average of two evaluations at nearby parameters, used when no formula applies, kept as few as 4 correct digits when a parameter difference is a nonzero integer: it is now used only when that difference is 0, and the result isNaNotherwise. A polynomial of degree above one million (such asHypergeometric2F1(-1e20, 0.5, 1.5, 2)) isNaNinstead of a computation that does not end. -
A
vars-mapped input reached through an assigned value is no longer baked into compiled code. The compile-time folder stopped only at a subtree that mentioned a mapped name itself. Witha := sin(y_0)andy_0mapped invarswhile also holding a value,cos(a)folded to a number, the body ofu := L ↦ 2L − aunder the callu(1)folded, and so did a definite integral or a limit over such a value: the input was in the argument bag but changing it changed nothing (a slider-dependent document definition drew outdated geometry; Tycho item 328). The guard now follows assigned values and user-function bodies at any depth, on every target, so the value is emitted as code reading the input (const _val_a = Math.sin(_.y_0); inlinesin(u_y0)on GLSL/WGSL). The same guard makes a function the caller overrides through thefunctionsoption run the caller's code when it is reached through a symbol's value (q := g(3)withfunctions: { g }ran the engine'sg). A multi-clause function (function w(x) {…}twice in Epsil) is read clause by clause for both purposes, andfreeSymbolsnow lists a symbol that only a clause body reads; it was missing. -
Reading the type of a product or a sum of calls to declared user functions no longer takes exponential time when a function body reads an undeclared name. With
handrdeclared(real) -> unknownand assigned bodies that read a freeT, the type ofh(1) r(1)cost 5 173 signature derivations and a 400-term sum of such calls did not finish in 60 s (Tycho item 329). Each derivation boxed the body again in a fresh scope, declared a newTthere, and narrowed it by its use; that narrowing advanced the engine-wide definition version, which is part of every derived-signature memo key, so each derivation threw away the memo of every other declared function. A narrowing by a use no longer advances that version (it can only leave a memoized result wider, never wrong; the rollback of a narrowing still advances it). The sum now types in about 30 ms with 251 derivations, the same count as withTdeclared, and the reported types are unchanged. -
Assigning a function that nests calls to functions declared with an
unknownresult no longer takes exponential time. WithW_1,W_2declared(unknown, …) -> unknown, their bodies applying undeclared names (b(x, y)), andEassigned a body that nests them (W_1(W_2(…))), the onece.assigntook 2 ms at depth 1, 3.5 s at depth 2 and more than 60 s at depth 3, with 706 258 signature derivations (Tycho item 336, a regression since 0.137.0; three consumer documents did not open). Boxing a function literal that applies an undeclared name declares that name in the literal's own scope; the engine then re-derived every stored definition waiting on that name, although none of them can see that scope, and each rebuild invalidated the memoized signature of every other function. A definition is now re-derived only when the new binding is on its scope chain, and the declarations a signature derivation makes in its own temporary scope no longer invalidate other memos. Depth 6 takes about 5 ms and the derivation count grows linearly; the reported types are unchanged, and a real declaration or assignment still updates them. -
A recursive call no longer overwrites the caller's loop variables, big-operator indices, or inner-block locals. A
forloop variable, aSum/Productindex, or aletinside a nested block of a function body lives in a scope created once when the body is canonicalized, so every application of the function shared it. After a recursive call returned, the caller read the callee's last value: infor c in [n*10, n*10+1] { …f(n+1)…; out = [...out, c] }the caller'scread21, not10, and a recursive tree walk visited only the first branch below every node (Tycho item 330). A re-entrant application now saves those scopes on entry and restores them on exit; a call that is not re-entrant is unchanged. The same holds for a recursive call made while a lazy comprehension built by the function is read. -
A function that spreads a parameter into a list or set literal splices the argument's elements.
g(a) = [...a, 0]gave[[9, 0]]forg([9])andh(a, x) = [...a, x]underreduce([1, 2, 3], h, [])gave[](Tycho item 331). The body canonicalized toJoin(a, [0])without runningJoin's own canonical handler, so the parameter was never typed from its use and stayedunknown, and a user function with anunknownparameter is applied to each element of a list argument. The parameter is now typed as a collection, as it is for a body writtenJoin(a, [0]), sog([9])is[9, 0]and the reducer returns[1, 2, 3]. The set literal{...a, 0}had the same defect. -
A block-local
letwith a literal value is typed from that value on the routes that never run theDeclare, and a nested block reads the hoisted binding.let queue = [[…], …]followed bywhile i <= Length(queue) { … queue = [...queue, g] … }declined to compile to JavaScript with "operand is not an indexed collection": the hoisted binding stayedunknownuntil theDeclareran, so the first use (Length, or theJoinbehind a spread) inferred the callee's loose parameter type (collection,collection<any>) onto it, and the later assignment could only widen that. Two fixes (Tycho item 332, programs b and c). TheBlockcanonical hoist now records the type of a closed literal initial value (a number, a string, aList/Tupleof such), widened through the assignment table asAssigndoes, solet xs = []hoistslist<never>andlet i = 1hoistsinteger, not the singleton1. And a reference to a hoisted local from a NESTED block (anifbranch, a loop body) now finds the hoisted binding one or more scopes up instead of declaring a secondunknowncopy in the nested scope and caching that copy as the name's binding for the rest of the block, which left the hoisted binding, and any declared type on it, unused. With the locals typed this precisely, one convention had to be narrowed: a list literal reads an unknown bare symbol as a number (the generic-symbol fold,[x, y]is avector<2>), which typed[c]asvector<1>for a block localcstill waiting for itsletto run, soout = join(out, [c])madeoutalist<number>and a laterstringJoin(listFrom(out))was refused althoughoutonly ever held characters (the JSON parser example of the Epsil documentation). A hoisted block local is no longer read as a generic number by that fold:[c]is alistuntilcis typed. A destructuringlet (x, y) = pfrom a block-local tuple literal now compiles too (the arity is statically known); it declined before. -
A compiled
forloop whose list holds complex values no longer reads the loop variable as a real number. The JavaScript target shaped the loop variable from its declared type (number, read as real) while thefor (const k of …)loop handed it the{re, im}objects the list produced, so every comparison onkwas false and every division gaveNaN: an Apollonian-gasket program returned 4 circles instead of 224 (Tycho ask 332). When every element of the list is complex the variable is now shaped complex; when only some are, or their lanes cannot be told apart, the compilation is a lane mismatch, which the defaultautomode answers by recompiling in complex mode andstrictmode refuses. The check that a local first assigned a real value is never later assigned a complex one now also enters the braced bodies ofif/for/while(r = r + 1/kinside a loop put an object intor, and the next pass turned it into a string).Max/Minof real operands now keep the operands' bounds (Max(0, x)isreal<0..>, it wasreal), so√(Max(0, x))compiles to a real square root instead of the complex route. -
A
letwith an initial value is typed from that value, on every route, and the recorded type follows later widening. OnlyAssignrecorded the static type of its value on the local at canonicalization; aletdid not, solet cands = fourth(a, b)orlet gap = queue[i]stayedunknownon the compile route, andfilter(cands, …)orgap[1]failed the compiler's shape gates although the callee's declared result and the list's element type were known.Declarenow records the initializer's type as assignment evidence, asAssigndoes; a declared type still wins. Reading the type once, at the statement, was too narrow when a later statement of the same loop widened the source (let e = circles[j]beforecircles = [...circles, (k, x, y, depth)]withk: number): in the complex lanee[1] - kthen read a{re, im}object as a number, the de-duplication never fired, and the compiled gasket program never ended. The block now re-reads eachlet/Assignvalue type and eachforindex type after its statements are canonicalized, until they stop changing; a type that keeps growing is capped, and a local that never settles falls back tounknown. An element read of a union of tuple types now gives the types at that index only. With type annotations on its helpers, Tycho's gasket program a compiles and answers 224 in about 18 ms; the interpreter takes 21 s (Tycho item 332). -
An Epsil program whose helper functions have no type annotations compiles to JavaScript. Tycho's gasket program a, as written, compiles and answers
(224, [4, 6, 18, 54, 86, 28, 8, 8, 4, 4, 4, 0])in about 4 ms; the interpreter takes 21 s (Tycho item 332, user decision 2026-09-29). Four changes. A parameter that the body only counts or measures (function seen(acc, c) { count(acc, e => …) > 0 }) is typedcollectionby inference, which also admits a set, andCount,Lengthand the other list operators refused it. Such a parameter now compiles with a check at run time that the value is an array; a set or a dictionary that arrives there stops the run with aRangeError. A DECLARED collection type still declines, so that the interpreter evaluates it.Max(0, x)counts as non-negative in the compiler whenxis read as real, also whenxmay be NaN, so√(Max(0, x))keeps the real square root. A sum and a square root of operands that may be NaN keep their range (p² + q²forp, q: nan | realisnan | real<0..>, and its square rootnan | real, where they werenan | realandcomplex | nan), so the inlined programs b and c now compile in the real lane and run in about 5 ms. The types recorded for a local from its assignments are joined by structure: two lists join their element types and two tuples of one length join slot by slot, where the union of the two list types reduced to a barelistand every local read from it becameunknown. -
Count(xs, p)with a predicate compiles to JavaScript. It lowers asCountIfdoes; only the value formCount(xs, v)still declines, since it needs the interpreter's structural element equality. -
A restriction with a list-valued condition has the right static type over every ordered carrier.
P\{P.x > 1\}withPdeclaredindexed_collection<tuple<number, number>>,collection<…>orrangewas typedlist<indexed_collection<tuple<number, number>> | missing>: the whole collection type stood where the element type belongs, because the type handler recognized only alistcarrier while the evaluation zips any finite collection. The cells are now typed from the element type (list<missing | tuple<number, number>>) when the carrier is finite by type (list, a vector or matrix,range, a dictionary or record). A carrier whose finiteness the type does not decide (indexed_collection<T>,collection<T>, which admitRange(1, ∞)) types its cells as the element type joined with the carrier type (list<indexed_collection<T> | missing | T>), because the evaluation puts the WHOLE value in the cell when it is not finite. A condition that is a set or acollection<boolean>is a mask too, as it is at evaluation. For the same reasonPointX/PointY/PointZover a declaredcollectionorsetof points is typed as a list of coordinates, not as a point, andWhich/Ifwith a list condition over arangetype their cellsinteger, notinteger | range. The evaluated values were right and are unchanged (Tycho item 335). -
Narrowing a union type meets it arm by arm.
narrow('missing | vector<real^4>', 'indexed_collection | dictionary')wasnever, because the union was tested only as a whole; it isvector<real^4>. A local typed from an element read, which admits absence, was narrowed toneverby its first indexed use and the expression built on it became a type error. -
Beta,ZetaandLbwrite conventional LaTeX when applied, and the sign of a numeric fraction moves in front of it.Beta(2, 3)wrote\Beta(2, 3)(capital beta is roman, not a separate glyph — MathLive renders it as an error) andZeta(3)wrote\Zeta(3); both came from the fallback that spells an unrecognized function head as its symbol's notation, which for these two names is the Greek-letter entry. They now write\mathrm{B}(2, 3)and\zeta(3); the old spellings still parse, and a bare\mathrm{B}is still the upright letterB.Lb(x)wrote\lb(x), not a standard LaTeX command, and now writes\log_2(x)(which already parsed toLb). A negativeRational, or a fraction with a number denominator, wrote its sign inside the numerator or the denominator —Rational(-1, 2)as\frac{-1}{2},Divide(x, -4)as\frac{x}{-4}— or, forNegateof a fraction, with a redundant parenthesis (Negate(Rational(3, 4))as-(\frac{3}{4})); all three now write the sign in front:-\frac{1}{2},-\frac{x}{4},-\frac{3}{4}. A fraction with a symbolic denominator keeps the sign in the numerator (\frac{-1}{x}), since-\frac{1}{x}reads back as a different expression (#345, contributed by enumeratio). -
A negative big number keeps its digits when its sign moves. The LaTeX serializer removed the sign of a negative literal in a sum through a JavaScript double, so
Add(x, -9007199254740993)wrotex-9\,007\,199\,254\,740\,992. The sign is now removed from the digit string. -
\operatorname{rank}(A)parses toMatrixRank, andMatrixRank(A)writes\operatorname{rank}(A). It parsed to the free symbolrankapplied toA, the same gap\operatorname{lcm}(→LCM) already covered for a different head (#345, contributed by enumeratio). -
Zeta(s, a)andHurwitzZeta(s, a)honorce.precisionfor realsanda(part of #340, contributed by enumeratio). Both were machine precision only at every engine precision;HurwitzZeta(3, 1/2).N()atce.precision = 50now returns 50 correct digits (8.4143983221171599977981671305801499353549040463835) instead of a double's ~16. The digits are significant digits at every magnitude:HurwitzZeta(200, 10)(about1e-200) andHurwitzZeta(-400.5, 0.3)(about7.75e549, past the double range) are correct to the last digit. A value the kernel cannot reach within its limits (s below about −1279) falls back to the double kernel, and stays symbolic where the double overflows. A complex operand still evaluates at machine precision — the complex special-function kernels do, at every engine precision. -
a * 2nparses in Epsil. The right operand of an explicit*or/refused an invisible multiplication, soa * 2nparsed asa * 2with anunexpected-symboldiagnostic forn— and the serializer writes that form.a * 2nis nowa·(2n)anda / 2nisa/(2n). -
Exact 2×2 eigenvalues.
Eigenvalues([[1, 2], [3, 4]])was[5.372…, -0.372…]; it is now[(5 + √33)/2, (5 − √33)/2]. -
AdjugateMatrixandPseudoInverseevaluate. Both stayed unevaluated for every matrix.AdjugateMatrixis the transposed cofactor matrix, for any square matrix.PseudoInverseis computed for a full-rank matrix: the inverse of an invertible square matrix,(A*A)⁻¹A*with full column rank,A*(AA*)⁻¹with full row rank; a rank-deficient matrix stays unevaluated. -
Trigonometry.
InverseFunction(Csc)returned aninvalid-symbolerror, andInverseFunction(Cot),(Coth)stayed unevaluated; every circular and hyperbolic function now maps to its inverse.Arccothas exact special values (Arccot(1)isπ/4,Arccot(-1)is3π/4).Sinc(Pi)is exactly0(it was1.2e-25underN) andSinc(Pi/2)is2/π.TrigExpand(Sin(x + Pi/2))iscos(x)(it leftcos(π/2)andsin(π/2)unreduced), andTrigExpand(Tan(x + Pi/2))is-cos(x)/sin(x). -
Arithmetic.
Log(1/8, 2)andLb(1/8)are-3(they stayed symbolic, whileLog(8, 2)was3).ComplexRoots(1, 4)is[1, i, -1, -i], exactly and underN(it was[1, 6.1e-17 + i, …]), and the roots of an exact real are exact (ComplexRoots(8, 3)is[2, -1 + √3 i, -1 - √3 i]).SupremumandInfimumof an open interval are its endpoints (they stayed symbolic).Interpret(1 + 2 + … + n)works from Epsil, whose left-nested sum the recognizer did not match.PreIncrement(5)is6andPreDecrement(5)is4(they had no evaluate handler).Sum(2^(-k), (k, 0, oo))is2, asSum((1/2)^k, …)was: the geometric-series rule did not read a negated index. -
Exact 3×3 eigenvalues. A matrix of exact rationals whose characteristic polynomial has a rational root has exact eigenvalues:
Eigenvalues([[2, 0, 0], [0, 3, 4], [0, 4, 9]])is[11, 2, 1](it was[11, 1.000000000000003, 2.0000000000000018]), andEigenvalues([[2, 1, 0], [1, 2, 1], [0, 1, 2]])is[2 + √2, 2, 2 − √2]. The exact eigenvalues are ordered by decreasing real part.N(Eigenvalues(…))gives their values. -
N(Arcosh(x))for a realxin [−1, 1] is purely imaginary (N(Arcosh(1/2))was5.6e-17 + 1.047…i). -
The upper incomplete gamma
Gamma(s, x)forRe(x) < 0(#353). On the negative real axis.N()dropped a term once|x|grew:Gamma(-1, -20)was1357393.643181685, it is now1357392.893567075 + 3.141592653589793i;Gamma(1/2, -20)was-111433110.2i, it is now1.7724538509055159 - 111433109.93704489i. Off the axis the error grew with|x|whereverRe(x) < 0(Gamma(-0.7, -12+5i)had 9 correct digits). The complex kernel now picks a method without cancellation for each region; measured against mpmath, the relative error is below1e-13for|x|up to 150 in every direction. The same kernel givesE₁,Ei,Si,Ci,Shi,Chi,erfanderfifor complex arguments, and they gain the same accuracy (Si(4+6.9i)had 5 correct digits). For a negative integersand a complexxin the right half-plane the old kernel was also inaccurate:Gamma(-9, 13+7.5i)was3.5e-18 - 1.1e-17i, it is now2.449e-18 + 7.84e-20i. The real kernel had the same defect for a realx > 0:Gamma(-15, 60)was2.389e-55, it is now2.457e-55, andGamma(-9, 30)had 8 correct digits, now 15. The complexGamma(s)is now accurate near its poles (Gamma(-1.999999 + 10^{-9}i)had 10 correct digits, now 15). Where the kernel cannot certify about 12 digits,.N()leavesGamma(s, x)unevaluated: somesclose to0, -1, -2, …withxnear the negative real axis (how close depends onx), and some points with|Im s| > 10(on 2500 random points with|Re s|, |Im s| ≤ 30and0.01 ≤ |x| ≤ 600it declines at 87 and answers the others with an error below1.7e-13). A value or a factor outside the range of doubles no longer spoils the result:Gamma(-5, -712).N()wasNaN, it is now1.2778259067196765e+292;Gamma(-170, -100).N()was7.4e-308, it is now3.925357618957481e-299; a value above the range of doubles (Gamma(-5, -2000)) stays unevaluated, where it was~oo(the pole of the one-argumentGammaat-5was applied to the two-argument form). The complexGamma(z)had the same kind of defect:Gamma(-2+300i).N()wasNaN, it is now3.448337914830325e-211 - 8.293880091137963e-212i, andGamma(150+i).N()was~oo, it is now1.1034056813344657e+260 - 3.632309144571929e+260i.
New Features
-
Indeterminate: the exact answer to an indeterminate form. A second number with no value besideNaN,ce.Indeterminate(MathJSON"Indeterminate", LaTeX\operatorname{Indeterminate}, EpsilIndeterminate), is the answer an EXACT computation gives to a form such as0/0, whereNaNstays the result of a floating-point computation that failed and the absence marker. Its double value isNaN, soisNaNis true and its type widens tonan(its own value type printsIndeterminate); the newisIndeterminateproperty is true for it only. It is not the same value asNaN(isSameand.is()are false between them, and each has its own hash);EqualisFalsefor it as forNaN. An operation forwards it when no operand is inexact:Sin(Indeterminate),Indeterminate + 1,2·Indeterminate,Max(1, Indeterminate)andMean([1, Indeterminate])areIndeterminate, while a float orNaNoperand givesNaN(Indeterminate + 1.5,Max(Indeterminate, NaN),Max(1.5, Indeterminate)), and so does an operator that does not forward it (Hypot,GCD)..N()of any result isNaN, and compiled code spells it as the target'sNaN. It is a value, not an absent entry:IsMissing(Indeterminate)isFalseandCoalesce(Indeterminate, 5)isIndeterminate. No computed answer changes yet:0/0,0^0and the other exact indeterminate forms still evaluate toNaN; a later release switches them toIndeterminate(docs/plans/2026-09-28-indeterminate-value.md, GitHub issue #355). -
epsil --compile: run an Epsil program as compiled JavaScript. The CLI compiles the program (or, with--from latex, the LaTeX expression) to JavaScript and runs the generated code instead of interpreting it, and writes the value as it writes an interpreted result, under every output option. Compiled arithmetic is machine arithmetic, so2 + 1is the float3.0andsqrt(2)is1.4142135623730951; a symbol with no value is a runtime error, since compiled code has no symbolic values; and a construct the JavaScript target declines is a runtime error naming it, never an interpreter fallback. See "Running a Compiled Program" in the CLI guide. -
ce.withStepBudget({ steps, label }, fn): a deterministic hang guard. Runsfnwith at moststepssteps of engine work, where a step is one of the engine's cooperative cancellation checks: the count of a computation on one engine state is the same on every machine, unlike a wall-clock limit, so a host can bound a decision without letting the clock decide it (Tycho item 326). The step is an opaque unit — deterministic, not a measure of cost, and not comparable across engine versions — so a budget is tuned empirically, with awithTimeLimitspan outside it as the guard of last resort. A spent budget throws aCancellationErrorwith the newcause: 'step-budget', the span'slabelas itsattributionand the active labels inspans; a time expiry keepscause: 'timeout'.CancellationCausegains'step-budget', andisTimeoutCancellationanswerstruefor both causes. An Epsil program stopped by a spent budget answers["Error", "Step budget exhausted", "step-budget"], as a timeout answers["Error", …, "timeout"]. -
PolyGamma(m, z),Digamma(z)andTrigamma(z)evaluate at a complexz(#340, contributed by enumeratio).PolyGamma(1, 1+2i).N()is0.1249311621409446 − 0.4778255501472298i: ψ⁽ᵐ⁾(z) = (−1)^(m+1) m! ζ(m+1, z) for m ≥ 1 (DLMF 5.15.2), and an asymptotic series for the digamma. Left of the imaginary axis the reflection formula is used, so the cost does not depend onRe(z)(PolyGamma(8, -10^12+i)answers at once). The order is limited to 10 000. A value outside the range of a double stays unevaluated, not~ooor0. The compiled JavaScriptPolyGammais now marked real-only, asZetais: a complex argument there ran the real kernel. -
LerchPhiis new (#340, contributed by enumeratio).LerchPhi(z, s, a)is the Lerch transcendentΦ(z,s,a) = Σ zᵏ(k+a)^(−s), generalizingHurwitzZeta(LerchPhi(1, s, a)reduces to it exactly).LerchPhi(0.5, 2, 1).N()evaluates by direct summation,LerchPhi(3, 2, 1).N()continues past the unit disk through the incomplete gamma function, andLerchPhi(-1, 1, 1)isln 2on the disk's rim, where direct summation alone would need an impractical number of terms. A base pointaat a non-positive integer is the poleComplexInfinitywhenRe(s) > 0andzis provably nonzero, as forHurwitzZeta. Values are computed at machine precision only, even whenprecisionis higher, and are within about 1e−11 relative of the true value (measured against mpmath). Where the kernel cannot vouch for that accuracy,LerchPhistays symbolic underN()instead of returning a wrong value: past the unit disk, for someswithin about10⁻²of a positive integer whenzis near the real axis (the incomplete gamma function it needs is inaccurate there), when the terms of a sum cancel too far (for exampleLerchPhi(-0.999, -6, 1)orLerchPhi(-2, -3.5, 1.5)), and when the base point is further left thana = −10⁶. A float operand gives a float result (LerchPhi(1.0, 2, 1)is1.6449…, notπ²/6).LerchPhicompiles to JavaScript, GLSL and WGSL for real operands. The JavaScript lane runs the interpreter's kernel and isNaNwhere the value is complex (realz > 1, ora < 0with a non-integers) or the kernel declines. The GPU lane isNaNpast the unit disk (except fors = 0, −1, −2, whereΦis a rational function ofz), and inside it wherever its f32 sums cannot converge within their term budget (|z|closer to 1 than about 0.995) or have cancelled too far. -
PolyLognow evaluates at a non-integer or complex order (#340, contributed by enumeratio).PolyLog(s, z)previously answered only an integer orders ≥ 2;PolyLog(2.5, 0.5).N()is now0.5549972787175124andPolyLog(1.5+0.5i, 0.5).N()is0.6126403889001154 - 0.05103210425890372i, both byLiₛ(z) = z·Φ(z,s,1)through theLerchPhikernel at base pointa = 1. Past|z| = 1that kernel's continuation answers, and where it declines Jonquière's inversion formula takes over:PolyLog(1.5, -3).N()is-1.6790897305048254(a real order gives a real value everywhere on the real axis below-1).PolyLog(s, 1)andPolyLog(s, -1)now reduce exactly toZeta(s)and the Dirichlet eta identity(2^(1-s) - 1)·Zeta(s)for every order, not only an integer one (the kernel answers instead for an order within1e-6of 1 atz = -1, where the identity cancels). A negative integer order from-2to-12at a numberzuses its rational closed form, soPolyLog(-2, 1/2)is the exact6andPolyLog(-2, -2)the exact2/27. Every existing integer-order and elementary-form result is unchanged. The widened kernel declines (stays symbolic) rather than answer a value it cannot certify to1e-12: within1e-3of thez = 1branch point, whereRe(s)is too negative for the series inside the disk, and past the disk where both the continuation and the inversion formula decline (some orders within about1e-2of a positive integer, on or past the unit circle, for examplePolyLog(3.000001, 2)).numerics/polylog.tsrecords the measured boundaries.PolyLogcompiles to JavaScript, GLSL and WGSL for real operands; it was not compilable at all before. The JavaScript lane answers every order the way the interpreter does. The GPU lane answers the orders1,0,-1and-2to-12in closed form and other orders only for|z| < 1and atz = 1. -
PolyLogof a real order and a realzis typedrealonly whenzis known to be at most 1 (or the order is an integer≤ 0). The value is complex on the cutz > 1(PolyLog(1.5, 2)is1.549 - 2.951i), and the type used to sayreal. A float order now makes a closed-form result a float:PolyLog(0.0, 1/2)is the float1. -
Examples for the arithmetic, trigonometry and linear-algebra libraries, with a hand-written introduction for each reference page: 97, 84 and 40 examples, each executed when the pages are generated.
0.140.0 2026-09-27
Behavior Changes
-
The spelling rule holds at machine precision, and a float operand makes a numeric result a float. On a
precision: 'machine'engine,ce.parse('2.0')was exact, so\sin(2.0)stayed symbolic there while it evaluated at the default precision; a machine number is now always inexact, and exact integers are the exact class, asce.number(2)already was.2.0^2,\ln(1.0),2.0!,\sinh(0.0),4^{0.5}and similar results of a float operand are floats at every precision (user decision 2026-09-27).\max(2.0, 3)still returns its operand3as given, and\operatorname{sign}(2.0)is still the integer1, as in Mathematica. A consequence at machine precision:(1.5+1.5)·(1/7)is the float0.42857142857142855, not the exact3/7. Also:x^{0.5}staysPower(x, 0.5)(only an exact1/2becomes\sqrt{x}), asx^{1.0}already stayed; a float0is no identity in a sum (0.0 - 2is the float-2);\gcd(4.0, 6)is the float2; the statistics of float data are floats (Mean([1.0, 3.0])is2.); a list of more than 100 numbers computed from floats keeps its integer-valued elements as floats.isMachineNumericis nowfalsefor a number or list that holds an integer-valued float such as2.0, because re-boxing it withce.list(...)would make it exact. -
Integrateof a tuple integrates each coordinate.\int_0^1 (x, 2x)\,dxstayed unevaluated; it is(1/2, 1), as\sum_{n=1}^3 (n, 2n)is(6, 12)(user decision 2026-09-27). The indefinite integral gives(x^2/2, x^2), and a list bound gives one tuple per element of the bound (\int_0^{[1,2,3]} (x, 2x)\,dxis[(1/2, 1), (2, 4), (9/2, 9)]). -
Implies,Nand,Nor,XorandEquivalentfollow the three-valued rules ofAndandOrfor an absent operand (user decision 2026-09-27). An operand that decides the result wins (Implies(Missing, True)isTrue; it wasMissing); beside an unknown symbol the expression stays symbolic (Nand(A, Missing)wasMissing), asAnd(A, Missing)does; the result isMissingonly when the other operands are decided and do not decide it (Nand(True, Missing)). The same rule decides an implication beside an unknown:Implies(A, True)andImplies(False, A)areTrue(they stayed unevaluated). -
Compiled JavaScript spells an absent point, tuple or list
undefinedin arithmetic.2·PandP + Qwith an absent pointPgaveNaNand[NaN, NaN]; they giveundefined, as a read of the absent point already did and as the interpreter fallback does (user decision 2026-09-27). A coordinate read (PointX(P)) staysNaN. -
A user binding shadows a capitalized library name.
let Pi = 3; PiwasPi(π) and is now3; so forExponentialE,Missing,Undefined,True,False,AllandNone.function Square(x) { x + 100 }; Square(3)was9and is now103; so for a userSqrt,Negate,Exp,Ln,Log,Power,Root,Divide,AddandMultiply, on the Epsil and the box routes. These names are interned constants, or heads that canonicalization folds by name, and both paths returned the library definition without looking the name up. ShadowingAdd(orMultiply,Divide,Power,Negate) also changes the operator that builds it (+,*,/,^, unary-) within the binding's scope, as shadowingSubtractalready did. Compiled code does not use a shadowed library operator:compile()of such a call fails closed and falls back to the interpreter (it emitted the library operator before,y * yfor a userSquare, and so for a userAbsorSin). -
A binding of
Nothing,MissingorUndefinedis an error. These absence markers are recognized by their name (an operand namedMissingis read as absent whatever it is bound to), so they cannot be rebound.let Missing = 3and the other binding forms (an assignment, a function, a parameter, a loop variable, amatchpattern) reportabsence-marker-binding, and the declaration evaluates to that error. Before,let Nothing = 3was silently ignored, andlet Undefined = 3; Undefined + 0wasNaN. -
Lengthof an infinite collection is+oo.Length(Integers),Length(Repeat(5)),Length(Cycle([1, 2]))andLength(Interval(0, 1))stayed unevaluated; they are now+oo, the answerCountgives. Only an unboundedRangeanswered+oobefore. A collection whose size is not known (aFilterover an infinite source) still stays unevaluated. The reported type follows:Lengthof a tuple, a string, a literal list or set, a dimensioned list or aRangewith finite literal bounds isinteger, andLengthof any other operand, including a symbol typedlist<…>, isinteger | infinity, because alistvalue can be an infinite lazy list. A function declared to returnintegerwhose body isLength(xs)keeps its declared type. -
StringFromof a string, a character or a symbol returns its text.StringFrom("hi")returned"\"hi\""andStringFrom(True)returned"\"True\"": the printed form of those values carries quotes, andStringFromused the printed form. It now returns"hi"and"True", as its description says, and a symbol such asintegerconverts to its bare name. Other values keep their printed form (StringFrom(x + 1)is"x + 1",StringFrom(Pi)is"pi"). -
Timingdeclares an unnamed tuple. Its result type wastuple<time: number, result: value>, but the value carries no element names, soTiming(expr).resultwas anincompatible-typeerror. The declared type is nowtuple<number, value>; read the parts asTiming(expr)[1]andTiming(expr)[2]. -
The imaginary part of a numeric result has the working precision.
.N()of√2 + √2iprinted1.4142135623730950488 + 1.4142135623730951i(21 digits on the real part, 16 on the imaginary part); both parts now have 21 digits, or the engine's precision.Ln,Exp,Power,RootandSqrtof a complex argument compute both parts at the working precision above machine precision (Ln(1.1+1.1i).N(),e^{1+2i}at 50 digits). The other complex transcendental functions (Sin,Gamma,Zeta, …) keep machine precision. The rounding noise a complex kernel removes is now relative to the working precision (10^{2−precision}times the modulus, it was10^{-14}), and a component that is small but real is kept:e^{i·10^{-800}}is1 + 10^{-800}i. -
The Epsil serializer writes the lowercase spelling of a library name.
serializeEpsil(["Sin", "x"])issin(x),"Pi"ispi, and["Map", "Sin", "xs"]ismap(sin, xs); theepsil formatcommand, the--epsiloutput mode of the CLI and the MCP server, and the snippet a diagnostic quotes follow. Before, the serializer wrote the MathJSON names (Sin(x)). A library name keeps its MathJSON spelling where the lowercase one would read back as something else: when the expression binds or mentions the spelling (let sin = 3, a parameter namedpi), and when the newisBoundoption reports it bound outside the expression (the CLI passes the session's bindings). The new optionlibraryNames: 'mathjson'restores the previous output. A round trip throughparseEpsilalone now yields the raw lowercase head (["sin", "x"]);resolveLibraryNames, whichexecuteEpsiland the CLI already run, reads it back toSin. -
Epsil library reference pages.
src/epsil/docs/reference/<category>.md, one page per library category (19 pages), lists every definition under its Epsil spelling with its MathJSON name, its signature, its full description and its executed examples; the Standard Library page links each category to its reference page.npm run docgenerates them (scripts/build-library-reference.ts); a hand-written introduction inreference/<category>.intro.mdis spliced in when present. -
A repeated constant in a call resolves. The Epsil resolution pass read a symbol operand that also occurs in another operand of the same call as the call's variable, for every operator:
[pi, pi],max(pi, 2 * pi)and(pi, pi)leftpiunresolved, and so didseries(x + pi, x, pi, 3). The rule now reads only the operand positions that hold a variable: the positions whose parameter is typedsymbolin the signature (D,series,factor, …), and a listed position for the operators that type their variable loosely (limit,solve,jacobianMatrix,characteristicPolynomial,findRoot,findFit, and the trailing variable oflinearRegressionandpolynomialFitwhen it is a bare symbol). -
A user binding named like a library name keeps its spelling. With the lowercase output,
let Pi = 3; f(Pi) = Pi; f(4)was writtenlet pi = 3; f(Pi) = pi; f(4), which reads the outer binding (3 instead of 4). A library name the expression binds — bylet, assignment, function name, parameter, loop index, match pattern or the variable operand of a binder — is written as is. -
A dictionary holding a list prints in Epsil.
serializeEpsilread a value of the MathJSON{dict: …}form as a nested expression when it was an array and as a symbol when it was a bare string, so the JSON of a boxed dictionary holding a list (let d = {"xs" -> [1, 2]}; din the REPL, whose value serializes as{dict: {xs: [1, 2]}}) threw aTypeErrorin the--epsiloutput mode. A{dict}value is now read as the engine reads it: an array is a list of values, a bare string is a string, a boolean is a boolean, and an expression is an expression object ({fn: …}). The JSON{dict: {z: ["Add", 2, "x"]}}therefore prints{"z" -> ["Add", 2, "x"]}(a list of a string, a number and a string), where it printed{"z" -> 2 + x}before. -
Limitshas no Epsil spelling.limitsis the type the engine declares for an indexing clause, so the spelling could never resolve to the operator; the Standard Library page listsLimitswith no Epsil column.
Issues Resolved
-
Modof a float near the double range keeps its remainder. At machine precisionMod(2.0, 9007199254740991.0)was1(the formula added the divisor before the second remainder, and2 + 9007199254740991rounds); it is2. Compiled JavaScriptMod(x, 9007199254740991)had the same defect (1forx = 2). The base-10 and base-2 logarithms of a machine float now use the correctly rounded primitives on every route, so a short list and a long list agree in the last digit, and\log_{10.0}(1000)is3. -
Compiled
And,OrandNotwith an absent operand follow the three-valued tables when used as a value. WithMabsent at run time, compiledAnd(M, False)gaveundefined(the interpreter:False),Or(M, False)gavefalse(the interpreter:Missing) andNot(M)gavetrue, in JavaScript and in Python; the condition position ofIfandWhichwas already right. Ordinary booleans keep the plain&&/||. -
GroupBywith a character key. A key function that returns a character (GroupBy(["apple", "avocado", "banana"], s => First(s))) grouped under the quoted text ("\"a\""), because a character key was stringified with its quotes. A character is the same value as the one-character string, so it now keys the groupa, asTake(s, 1)does. -
A complex numeric value whose imaginary part is outside the double range keeps it.
(1+i)10^{800}under.N()was~ooand is1e+800 + 1e+800i;(10^{-200}(1+i))^2was0and is2e-400i;\sqrt{i\cdot10^{-600}}was0;2\cdot10^{-800}ievaluated to0; a complex part below the double range serialized to LaTeX as0. The inexact complex value now holds its imaginary part as a big decimal. Seedocs/plans/2026-09-27-big-decimal-imaginary-part.md. -
Exp(Ln(−2)).N()is−2. It printed−1.99…98 + 4.8e-16i: the logarithm of a negative real took a 16-digit imaginary part, whose rounding error the working-precisionExpthen read as a value. -
An exact complex number whose imaginary part is too small or too large for a double keeps it.
i\cdot10^{-800}evaluated to0,(1+i)10^{-800}to1/1e+800with the imaginary part lost,(10^{-200}i)^2and(10^{400}i)^2stayed symbolic,Mean([1, 10^{400}i, 3])read the exact datum as complex infinity, and an exact10^{-800} + iwas taken foriby the imaginary-unit recognizer: the exact value stored its imaginary part exactly but every "is this complex?" test read the cached machine double, which underflows to0or overflows toInfinity. Every numeric value now answersisComplexfrom its own representation (NumericValue.isComplex,BoxedNumber.isComplex, public on the number-literal interface), the finiteness, integrality and "is the real part zero?" tests on exact values read the exact fields, and the exact-to-double projection of a large rational is finite ((10^{400}+1)/10^{400}projected toNaN; it is1). The doubleimstays available as a projection for double kernels. This is Phase 1 ofdocs/plans/2026-09-27-big-decimal-imaginary-part.md; the inexact route (.N()of such values, and the imaginary part's printed precision) is unchanged until Phase 2.
New Features
ce.number({ re, im })builds a complex number from two parts, each a JavaScript number or aBigDecimal.ce.number({ re: ce.bignum('1e-800'), im: ce.bignum('2') })keeps both parts at full precision; a zero imaginary part gives a real number.ce.complex(a, b)is unchanged: it returns a machine-precisionComplex(its documentation now says so and points at the lossless routes, this overload and the MathJSONComplexnode).
0.139.0 2026-09-27
New Features
- Epsil comprehensions. A
forclause inside a list or brace literal builds the collection from an iteration. The brackets select the kind:[x^2 for x in 1..10 if x % 2 == 1]is a list,{x % 3 for x in 1..10}a set, and{s -> length(s) for s in ["ab", "cde"]}a dictionary. Clauses are separated by commas, and a later clause can use an earlier binding ([(x, y) for x in 1..3, y in 1..x]). A binding can be a tuple pattern ([p + q for (p, q) in pairs]), and each clause takes an optionalifguard. The set literal{1, 2, 3}is unchanged. - Guards on
ComprehensionandLoopclauses.["Element", x, xs, cond](the formSumandProductalready accept) visits only the elements for whichcondisTrue. This works in the interpreter and in compiled JavaScript and Python (a guardedLoopis not compiled to Python). SVDof a complex matrix.SVD([[2, 1+i], [1-i, 3]]).N()returns a complexU, a real diagonalΣand a complexV. It stayed unevaluated before. A complex matrix with exact entries is decomposed only with.N().
Behavior Changes
-
Exactness is decided by the route a number arrives on. A literal with a fraction part is a float on every route:
1.0,0.0,-1.0,1.00and the MathJSON{num: "1.0"}were the exact interned constants (1.0xevaluated tox,\frac{1.0}{3}to the exact1/3,\sqrt{1.0}to the exact1,\sin(1.0)stayed\sin(1)) while2.0was a float. They now behave as2.0does:1.0xis1.0·x,\frac{1.0}{3}is0.333…,\sin(1.0)is0.841…. On the API,ce.number(bigDecimal)with an integer-valued big decimal is exact at any magnitude (ce.number(1)was exact andce.number(ce.bignum('10000000'))was a float; both are exact, abigintabove the safe integers). A float±1produced by a computation is kept as a coefficient (0.5·2xevaluates to1x, it wasx), a float0or±1is no identity in a power, a quotient or a sum either (x^{1.0}staysx^{1.0},1.0^xstays,2/1.0and2.0^2are floats,0.0 + xstaysx + 0.0,0.5x + 0.5xis1.0·x), while a float0still absorbs a product (0.0xis the float0).GCD,LCMandFactorial2of big integers are exact at the default precision (they were floats). An exponent literal with no fraction part (1e3) is still exact, as Mathematica's1*^3is. See "Exact and Inexact Numbers: the Spelling Decides" in the numerical evaluation guide. -
Derivatives serialize with the differentiand in the numerator.
D(x^2, x)now serializes as\frac{\mathrm{d}x^2}{\mathrm{d}x}instead of\frac{\mathrm{d}}{\mathrm{d}x}x^2. The old form did not round-trip when a term followed it:D(x, x) + 1re-parsed asD(x + 1, x). The old form is still accepted as input.
Resolved Issues
-
An exact coefficient raised to a rational power stays exact.
(8x+8)^{2/3}·yevaluated to3.99999999999999944548·y·(x+1)^(2/3)(an exact input gave a float with a wrong last digit) and\frac13(2x^2+2)^{-2/3}to0.2099…/(x²+1)^(2/3): a rational exponentp/qwithp ≠ 1went through the big-number route.p/qis now theq-th root followed by the integer powerp, exact when the root is (8^{2/3} = 4,(1/8)^{-2/3} = 4), and a root that is not exact is kept as a symbolic factor ((2x+2)^{2/3}·yis2^{2/3}·(x+1)^{2/3}·y). On the way,(-8)^{2/3}gave8^{2/3}·i; it is4, the real root, asRootgives. -
A compiled comparison chain with an absent operand keeps its decided links.
Less(x, 0, Missing)withx = 1isFalsein the interpreter (the first link decides), but compiled toundefined, so anIfover it took no arm; a chain now combines its pairwise links with Kleene "and". A two-operand relation with an absent operand and an operand with effects still evaluates that operand (Less(Missing, t + Random())consumes its draw), and a chain whose shared operand has effects is refused rather than evaluated twice. -
A complex quotient whose numerator products underflow is scaled.
2^{-1000} / (2^{-100} + 2^{-100}i)gave0on the interpreter's numeric values (the products2^{-1000}·2^{-100}round to0, so the numerator read as an exact zero); it is2^{-901} − 2^{-901}i, on both the interpreter and the compiled JavaScript. -
The interpreter fallback of a compiled function spells an absent value as compiled code does. When a compiled function falls back to the interpreter, every
MissingbecameNaN, while compiled JavaScript spells an absent point, list, tuple, colour, boolean or stringundefinedand only an absent numberNaN. The fallback now spells an absent result, and an absent cell inside a list or tuple, by the type of its position (user decision 2026-09-27); the interval-js fallback spells it{kind: 'empty'}as its compiled code does (it gave{lo: NaN, hi: NaN}). Also, a compiled comparison with a writtenMissingorUndefinedoperand answeredfalse(NotEqualtrue) andIf/Whichover it took an arm; the six relations now compile to the undecided value (undefined, PythonNone), a branch over them takes no arm and answersNaN, andAnd/Orkeep the Kleene rules. A restricted number is stillNaNwith its IEEE comparison. -
A lazy collection whose element type carries a
nanarm is accepted at alist<…>parameter. Withuandsdeclared(list<real>) -> unknownanduassigned an up-sampling comprehension,s(u(L))evaluated toincompatible-type(u(L)is a lazy comprehension typedindexed_collection<integer | nan>, thenanarm coming from an element read) while the compiled JavaScript returned values. The argument check now accepts a lazy collection at a list, collection or indexed-collection parameter when its element type without thenanarm fits (user decision 2026-09-27); thelist<real>contract is unchanged, an eager list that holdsNaNand a mismatched element type are still rejected. -
Mapwith a bare-symbol callback is typed from the callback's signature.Map(\sin, [-1, 2])was typedvector<integer^2>(the source type was copied because a bare symbol callback reads asunknown); it isvector<number^2>, fromSin's signature, and a declared or assigned function's result type is used the same way (user decision 2026-09-27). The source type is still copied when the callback is an undeclared symbol. Also,.N()of such aMapdid not numericize ([sin(-1), sin(2)]stayed symbolic while the lambda form gave floats); it does now. -
A list-valued integrand distributes over the integral.
\int_0^1\int_0^{G} xy\,dx\,dywithG = [1, 2, 3]stayed unevaluated (the outer integral had a list-valued integrand); it is[1/4, 1, 9/4], one integral per element, as a list BOUND already evaluates (user decision 2026-09-27), underevaluate()and.N(), on the parse, box and function routes. Two list bounds of equal length pair element by element; different lengths are theincompatible-dimensionserror underevaluate(). Also,\int_0^1 (x, 2x)\,dxparsed to a two-statementBlockintegrand and gave1/2underevaluate()and1under.N(); it parses to theTupleintegrand, as(x, 2x)does on its own, and stays whole in both modes. -
A float multiple of π that is a special angle is exact in every angular unit.
e^{0.25i\pi}was the exact√2/2 + √2/2·iin degree mode and a float in radian and turn mode, while\sin(0.5\pi)in radian mode was already the exact1. A float coefficient within one unit in the last place of a fractionp/qwithqin the recognizer's table (1, 2, 3, 4, 5, 6, 8, 10, 12) is now read as that fraction on thee^{iθ}route and inSin,CosandTan(user decision 2026-09-27): radiane^{0.25i\pi}is√2/2 + √2/2·i,\sin(0.3\pi)is1/4 + √5/4. A float that is not a special angle stays a float (e^{0.35i\pi}), and.N()is unchanged. Found on the way: in degree and gradian modee^{0.35i\pi}came out as the symboliccos(63) + i·sin(63)for a float input; it is now a float. -
XorandEquivalentaccept an absent operand.Xor(True, Missing)andEquivalent(True, Missing)wereincompatible-typeerrors, andXor(True, Undefined)stayed as¬Undefined, whileAnd,Or,Not,Implies,NandandNoranswerMissingfor an absent operand. The two now do the same (user decision 2026-09-27); a decided result is unchanged (Xor(True, False)isTrue,Xor(True, B)is¬B). -
Complex division in the interpreter no longer overflows or underflows.
1/(10^{308}+10^{308}i)evaluated numerically to0and1/(10^{-200}+10^{-200}i)to~oo;Inverse([[1+i, 10^{308}+10^{308}i], [0, 1]])gave-oofor the entry that is-10^{308}. The interpreter divided with the textbook formula, whose intermediate products overflow or underflow the double range. Every complex quotient the interpreter forms (the numeric-valuedivandinv, the complex matrix field behindInverse,DeterminantandLUDecomposition, the reciprocal trigonometric functions,Rationalwith complex operands, polynomial root finding) now uses the scaled division the compiled JavaScript route already had (Smith's algorithm with power-of-two scaling), so the two routes share one algorithm. Ordinary quotients are unchanged bit for bit; a zero, infinite or NaN operand keeps its previous answer.ComplexRoots(10^{200}+10^{200}i, 2)gaveNaNfrom an overflowing modulus and is now correct. -
An operator applied to an
error-typed operand has the typeerror. WithE = Sin(Tuple(A, B))forA, B: list<real>,Add(1, E)had the typeerror | integerandMultiply(2, E)had the typenumber. The arithmetic operators, the elementary functions,Abs,Hypot,Norm,Max,Min,SupremumandInfimumnow have the typeerror. -
Lazy collection operators over an unevaluated operator evaluate.
Sum(Join([3], Sort([2, 1])))stayed unevaluated, whileSum(Join([3], [2, 1]))was6. This also applies toAppend,Reverse,Drop,Take,Filter,Zipand the other lazy collection operators. -
A comprehension clause sees only the indices of the clauses before it. In
Comprehension(x, Element(x, Range(1, 3), x < y), Element(y, Range(1, 2)))theyin the guard is a free variable, as it is forSumandProduct. Compiled code returned[]for everyy. -
The Epsil serializer prints a set-builder as a comprehension.
["Set", body, ["Element", v, domain]]prints as{body for v in domain}. It printed as{body, v in domain}, which reads as a set of two elements. -
The real part of a complex number prints at the working precision.
.N()of√2 + √2iprinted a 25-digit real part (1.414213562373095048801689 + 1.4142135623730951i). -
A binder's free variables follow the clause order. In
Comprehension(x, Element(x, Range(1, 3), x < y), Element(y, Range(1, 2)))the guard'syis the enclosing variable — a clause sees the indices of the clauses before it, not after — and the node now reportsyfree, asSumandProductdo for the same shape. Before, every index was subtracted from the whole node, so the compiled routes folded such a node withyunbound and answered[]for everyy. The Python route declines a guard that names a later clause's index (a Python comprehension is one scope). The Epsil serializer prints the engine's set-builder,["Set", body, ["Element", v, domain]], as the comprehension{body for v in domain}instead of the literal{body, v in domain}, which read as a two-element set.
0.138.0 2026-09-27
New Features
Zetaevaluates at complexsand takes a second operand, andHurwitzZetais new (#340, contributed by enumeratio).Zeta(0.5 + 14i).N()evaluates.Zeta(s, a)is the same asHurwitzZeta(s, a)forRe(a) > 0.ζ(−n, a)at a rationalais exact,Zeta(2, 1/2)isπ²/2, andZeta(+oo, 2)andZeta(2, +oo)are0. The two-operand and complex values are computed at machine precision only, even whenprecisionis higher. Both functions compile to JavaScript, GLSL and WGSL for real operands.
Resolved Issues
- The hyperbolic functions evaluate at 0, as the circular functions do
(#341, contributed by enumeratio):
Sinh(0)is0,Cosh(0)is1,Arcosh(1)is0, andCoth(0)andCsch(0)areComplexInfinity.Coth(0).N(),Csch(0).N()andArcsch(0).N()are nowComplexInfinity(they werePositiveInfinity).simplify()gives the same values. - Number theory on large integers (#339, contributed by
enumeratio). With
pa large prime,FactorInteger(p^2)(and soTotient,DivisorSigmaand related functions) andDivisors(p^2)now evaluate, andMultiplicativeOrder(2, p)is faster.ModularInverseaccepts a negative modulus:ModularInverse(3, -7)is-2. EllipticE(m)is correct at complexm(#346, contributed by enumeratio).EllipticE(0.57 + 0.23i)was1.3175 − 0.1205iand is1.3248 − 0.1197i.EllipticE(φ, m)forφoutside[−π/2, π/2]ormvery close to1is also fixed, andEllipticE(10³⁰⁰)now evaluates to10¹⁵⁰i.- The base of a
Poweris bracketed when necessary in LaTeX (#345, contributed by enumeratio).["Power", ["Complex", 1, 1], 2]serialized as1+\imaginaryI^2and is now(1+\imaginaryI)^2. The same rule fixes other serializations:- A
SumorProductraised to a power:(\sum_{k=1}^{n}k)^2, not\sum_{k=1}^{n}k^2. Also(A\cup B)^2, notA\cup B^2. - With
fractionStyle: 'inline-solidus',["Divide", "a", ["Divide", "b", "c"]]isa/(b/c), nota/b/c. - The derivative of a fraction,
SumorProductputs the function in the numerator,\frac{\mathrm{d}(\frac{x}{y})}{\mathrm{d}x}, so it reads back correctly inside a larger expression. - A factorial raised to a power is written
(n!)^2instead ofn!^2.
- A
- Three incorrect Chebyshev identities are corrected (#343, contributed by
enumeratio). With the identities loaded by
loadIdentities,simplify()rewroteT_n(x)² + (x²−1)·U_{n−1}(x)²to1,U_n(cos x)·sin xtosin(n·x), andT_n(2x²−1) + U_{n−1}(2x²−1)toU_{2n}(x).
0.137.3 2026-09-26
Resolved Issues
- The result type of a function follows changes to the functions it calls.
With
f := x ↦ g(x)andgdefined in another scope, a declaration that changed the signature ofgdid not update the signature off.
0.137.2 2026-09-26
Resolved Issues
- Functions with an
unknownparameter no longer slow each other down. Withk: (unknown, T) -> unknownandw: (T, unknown) -> unknown, the type ofsin(cos(k(x,y) + w(x,y)))took 10 s to compute; it now takes a few milliseconds.
0.137.1 2026-09-26
Resolved Issues
- Declaring several functions that call each other no longer takes minutes.
With three nested functions declared
(T, …) -> unknown, an assignment that did not finish in 100 s now takes 20 ms. - A function declared
(T, …) -> unknownno longer keeps an outdated result type. Assigningx ↦ atof: (integer) -> unknownwitha: integerkept the signature-> integerafterawas retypedreal, and in complex mode a function with a real-declared parameter refused a complex argument. - A recursive function declared
(T, …) -> unknownhas a signature. The signature off := n ↦ n + f(n − 1)was never computed.
0.137.0 2026-09-26
Behavior Changes
Types
-
Operations on extended reals have more precise types. With
y: real | signed_infinity,a: real | signed_infinity | nan,L: list<real>,K: list<integer>andC: list<real | signed_infinity | nan>:- Two types were wrong:
y²wasreal<0..>ande^ywasreal<0<..>, although∞² = +∞. They are nowreal<0..> | signed_infinity. y³isreal | signed_infinity,tanh(y)isreal,sin(y),cos(y),tan(y)andsec(y)arenan | real,arsinh(y)isreal | signed_infinity,max(y, r)isreal | signed_infinity,Max(K)isinteger | nan,Sum(L)isrealandMean(K)isnan | rational. All werenumber.- A literal list keeps an infinite or NaN element in its element type:
[1, ∞]islist<integer | signed_infinity^2>(it wasvector<2>). - A value that may be complex stays
number, or iscomplex | nan:arccos(y)andarcsin(y)arecomplex | nan. A compiler that cannot produce complex values now refuses such an expression.
Code that compared these types with
numberorvector<n>now sees the more precise type. - Two types were wrong:
-
A power that may be
0^−kor0^0includes this in its type.x^kwithx: real,k: integerwasrealand is nowinfinity | nan | real;x^nwithn: integer<0..>isnan | real. A base or exponent that is provably non-zero keeps the old type (2^nisinteger). -
A product or sum that may be infinite is typed on the extended real line. With
y: real | signed_infinityandr: real,2ywasrealand is nowreal | signed_infinity;r·yisnan | real | signed_infinity, because0 · ∞is NaN.y + 1isreal | signed_infinityand∞ + yisnan | signed_infinity. Lists, matrices and tuples get the same element types:y·Lislist<nan | real | signed_infinity>. As a result,Round(4Q)withQ: real | signed_infinityisinteger | signed_infinity, notinteger. -
Clamp,ElementMaxandElementMinare typed by their bounds.Clamp(x, −1, 1)isreal<−1..1>(it wasreal), withnanwhenxmay be NaN, also whenx: number.ElementMax(x, 0)isreal<0..>. SoArccos(Clamp(x, −1, 1))isreal(it wascomplex). -
Abs,Real,ImaginaryandArghave more precise types.|q|withq: real | nanwasreal<0..> | signed_infinityand isnan | real<0..>, sotype.matches('real<0..>')on|x|is nowfalsewhenxmay be NaN.Re(z),Im(z)andArg(z)withz: complex | nanarenan | real(they werenumber). -
SumandProductare typed from their body.Sum(k², k, 1, 10)isintegerandProduct(1/k, k, 1, 4)isrational(they werenumber). With an infinite or unknown upper bound, the type also includesnanandsigned_infinity. The index over integer limits is typed by its range: inSum(body, Limits(i, 1, 40)),iisinteger<1..40>. -
An operation on a scalar-or-list union is typed from the element type. With
v: real | list<real>,2vandsin(v)arelist<real> | real(they werelist<number> | number). -
A function declared with an
unknownresult reports the type of its body.(real | signed_infinity | nan) -> unknownassignedt ↦ t + 1reports-> nan | real | signed_infinity(it was-> number). -
A call with a tuple argument for a scalar parameter is typed
any.h((1, 2))withh: (real) -> realandh := x ↦ 2xevaluates to(2, 4); its type wasreal. -
A symbol whose value is a pole is typed accordingly.
Tan(z)withz := π/2wasrealand evaluates to~oo; its type is nownumber. -
The sign of a function is read from a ranged type. With
t: real<0.9985..0.9999>,1 − tis positive. So|π − 4|simplifies to4 − π.
Functions and Pipes
- A function literal applied to a collection maps over it, as a named function
does.
Apply(x ↦ (x, x), [1, 2])is[(1, 1), (2, 2)](it was([1,2], [1,2])). A function literal whose parameter is a collection (x ↦ Length(x)) or generic still takes the whole argument, and a tuple or string argument is not mapped. Over an infinite collection the result is an infinite list:Apply(x ↦ (x, x), Range(1, ∞))is[(1, 1), (2, 2), …]. - Mapping a user function keeps a tuple argument whole and rejects lists of
different lengths. For
f := (x, y) ↦ (x, y),f([1, 2, 3], (10, 20))now gives three results, each with the whole point (it gave[(1, 10), (2, 20)]). Forf := (x, y) ↦ x + y,f([1, 2], [1, 2, 3])was[2, 4]and is now anincompatible-dimensionserror. To truncate, useZip. Built-in operators are unchanged. - A pipe stage behaves exactly as a call:
xs |> fisf(xs).[[1,2],[3,4]] |> x ↦ (x, x)maps at every depth and is[[(1,1),(2,2)],[(3,3),(4,4)]]. A stage whose parameter is a collection takes the whole value:[[1],[2,3]] |> l ↦ Length(l)is2(it was[1,2]); writexs |> ((a, b)) ↦ a ∧ bto map over a list of pairs. A parenthesized stage now maps as an unparenthesized one does.
Absent Values and Collections
- A number read from an absent collection is
NaN. Witht := -1,First((1,2){0<t})gaveNaNorMissingdepending on how it was evaluated; it is nowNaNeverywhere, typedinteger | nan. The same applies toSecond,Third,LastandAt. A row, point or string read from an absent collection is stillMissing. - A search finds
NaN,MissingandUndefinedwhere the same value is.IndexOf([1, NaN], NaN)is2,Contains([1, Missing], Missing)isTrueandCount([NaN, NaN], NaN)is2. Before,Contains(L, Missing)wasMissingandIndexOf(L, Missing)wasNaN.NaN = NaNis stillFalse. - A predicate that returns
Missingdoes not select the element.Filter([1, Missing, 3], x ↦ x > 0)is[1, 3]andCountof the same is2(Countthrew an error).AnyandAllcombineMissingasOrandAnddo:Any([1, Missing], x ↦ x > 2)isMissing, whileAny([1, Missing, 3], x ↦ x > 2)isTrue. Elementstays unevaluated when an unknown could make a value a member.Element(x, [1, 2])withxfree wasFalse.- An operator over an absent collection gives
Missing.Insert,ReplaceAt,Append,Union,IntersectionandSetMinusoverMissingwereincompatible-typeerrors,Join(Missing, [1])was[Missing, 1], andSubset(Missing, {2,3})wasFalse.Append([1], Missing)is now[1, Missing](it was an error). - Types of collection operations that may give an absent value are
corrected.
Reduce([Missing], Max)ismissing | number, and2ywithy: integer<1..3> | missingisnumber(it wasinteger<2..6>).Map(a ↦ 0, [[1,2],[3,4]])is typed as a list of two results, not a matrix. Rangemembership accepts a value within rounding of either end.Element(1000, Range(0, 999.9999999999, 0.1))wasFalseand isTrue.
Numerics
- At machine precision,
.N()of an integer too large for a double is±oo.\frac{10^{400}}{10^{-400}}gave1e+800and is+oo, as10^{800}already was.evaluate()is unchanged.
Resolved Issues
Arithmetic and Numerics
- A complex power, root or exponential keeps a small result.
(10^{-10}i)^2was0and is-10^{-20}. At machine precision,∛(8i)was0and is√3 + i, and(10^{300}+10^{300}i)^{0.3}wasNaN. .N()of an exact value beyond the double range is correct.\frac{10^{300}+1}{10^{400}}was0and is1e-100;\ln(10^{400})was+oo;10^{400}-10^{400}+1wasNaN. An exact integer beyond2^53no longer compares equal to its neighbor with.is().- Exact values:
\log_2(2^{100})is100, and((2·10^{30}+1)/10^{30})·2^xno longer simplifies to2^{x+1}. .N()of an infinite series with no limit stays unevaluated.Σ_{k≥1} (−1)^kgave−1andΠ 2^((−1)^k)gave0.5.MinandMaxof a range.Min(Range(5, 1))was5and is1;Min(Range(1, −∞))is−∞. The extremum of an empty range isNaN:Max(Range(1, 5, −1))was2.- A multiple integral evaluates a scalar bound once, so a
Random()bound gives a single value.Range(0, 1, +∞)is[0](it was[NaN]).
Linear Algebra
Norm,SingularValuesandEigenvaluesare correct for matrices with very small or very large entries.Eigenvectorsreturns a basis for a repeated eigenvalue:[[2,0],[0,2]]gave[[1,0],[1,0]]and now gives[[1,0],[0,1]].Norm(Linspace(a, 1, 3))with a symbolicastays symbolic (it was0).Mean,MedianandVarianceaccept exact values beyond the double range.
Absent Values and Collections
Sum([Missing]),Product([Missing])andSum(Join([3], Take(Missing, 1)))areNaN(the last was3).Sortby a key puts absent values last.- A
Joinwith an operand that depends on an undecidedIfstays unevaluated. - A rebuilt
Sum,Product,LooporComprehensionkeeps the type of its index.[10i for i in [1, 2, 3]]could be typedindexed_collection<number>instead ofindexed_collection<integer>.
Compilation
- JavaScript: a
Sumwith a square root no longer givesNaNwhen the square root is real by its type. - Complex division in compiled JavaScript no longer overflows or underflows. A recursive function that returns complex matrices compiles.
- Python:
Degreesparenthesizes its operand,Normhandles NaN and infinite entries, andDotandCrossrefuse an operand that can be absent. - GLSL and WGSL: color functions reject infinite channels, and
Gammahas a pole at every negative integer.interval-jsno longer treats a very small nonzero angle as the pole at0.
LaTeX Parsing and Serialization
- A
Tupleparsed in an explicit scope keeps the\operatorname{Tuple}spelling when serialized outside that scope.
0.136.2 2026-09-25
Resolved Issues
- A call with a
nan | realargument is typed from the function body. Withd := (l, b) ↦ l + b·Length(l),L: list<real>anda: nan | real,d(L, a)was typedcollection | number; it is nowlist<nan | real>. A union argument that may hold a collection still keeps the declared result. - The product of a
nan | realfactor and a list keepsnanin its element type.b·L, withba function parameter, was typedlist<real>; it is nowlist<nan | real>, as for a declared symbol. A factor that may also be infinite giveslist<infinity | nan | real>.
0.136.1 2026-09-25
Behavior Changes
Absent Values
- The arithmetic methods read an absent operand (
MissingorUndefined) asNaN, as theAdd,MultiplyandDivideoperators already did.ce.box('Missing').add(ce.box(1))isNaN(it was"Missing" + 1), andce.box('Missing').mul(0)isNaN(it was0). Beside a point the whole point is absent:ce.box('Missing').mul(ce.box(['Tuple', 1, 2]))isMissing. The.neg()and.inv()methods are unchanged. - Canonicalization no longer removes an absent operand from arithmetic.
Multiply(Missing, 1),Add(Missing, 0)andDivide(Missing, 1)wereMissing,Divide(Missing, Missing)was1andMultiply(0, 2, Missing)was0. They are nowNaN, and so isDivide(Missing, 2).Multiply(2, Missing)andAdd(Missing, x)keep their form and evaluate toNaN. - An out-of-range read of a numeric collection with an absent element is
NaN.Third((1, Missing))andAt([1, Missing], 5)areNaN(they wereMissing), asThird((1, 2))is. A collection with only absent or non-numeric elements still givesMissing. Trace(Missing)andTrace(Undefined)areNaN, asNorm(Missing)is. They wereincompatible-typeerrors.SortandOrderingput an absent element last.Sort([3, Missing, 1])is[1, 3, Missing]andOrdering([3, Missing, 1])is[3, 1, 2]; both stayed unevaluated. An absent element sorts afterNaN, also with a comparator or a key.
Types
- Arithmetic over a
Tuplewith a list coordinate is typederror. Its value is already anincompatible-typeerror, butAdd(Tuple(1, 1), Tuple(A, B))(withA,Blists) was typedtuple<list<real>, list<real>>. It, and the functions that apply to each coordinate of such a tuple, are now typederror. - An operand typed
erroris kept as it is inside a sum, product, power or negation. Before, it was wrapped in a secondincompatible-typeerror that hid the real one.(1 − t)D − tD, withDthe tuple above, now evaluates to one error that names the tuple.
Linear Algebra
SVDreturns the singular values in descending order. TheΣofSVD([[1e200, 0], [0, 2e200]])was[[1e200, 0], [0, 2e200]]; it is now[[2e200, 0], [0, 1e200]]. The sign (or phase) of each pair of singular vectors is fixed so that the largest entry of each column ofVis real and positive.SingularValuesandSVDaccept entries whose magnitudes span up to10^290(it was10^150).SingularValues([[1e200, 3e-80], [2e-80, 1e-85]])was unevaluated; it is now[1e200, 1e-85].
Arithmetic and Numerics
- The fourth root of a negative number evaluates to its exact principal value
when it has one.
∜(−1)and(−1)^{1/4}are(√2/2)(1 + i), and∜(−4)is1 + i; they stayed aRoot.∜(−2)still stays aRoot, and an odd root of a negative number keeps its real value (∛(−8) = −2).
Resolved Issues
Compilation
- A restricted list, point or list of points compiles on the
interval-jstarget, as onjavascript.[1,2]\{0<t\},(A\{0<t\}, B)andA\{0<t\} + 1failed with "no lowering forList". When the condition fails, the result isempty. - Compiled JavaScript gives the shifted points for
(A\{0<t\}, B) + (1, 1)and(A, B)\{0<t\} + (1, 1), as the interpreter does. They failed to compile. When the condition fails, compiled code givesNaNwhere the interpreter givesMissing. - A compiled out-of-range
First,SecondorThirdof a numeric collection isNaN, as in the interpreter.Third((1, x))wasundefined. SortandOrderingof a list with an absent element compile to JavaScript, with the absent element last:Sort([3, Missing, 1])gives[1, 3, undefined]. It gave[1, 3, NaN].
Linear Algebra
- The small singular values of a matrix with graded entries were 0.
SingularValues([[1e20, 0.5], [0.5, 2.5]])was[1e20, 0]; it is now[1e20, 2.5]. The same applies toSVDand to complex matrices.
Arithmetic and Numerics
- An even root of a negative number no longer drops the sign.
\sqrt[4]{-16}simplified to2andce.number(-16).root(4)gave the same; they now give√2 + √2·i.(-x).root(4)stays∜(−x). ce.number(-8).root(-3)is−1/2: a negative odd index now keeps the real root. At high precision, the square root of a negative real is imaginary (2ifor−4) instead ofNaN.
Absent Values
- A point minus an absent value is
Missing, as the sum is.(1, 2) - Missingwas anincompatible-typeerror. The same applies toUndefined, to2·Missing + (1, 2)and to.sub().3 - Missingis stillNaN. - Collection operators that reorder or select elements keep an absent element
in the type.
Sort([3, Missing, 1])was typedlist<unknown>; it is nowlist<integer | missing>. This applies toReverse,Take,Drop,Unique,Filterand similar operators.
0.136.0 2026-09-25
Improvements
- A call of a user function that takes a whole list is typed and compiled from
its argument. When the parameter is broader than the argument (a bare
functiondeclaration, or alist,collectionorunknownparameter), the call is typed from the body and compiles to JavaScript. WithL: list<real>, a chain such asd(s(u(L)), 1)was typedlist<unknown>and failed to compile; it is now typedlist<nan | real>and compiles. - A seeded list draw compiles on the
interval-jstarget.WithRandomSeed(seed, RandomChoice(domain, k)), with a literal seed and a literalInterval,Rangeor list domain, compiles to the same values asevaluate()and thejavascripttarget. Before, it failed to compile.
Behavior Changes
Restrictions and Absent Values
- A restriction over a number masks to
NaN.x\{c\}withcfalse isNaNwhenxis a number; it wasMissing.1\{c\}is typedinteger | nan, and[10,20,30]\{[1,2,3] > 2\}is[NaN, NaN, 30]. A point, list, string or non-numeric value still masks toMissing. A scalar restriction beside a list or point goes into each element:[1,2,3] + 2\{c\}is[3\{c\}, 4\{c\}, 5\{c\}]. - A restriction over a list stays one restriction while its condition is
undecided.
[1,2,3]\{c\}evaluates to itself instead of a list of restricted elements, so re-evaluating it oncecis false givesMissing, as a fresh evaluation and compiled code do. Its type ismissing | vector<integer^3>. - A collection operator over an absent collection gives the absent value of
its result.
Reverse(Missing),Sort(Missing),Map(f, Missing)andFilter(Missing, p)areMissing, andLength(Missing)isNaN. They wereincompatible-typeerrors.Reverse([1,2]\{c\})isReverse([1,2])\{c\}. - An
Undefinedelement of a list, tuple or set is typed as absent.[1, Undefined]is typedlist<integer | missing>(it wasvector<2>), and(2·[1, Undefined]).N()is[2, NaN](it was[2, 2·"Undefined"]). Arithmetic on a point with an absent coordinate givesNaNthere:(1, Missing) + (1, 1)is(2, NaN). Dot([1, Missing], [1,1])isNaN, asNorm((1, Missing))is, instead of an error.
LaTeX Parsing and Serialization
- A bracketed list in the parentheses of a function call keeps the parentheses
in the raw and structural forms. With
Aa function,A([1])parses to["A", ["Delimiter", ["List", 1]]]; it parsed the same asA[1]. The canonical form is unchanged: both are["A", ["List", 1]].
Arithmetic and Numerics
√ievaluates to(√2/2)(1 + i), and√(−i)to(√2/2)(1 − i). They stayed a symbolicSqrt.e^{a + bπi}with an exact real part evaluates to an exact product.e^{1 + iπ}is-eande^{ln 2 + iπ}is-2; they stayed unevaluated.e^{1 + 0.5iπ}is2.718281828459045iwith no rounding residue.- The sign of a trigonometric function of an exact rational multiple of π is
known.
Cos(π/3).isPositiveandCos(2π/3).isNegativearetrue; they wereundefined. CscandCotof a tiny nonzero angle give a signed infinity.Csc(5e-324)is+ooandCsc(-5e-324)is-oo; both were~oo. Compiled JavaScript and Python give the same result.- A
Rangewhose end point is on the step grid keeps its last element.Range(0, 0.3, 0.1)had 3 elements because0.3/0.1is2.9999999999999996; it now has 4. The element count allows a very small tolerance, soRange(0, 2.5, 1)still has 3 elements. A unit-step range one rounding error short of an integer span gains its last element:Range(1.3, 2.3)is[1.3, 2.3](it was[1.3]). The interpreter and every compile target use the same rule.
Collections
Mapwith its arguments in the wrong order is always an error.Map(P, fn)is anincompatible-typeerror also whenPis a symbol; it stayed unevaluated.
Compilation
Lastcompiles on the GLSL and WGSL targets for a point or a list of 2 to 4 numbers, asFirstdoes.
Resolved Issues
Compilation
- On the
interval-jstarget, a point within rounding error of a pole ofTan,Cot,SecorCscissingular, as the interpreter gives~oo. It gave a very large finite interval, which plotted as a wrong segment. Also, above2⁴⁰every point wassingular(tan(10²²)). - A bare
functionover a list compiled to wrong values.s(L)returned{ re: NaN, im: NaN }objects instead of numbers. - A compiled
Rangewith a compound step or start has the right elements.Range(0, 10, d - 498)atd = 500gave[-498, 2, 502, …]; it now gives[0, 2, 4, 6, 8, 10], as the interpreter does. - A compiled count, bound, position or index that is not provably real is read
through its real part. At
K = 4,Take(L, \sqrt{K})was[],Range(1, \sqrt{K})was[], andRandomChoice(L, \sqrt{K})threw. - A compiled
Rangewhose step points away from its end (Range(5, 1, 1)) threw aRangeError, and a zero step gave an infinite length. Both give[]. - A mutually recursive function pair that uses an operator with no lowering
reports that operator. Compiling
h(2)reported a misleading type error instead of "Could not compileSimplify". PointX(Missing)and other component reads of an absent value compile to NaN on GLSL and WGSL. The WGSL output was invalid.
Types
Multiplykeeps ananmember.2·xwithx: nan | realwas typednumber; it is nownan | real, asAddalready was.- An
unknownsymbol in a sum or quotient is accepted where a matrix is expected. WithPdeclaredunknown,Histogram(P + 0.5, 1/100)was anincompatible-typeerror; it is now valid and typesPasmatrix, asHistogram(2P, 1/100)did.Determinant(A + 1)works the same way. - An
Undefinedoperand of arithmetic is typed as absent.Multiply(Undefined, [1, 2, 3])was typedvector<integer^3>; it is nowvector<3>, the same as withMissing. Atwith two indices through a row that may be absent is typed.At([[1,2]\{0<t\}, [3,4]], 1, 2)is typedinteger | missing | nan(it wasunknown).- A union type no longer lists a member twice.
C + 1withC: list<real | signed_infinity | nan>was typedlist<infinity | infinity | nan | real>.
Calculus
- The normal CDF written as an integral evaluates over a list. With
E(x) = \frac{1}{\sqrt{2\pi}s}\int_{-\infty}^{x}\exp(-\frac{1}{2}\frac{Z^2}{s^2})\,\mathrm{d}Z,s = 0.577andG = [-1, 0, 1],E(G).evaluate()was a list of symbolic integrals; it is now[0.0415…, 0.5, 0.9585…]. An integral with a list bound gives one integral per element under.N(), andPolynomialDegree(x²/a, x)is2. .N()of a number times a list ofMeasurementvalues evaluates each element.0.5·[2 ± 0.1, 3 ± 0.1]is[1 ± 0.05, 1.5 ± 0.05].
LaTeX Parsing and Serialization
- A number before a comprehension or a range in brackets is a product.
0.01\left[u+1 \operatorname{for} u = [1,2,3]\right]and4[1...3]wereunexpected-operatorerrors; they now parse toMultiply, so the serialized form of such a product parses back. A bracket after a symbol is still an index, soxtimes a comprehension now serializes with\times.
Arithmetic and Numerics
- At machine precision, a rational is the correctly rounded double.
Divide(Pi, 6).N()withprecision: 'machine'was0.5235987755982998, and(1/6).N()printed0.166666666666667. Machine-precision values that involve a non-integer rational can change in the last digits. - At 21 digits,
\sin(\pi/6),\cos(\pi/3)and\sin(30)in degree mode are exactly0.5. They were0.500…001. - Trigonometric functions of a large exact integer, or a large multiple of π,
are correct under
.N().Sin(12345678901234567890123).N()is−0.4205828591…(it was0.9918…) and\sin(10^{30}\pi).N()is0(it was0.8838…). In degree mode,\sin(10^{30}\pi).N()is0.350160229920896711994(it was0).\cot(5\pi/2)and\tan(\pi)at machine precision are0. - An imaginary literal with an integer coefficient is exact.
4i,-2iand["Complex", 4]were inexact, so\sqrt{4i}gave1.414… + 1.414…i; it is now\sqrt2(1 + i), and(4i)^2is-16.1.5istays inexact. - A large double prints in exponent form at machine precision.
ce.box(1e300).toString()printed 301 digits, and1e23printed as99999999999999991611392. ComplexInfinityhas one MathJSON form at every precision. At machine precision,Add(1.5, 2.5, ComplexInfinity).N()gave["Complex", 1, "PositiveInfinity"].- A number literal follows the engine's current precision.
√2created at machine precision still gave a double from.N()afterce.precision = 50. Because of this,Max,MinandSortalso ordered√(2 + 10⁻³⁰)below√2at machine precision. - A perfect-power root of a large integer is extracted.
∛(10^60)is10^20and∛(10^61)is10^20·∛10; they stayed a root. - Simplifying
\sin(10^{30}\pi)no longer fails an internal assertion. - A constant that contains a special function can be ordered.
Max(Γ(1/3), 3)is3(it stayed unevaluated),Min(erf(1), 1/2)is1/2andMax(ζ(3), 1)isζ(3). Artanh(10⁻³⁰).N()is correct at 21 digits. It was0.Zeta(1 + 10⁻³⁰).evaluate()is no longer the pole~oo. The exact rational argument was read as the integer1.
Absent Values and Points
DistanceandDotof a point with an absent coordinate areNaN.Distance((1, Missing), (0, 0))was anexpected-valueerror andDot((1, Missing), (1, 1))stayed unevaluated. Compiled JavaScript also givesNaNinstead of throwing.PointY((1, Missing))is typedmissing.- A function applied to a list of points gives
Missingin an absent element.Sin([Missing, (3,4)])gaveNaNthere, as didNegateandSqrt. A numeric list keepsNaN(Sin([Missing, 1])is[NaN, sin 1]). - An absent point inside a matrix of points stays
Missingwhen a restricted point is added whose condition fails. Normof a matrix with an absent element isNaN.Norm([[1, 2], [3, Missing]])stayed unevaluated. A text element is anincompatible-typeerror.
Other
- A seeded draw from an open interval is cached.
WithRandomSeed(s, RandomChoice(Interval(0, Open(1)), n))was treated as impure, so each read of one element drew the whole list again.OpenandClosedare now defined operators, andce.box(['Open', 1]).isPureistrue. - A
Delimiternested thousands of levels deep boxes to its operand. Past about 1000 levels, the result was a non-canonicalDelimiterand an error was logged to the console.
0.135.0 2026-09-25
Behavior Changes
- A parenthesized LaTeX list with a list coordinate is a list of points.
With
A = [1, 2, 3]andB = [10, 20, 30],(A, B)canonicalizes toPointList(A, B)and evaluates to[(1, 10), (2, 20), (3, 30)]. Arithmetic,Distanceand functions apply point by point; a scalar coordinate is repeated ((A, 0)), and lists of unequal lengths stop at the shortest. This applies only to parentheses, and only when every coordinate is a number or a list of numbers.(A, B)serializes as\operatorname{PointList}(A, B). Compiledjavascriptandinterval-jscode gives the same points; GLSL and WGSL do not compile it.- A MathJSON
["Tuple", …]built by code (for example the result ofTallyorSVD) stays data. Arithmetic or a function such asSinapplied to such a tuple with a list coordinate is now anincompatible-typeerror:Sin(Tuple([1, 2], 3))was([sin(1), sin(2)], sin(3)). UsePointList(A, B)for a list of points.PointX,Norm,Dot,LengthandAbsof such a tuple are unchanged. - A user function whose parameter is a point (
tuple<real, real>, …) or has no declared type maps over a list of points: withx = [1, 2]andy = 3,k((x, y))is[k((1, 3)), k((2, 3))]. A parameter declared as a list still takes the list whole. PointListwith a source that may be absent (PointList(A\{0<t\}, B)) evaluates toMissingwhen the source is absent (it repeatedMissingin every point).
- A MathJSON
DotandNormaccept an absent operand.Dot(Missing, (1, 1))was anincompatible-typeerror; it is nowMissing(NaNwhen the result is a number).Norm(Missing)andNorm([1, Missing])giveNaN, asAbsdoes.- In strict mode,
Atrejects a colon index.[1,2,3][1:2]parses toAt(List(1,2,3), Colon(1,2)), which is now anincompatible-typeerror (it stayed unevaluated). Use the range notation:[1,2,3][1...2]is[1,2]. Mapover a range or an indexed collection is typedlist<T>, notindexed_collection<T>:Map(f, Range(1, 3))islist<…>.- A product of rational numbers is typed
rational, notreal:2xwithxdeclaredrational, andk/2for an integerk. Sin,Powerand a scalar factor of a restricted point whose condition is false answerMissing, notNaN(\sin((0,1)\{t<0\})att = 1). Code that tested the result withisNaNmust test forMissing.- Products and quotients with a restricted point are rejected when the
expression is created, as for a point without a condition. With
P = (0,1)\{0<t\},P·Q,t/Pand(2,3)/Pare now invalid (no-product-between-points,no-division-by-point).P/t,t·P,-P,P + QandDot(P, Q)do not change. - A point plus a list of numbers is an
incompatible-typeerror:(0,1) + [10,20,30]was valid and evaluated to a list of errors. A point plus a list of points still adds the point to each point. - The same rules apply to a list of points
L, with or without a condition.L·(1,1),L·L,2/L,L/(1,1),L + 1andL + [1,2]are now invalid when created (before, most evaluated to a list of errors, andL/Lwas[1, 1]).L·2,L/2,-L,L + (1,1),L + L,L·[1,2]and[1,2,3]·(0,1)do not change. - On
glslandwgsl, a number added to a point (t + \operatorname{PointList}(t, 1)) no longer compiles, as the interpreter givesincompatible-type.
Improvements
- New operator-definition flag
threadsConditionals. When an operator sets it, a conditional value whose condition is not decided (P\{c\}, that is["When", P, c], or aWhich) moves out of the application:f(When(v, c))evaluates toWhen(f(v), c). The value istrue(every operand) or a list of 0-based operand positions.Dot,Cross,Norm,Distance,At,First,Last,PointXand similar accessors set it. A custom operator that reads a point or a vector whole can set it too. simplify()applies more of the loaded Fungrim identities. Identities loaded withloadIdentities()now run before the built-in rules, are also tried on the expression before its operands are simplified, and are used when their result is strictly cheaper.LambertW(-π/2)simplifies toiπ/2,Hypergeometric0F1(3/2, -z²/4)toSinc(z), andRisingFactorial(1, n)ton!. When a later rule makes a result more expensive,simplify()now keeps the cheapest value reached:3x/(x+1)² + 5/(x+1)²simplifies to(3x+5)/(x+1)².
Epsil
- The Epsil MCP server accepts LaTeX. The
evaluateandparsetools acceptformat: "latex"(\int_0^1 x^2\,dx), andserializecan write LaTeX. Everyevaluateresult includes alatexform of the value. - The
epsilcommand accepts LaTeX.--from latexreads a LaTeX expression and--latexwrites the result as LaTeX:epsil --from latex --latex -e '\frac{1}{2}+\frac{1}{3}'prints\frac{5}{6}. - A Compute Engine guide for AI agents, a condensed reference for writing
JavaScript or TypeScript with the library. The Epsil MCP server serves it as
the resource
epsil://docs/compute-engine-api. - The Epsil MCP server has a
compiletool. It shows the code generated for an Epsil program or a LaTeX expression:toisjavascript(default),glsl,wgsl,pythonorinterval-js;declarationsgives the types of free symbols. The result has thecodeandfreeSymbolTypes, or anerrorand adiagnostic.
Resolved Issues
LaTeX Parsing and Serialization
- A juxtaposition that was not canonicalized (
InvisibleOperator, from a raw parse) now serializes with the parentheses it needs, so it parses back to the same expression:(x+1)(x+2), notx+1x+2;(2x)^2, not2x^2. - A number before a bracketed list is a product:
4[1,2]evaluates to[4, 8]; it was anunexpected-operatorerror.a[1,2]is still an index. - A space before an index bracket is ignored:
a [1,2]parses likea[1,2]. A visual space (\,,\quad) before a list makes a product:a\,[1,2]evaluates to[a, 2a]. - A bracketed list after a function name is its argument:
\sin[a,b]and\Gamma[a]parse asSin(List(a, b))andGamma(List(a)), and broadcast. They were anincompatible-typeerror. - A
Tuplewith a list-of-numbers coordinate round-trips. It serializes as\operatorname{Tuple}(…), since(A,B)parses asPointList(A, B). - Empty sets and lists serialize with no space:
\lbrace\rbrace, not\lbrace \rbrace.
Points and Restricted Values
Erf,Erfc,ErfiandErfInvapply to each element of a list:Erf([0.5, 0.25])is[0.5205…, 0.2763…]. It was anincompatible-typeerror.Distance,Norm,DotandCrossaccept a restricted point or a restricted list of points. Withtfree,Distance((3,4)\{0<t\}, (0,0))is5\{0<t\}(it was an error), andDot([(1,2),(3,4)]\{0<t\}, (1,1))is[3\{0<t\}, 7\{0<t\}]. A list that holds an absent point gives one value per point:Norm([(3,4), Missing])is[5, NaN].- The point accessors read restricted points. With
tfree,PointX([(1,2),(3,4)]\{0<t\})is[1\{0<t\}, 3\{0<t\}](it was the first point), andFirst((1,2)\{0<t\})is1\{0<t\}(it was an error).PointX([Missing, (1,2)])is[Missing, 1](it wasMissing). - The types of accessors and functions of restricted values include the absent
case.
First((1,2)\{0<t\})was typedinteger;PointYof a list with a restricted point is typedlist<missing | number>;\sin((0,1)\{0<t\})is typedmissing | tuple<number, number>(it wasnumber).PointX(Missing)is typednumber, as its value isNaN. - Accessors of a symbol declared as a restricted point with no value stay
unevaluated; they were an
incompatible-typeerror. - A numeric function of a list restricted by a list of conditions works cell
by cell:
\sin([10,20,30]\{[1,2,3]>2\})is[NaN, NaN, sin(30)]; it was a 3×3 matrix. - An absent scalar times a list gives
NaNin every cell:Missing · [1, 2, 3]was[Missing, NaN, NaN]. - An absent point cell of a list answers
Missing, as its type says:Mapand2·[P{c}, (2, 3)]gaveNaN, or a mix ofNaNandMissing. Dotof a restricted list of points and a point of another width is oneincompatible-dimensionserror, not a list of errors.
Compilation
- Compiled products of a list and a point match the interpreter.
[1,2,3]\cdot\operatorname{PointList}(t,1)ran toNaNin JavaScript; it now gives the list of points. On GLSL and WGSL, a point times or divided by a list now fails to compile instead of giving one vector. - Compiled arithmetic over a restricted point is correct.
t\operatorname{PointList}(0,1)\{0<1\}ran tonullin JavaScript. The product of two points, a division by a point, a point plus a number and an ordering of points now fail to compile on every target, where they gave a wrong value. - Compiled
NormandDistanceof restricted points match the interpreter.Norm([(3,4),(6,8)]\{0<t\})gave11.18(the Frobenius norm); it is now[5, 10].Distance,PointX,First,SecondandThirdof an absent point no longer throw aTypeError. Python refuses to compileNorm,DotandCrossof an operand that can be absent. freeSymbolsandfreeSymbolTypesof a compiled block with a local declaration are correct: the symbols read bylet y = x + 1were missing.- Compilation error messages are clearer. They start with what could not be
compiled, then give the reason:
Could not compile `SinIntegral`: the operator is known to the engine but target 'glsl' has no lowering for it.Code that matched the old text must match the new one;diagnostic.codeis unchanged. - A declined JavaScript compilation names the reason of a user-function call that could not be compiled (for example "argument 2 is a point with a complex-valued coordinate").
Other Fixes
- An element-wise result over more than 100 elements has the type of its
expression. Assigning
PointList(0, k) + PointList(cos k, sin k)withk = [1...101]to a symbol declared with that type threw aTypeCompatibilityError. - The type of a numeric function of a value that can be absent keeps
missing:PointX(V)^2is typedlist<number> | missing | number. PolynomialGCDandsimplifyno longer compute over long floats. A coefficient with more than 15 significant digits gives the GCD1; a short decimal such as1.5is read as exact (PolynomialGCD(x² − 1.5x + 0.5, x − 1)isx − 1). With the integration rules,∫ 1/(2+3x⁴)² dxtakes 0.1 s instead of reaching the 30 s limit.- Assigning an invalid function to a symbol declared with a signature no longer says the initializer "is not a function".
- The documentation of
isEqual()said an identity such as(x+1)^2vsx^2+2x+1istrue; it isundefined. UseisIdenticallyEqual().
Performance
- Repeated
Apply(Derivative(f, n), x).N()at machine precision computes the derivative once: 1,000 points take 0.16 s (they took 1.94 s). - Interpreted evaluation of scalar expressions is about 14% faster.
Max,Min,ClampandSortof constants that are very close are faster:Sortof 200 such values is about 3× faster.
0.134.0 2026-09-24
Behavior Changes
Ordering
- Ordering functions order exactly.
Max,Min,Clamp,Sort,ArgMaxand the other ordering functions no longer treat two numbers closer than the tolerance as a tie:Max(1e-12, 2e-12)is2e-12. Relational operators keep the tolerance, so1e-12 < 2e-12is stillFalse.Real(1 + √2 i)now evaluates to1. Sortwithout a key stays unevaluated when two elements cannot be ordered.Sort([2, y, 1, 3, x])was[2, y, 1, 3, x].OrderingfollowsSort.SortandOrderingput NaN last.Sort([3, NaN, 1])is[1, 3, NaN], in the interpreter and in compiled JavaScript.
Colors
Rgbcolours are extended sRGB. A channel outside [0, 1] is kept:AsRgb(Rgb(2, 0, 0))isRgb(2, 0, 0)on every target, and conversions amongRgb,OklabandOklchno longer clip.HsvandHslclamp saturation, value and lightness, also when they are infinite:AsRgb(Hsv(30, ∞, 3.14))isRgb(1, 0.5, 0)(it was an error). The hue is reduced modulo 360.- Colour output is gamut-mapped, not clipped.
ColorToStringand the conversions toHsv/Hslmap into the gamut with the CSS Color 4 algorithm:ColorToString(Oklch(0.7, 0.4, 30))is#ff5843(it was#ff0000). New operatorGamutMap(color, gamut?), with gamut"srgb"(default) or"display-p3".ColorToString(c, "display-p3")givescolor(display-p3 r g b). On a shader, wrap a final colour in_gpu_gamut_map_oklch()instead of_gpu_oklch_to_srgb().
Arithmetic and Numerics
- Only a safe integer is boxed as an exact integer.
ce.number(2.0)is the exact2, butce.number(1e200)is the float1e+200. For an exact large integer, use a bigint or a string ({num: "1e200"}). A literal with a decimal point (12345678.0) is a float.IsPrime(7.0)is stillTrue. - A float argument of a trigonometric function is never a special value.
Sin(3.141592653589793)is2.38e-16(it was0) andArcsin(0.5)is a float (it wasπ/6); writeSin(π)orArcsin(1/2)for an exact value. This fixes wrong exact answers such asSin(π − 10^-30)= 0.e^{iπ}in degree mode is now−1, andCot(10^-25).N()is1e25(it was~oo). .N()of a Gaussian integer is a float:(1 + i).N()is no longer exact..N()of a sum of quantities gives a float magnitude:(1/3 m + 1/3 m).N()is0.6666… m.- Tangent-like poles are detected relative to the argument. A value is a
pole of
Tan,Cot,SecorCscwhen the argument is within about 100 ulps of a pole:Cot(1e-13)is1e13andTan(1.5707954)is1078987.38…(both were~oo);cot(1000π)is~oo. Compiled code givesInfinity(JavaScript) ornp.inf(Python) at a pole.
Linear Algebra
Norm(A, 2)of a matrix is the spectral norm (the largest singular value); it was the Frobenius norm.Norm(A)andNorm(A, "Frobenius")are still the Frobenius norm. The result is exact for small or simple matrices; otherwise it staysNorm(A, 2)underevaluate()and gives a number under.N().- Compiled JavaScript linear algebra selects real or complex computation from
the operand types. A
matrix<number>orvector<number>operand is read as real, except undermode: 'complex'. The compiled function checks at run time that such an input has no complex entry and throws an error that names it;entryChecks: falseturns the check off. Before,Determinant(M) + 1gaveNaNfor a complex entry. A function declared with a real result whose body is complex now fails to compile on JavaScript, as on GLSL and WGSL.
Integration and Time Limits
- Symbolic integration gives up at the same point on every machine. The
integration rules and the compiler stop after a number of steps (300,000 per
integral; new option
stepBudgetofloadIntegrationRules), not after a time.timeLimitMsnow defaults to 30 s (it was 10 s) and guards only against a hang. - A caller's deadline propagates out of
compile(), integration and derivatives. When ace.withTimeLimitspan expires, theCancellationErroris thrown:compile()of anIntegratethrows instead of returningsuccess: false, andNIntegratethrows instead of returning a partial result.
Improvements
- On
glslandwgsl, user functions accept complex arguments. A function whose parameter type does not saycomplexgets a second, complex definition when a call passes a complex value;\arg(f(x+iy))now compiles. A real argument to a complex parameter is converted tovec2(x, 0.0). - Every compile result has
freeSymbolTypes: for each free symbol, its type, the type the target reads it as (float,vec2,complex, …) and whether it was declared or inferred. - New compile option
strictTypes: true: a compilation with an undeclared free symbol fails and names the symbol. - On
interval-js,PointX/PointYof aPointListof list symbols compile, and so does the sum of two suchPointLists.
Resolved Issues
Arithmetic and Numerics
- Exact complex numbers stay exact.
√2·(1 + i)is√2 + √2 i(it was1.414… + 1.414…i),(√2(1 + i))²is4i,|√2(1000 + 1000i)|is2000,Abs(π i)isπandConjugate(1 + √2 i)is1 − √2 i.Erfcof a complex argument evaluates. - Values very close to an exact number are no longer rounded to it:
√(1 + 10^-30) − 1evaluated to an exact0and2^{10^-30}to an exact1; they now stay symbolic. - Exact numbers outside the float64 range keep their value.
Norm([10^400, 1])was+oo,Max(10^{-400}, 2·10^{-400})was10^-400, and√(10^30 + 1)was10^15. - Ordering is sound. An order is decided only when the rounding error allows
it, so a value near 0 no longer gets a sign from rounding noise.
Max(3.141592653589793, π)isπ,Max(∞, π)is+∞, and complex values are never ordered. When the precision cannot decide, the engine tries a proof of equality (Max(sin²1 + cos²1, 1)is1), then a higher precision. Sorting symbolic constants is about 8× faster. Absof a real constant evaluates:|1 − π|isπ − 1.Conjugatedistributes over sums, products and powers.Distance((π, 0), (0, 0))was an error.ArsinhandArcoshof a big decimal are accurate near 0.- A degree literal such as
\sin(30^\circ)evaluates correctly in grad and turn mode: it gave0.0082in grad mode; it is1/2. isIdenticallyEqualno longer answersundefinedfor some true identities such as(x+y)² ≡ x²+2xy+y².- The hash of a dictionary does not depend on the order of its keys, so like terms that hold equal dictionaries combine.
- Writing
valueDefinition.typeoroperator.signatureupdates the types already computed for expressions that use them. - A use that narrows a symbol's type updates expressions boxed before:
List(x)becomesvector<real^1>after a use makesxreal. Timingreturns the time first, in microseconds, then the value. Its description and signature now say so.
Statistics
Modebreaks ties by the smallest value also under.N():Mode([3.5, 2.5, 1.5])is1.5, not3.5.- The statistics operators accept real constants.
Median([π, 3, e])is3andMean([π, 3])is(π + 3)/2.
Linear Algebra
- Matrix functions keep the imaginary part of complex entries.
SingularValues([[1, i], [0, 1]])was[1, 1],Eigenvalues([[0, i], [i, 0]])was[0, 0](it is now±i), andCholeskyDecompositiongave a realL. Decompositions of a complex matrix that are not supported now stay unevaluated. - Exact complex matrix entries stay exact:
Norm([[1, 1+2i], [0, 1]], 1)is1 + √5(it was3.236…), andInverseof a Gaussian-integer matrix is exact. SVDandSingularValuesof matrices with very large or very small entries no longer give NaN or 0. When entry magnitudes span more than about 10^150, they stay unevaluated;Norm(A, 2)still answers.Normof a lazy collection is computed:Norm(Sin(L))stayed unevaluated for a list of a thousand numbers.determinant()of thenumericsentry point threw aTypeErrorfor a matrix of size 3 or more.
Compilation
- Compiled JavaScript:
Sort/Orderingrefuse element types they cannot compare;Uniqueacceptslist<real | signed_infinity | nan>;Normof a list with a complex entry is correct;Dot,Determinant,Inverseand the other linear-algebra functions fail to compile on a complex entry instead of givingNaN;Float64Arraymatrix rows are accepted; a nested function such asTrace(MatrixPower(A, 2))compiles overmatrix<real>. - Compiled user functions read the right global when a
Sumindex or a local has the same name:\sum_{k=1}^{3} h(x)withh(s) := s + kreturned the string"[object Object]…"; it is now6 + 3i. - Compiled
Cot,Coth,Round,Fract,Haversineand odd roots compute their operand once, soCot(Random())uses one random draw. - GLSL and WGSL: real-only functions such as
Gamma,ErfandBesselJfail to compile on a complex operand instead of giving invalid code;Rootof a complex radicand is the principal root and its compound degree is parenthesized; anIfwith one complex and one real value is valid code; a complex value in a point component fails to compile; a complex result declared over a real body is converted;0^wis 0; a hue outside [0, 360) is correct.MandelbrotandJuliatakecomplexpoints, passed asvec2. - Python:
GammaLnof a negative real usesscipy.special.gammaln(it gavenan);Normof an operand of unknown rank checks the rank at run time;Artanh,Arsinh,Sign,Mean,VarianceandTracecompute complex values;2(x + z)compiled to2 * x + z. interval-js: a free symbol declaredcomplexfails to compile (it was read as real); degree literals compile.- Colour conversions no longer round each channel to 8 bits:
AsRgb(Hsv(30, 1, 1))compiled had a green channel of0.498.
Integration
- Numeric integration of an integrand that is NaN everywhere returns
NaNquickly; it returned0after about 32,000 evaluations. A compiled nested integral that used its whole budget returnsNaN.
Performance
evaluate()of aSumof symbolic terms is no longer quadratic:Sum(sin(i), i, 1, 1000).evaluate()takes about 80 ms (it took about 770 ms).- Like terms in a sum are found faster:
Expand((x+y+z+1)^32)is about 2.7× faster. .N()ofAddandMultiplyapproximates each operand once:(1 + Σ_{i=1}^{1000} sin i).N()takes about 12 ms (it took about 0.9 s).- More element-wise operations over large lists of machine numbers run on
doubles, such as
L / 3andExp(L)underevaluate(). - Monte Carlo integration is faster: 10⁷ samples take about 0.4 s (about 3.6 s before).
- Repeated derivatives of the same function are cached.
0.133.0 2026-09-22
Breaking Changes
StringFrom(value)without a format reads integers as Unicode code points. A finite non-negative integer, or a list of such integers, is now decoded:StringFrom(65)is"A"andStringFrom([127467, 127479])is"🇫🇷"; they were"65"and"[127467,127479]". Other values (NaN, non-integers, negative numbers, tuples such as(65, 66), strings, symbols) are still printed. To get the decimal text of a number, useStringFrom(128287, "default"). The empty list is now""instead of"[]".EqualandNotEqualof two collections are undecided when a pair of elements is undecided.Equal([1, Missing], [1, Missing])wasTrueandEqual([1, Missing], [1, 2])stayed unevaluated; both now answerMissing(NaNin compiled code), andNotEqualanswers the same. One undecided pair is enough:Equal([1, Missing, 3], [2, Missing, 3])isMissing, notFalse. A length mismatch is stillFalse, and[x] = [y]with free variables still stays unevaluated..isEqual()andIdenticallyEqualstill answertruefor two such lists.
Behavior Changes
Undefinedis an absent value, the same asMissing. In a numeric argument it answersNaN:Cos(Undefined),2 · UndefinedandSqrt(Undefined)areNaN, where they stayed unevaluated. The comparison and logical operators and the statistics operators read it asMissing:Equal(Undefined, 1)andAnd(Undefined, True)answerMissinginstead ofFalseandUndefined;Mean([1, Undefined, 3])isNaN;IsMissing(Undefined)isTrue;Coalesce(Undefined, 2)is2;First(Undefined)isMissinginstead of anincompatible-typeerror. A bareUndefinedstill evaluates to itself, with typeunknown.- A function declared with scalar parameters broadcasts over collection
elements in callbacks. With
ce.declare('f', '(number) -> number')andf := x ↦ 2x,Map(f, [[1, 2], [3, 4]])now answers[[2, 4], [6, 8]], asMap(x ↦ 2x, …)already did; it was anincompatible-typeerror. This applies toMap,FlatMap,Reduce,Fold,Scan,MaxBy,MinBy,ArgMax,ArgMin,Sort,Ordering,GroupByandChunkBy, and to compiled JavaScript. Predicates that must answer a single boolean (Filter,Any,All,CountIf,TakeWhile,DropWhile) keep the error, and tuple and string parameters do not broadcast. - A symbol value keeps the meaning it had where it was assigned, in compiled
code too. With
a := 3t + 1andg := t ↦ t + a, thetinais the globalt, not the parameter ofg. The interpreter already gaveg(2) = 3t + 3; compiled code now agrees (it substitutedaintogand answered9). The same applies insideSumand other big operators. Theglslandwgsltargets, and a compiled lambda that shadows the name, now fall back to the interpreter for such values. - A loop index no longer changes a global read inside a called function.
With
wdeclared real without a value andW(x) := [w·x, x],[W(w)[1] for w in [1, 2, 3]]is now[w, 2w, 3w]andΣ_{w=1}^{3} W(w)[1]is6w, the same as with another index name. They were[1, 4, 9]and14. - An element-wise operation on a single empty list answers the empty list.
Sin([]),-[],Not([]),Abs([])and[] < 3answeredNothing; they now answer[], as2·[]already did.Sin([]) + [1, 2]is now anincompatible-dimensionserror instead of[1, 2].PointListandZipstill pair up to the shorter input. RandomChoiceof a string answers a string.RandomChoice("abc", 5)is now a five-character string instead of a list of characters, andRandomChoice("abc", 0)is"". To get the old result, useCharacters(RandomChoice("abc", 5))or draw fromCharacters(s).Random(s)still answers onecharacter. Compiled JavaScript now supports a string source.
Resolved Issues
Types
- A function declared with
unknownslots follows its current body. Withce.declare('phi', '(unknown) -> unknown'),phi := x ↦ 1 + c(x), andcdefined later asc := i ↦ 2i,phinow reports(unknown) -> number; it kept-> broadcastable<number>, and compiling2\cos(\phi(x))failed. A re-assignment also updates the reported signature. - A restricted value can be assigned wherever its unrestricted value can.
With
v > 3assumed,ce.assign('v', ce.parse('5\{a>0\}'))threw an error about the assumptions; it now binds, as5does.1\{a>0\}is still refused, andx := 7\{a>0\}is now refused underx = 5asx := 7is. A bareMissingcan now be assigned to a symbol with an assumption.
Arithmetic and Numerics
- A deep expression no longer overflows the stack.
ce.parse('1-2-3-…-N')threwRangeError: Maximum call stack size exceededpast about 500 terms. A 5,000-term chain and a 2,000-deepSinnest now parse, canonicalize and evaluate. Roundrounds a half away from zero at every precision. At machine precision,Round(-0.5)was0andRound(-1.5)was-1; they are now-1and-2, as at the default precision and in compiled code.Exp(x).N()andExp(x).evaluate()agree to the last digit at machine precision.Exp(1.1)gave3.0041660239464334or3.004166023946433depending on the method, and.N()could vary with the Node version.Exp(π).N()is now23.140692632779267(it was23.140692632779263).Linspacebetween endpoints whose difference overflows a double.Linspace(-1e308, 1e308, 3)wasNaN, +oo, +oo; it is now-1e308, 0, 1e308.- A huge float product with a vector stays a float.
1e308 · [0.9, …]answered9e307as an exact 308-digit integer; it is now a float. FindFitandFindRootreport a time limit reached during setup as the"setup"phase again, instead of"solve".
Collections
Dotbroadcasts over a list of points. WithL: list<number>,Dot(PointList(1, L), PointList(3, 4))was anincompatible-typeerror; it now answers alist<number>, one inner product per point, asDot((1, L), (3, 4))already did. Two point lists are paired element by element; different lengths or widths giveincompatible-dimensions. This also works in compiled JavaScript. The Python target now rejects a point-list operand instead of returning a wrong result.Orderingstays unevaluated when the order cannot be decided.Ordering([[5], [1], [3]], x \mapsto 2x)answered[]; it now stays unevaluated, asSortdoes.MaxandMinfixes.Max([[1, Missing], 3])is nowNaN(it wasMax(3, Missing)), andMax(Linspace(1, 5, 0), 1)is now1(it stayed unevaluated).Max(Reverse(Map(f, xs)))no longer callsftwice per element.simplify()keepsMax,Min,SupremumandInfimumof one collection.Min(Map(Sin, RealNumbers)).simplify()returned theMapitself; with the Fungrim identities loaded it is now-1.Max().simplify()isNaN.
Compilation
MissingandUndefinedcompile to a value, not a free variable. CompiledIsMissing(Missing)answeredfalse,Coalesce(Missing, 2)answeredundefinedandMedian([1, Missing, 3])answered3; they now match the interpreter (True,2,NaN). In JavaScript they compile toundefined, in GLSL, WGSL, interval and Python code toNaN.IsMissingon a variable not given to the compiled function now answerstrue.
LaTeX Parsing and Serialization
- A call whose argument uses the same function is read as a call.
f(yf(x))(x+y)was the productMultiply(f, y, x + y, f(x)); it is nowfapplied toy·f(x), timesx+y.q(2q)with an undeclaredqis still a product. - A sequence with a second ellipsis after its last term.
a_1 a_2 \dots a_k \dotsparsed to aRangewith anincompatible-typeerror; it now parses to the sequencea_1·a_2, …, a_k, ….
Epsil
- A sum type can conform to a protocol under its own name. With
type shape = circle(r: number) | square(s: number),type shape is Area { … }was rejected withprotocol-conformance-target-invalid; it now applies the implementation to each variant, including variants added in a later program. Generic sums andce.declareProtocolImplementation()are unchanged.
Performance
- Arithmetic and common functions over large lists of machine numbers are
computed on doubles. At machine precision,
2·L,L + M,L·M,−L,Sin(L),Sqrt(L),Ln(L),L^2and similar expressions over lists of more than a hundred elements, andSum,Max,MinandPointX/PointY/PointZof such lists, are now much faster: over ten thousand elements,Sum(Sin(L))went from 58 to 2 ms andMin(1, 2 − 2·L)from 81.5 to 4.0 ms. The result is aListof numbers where it was a lazyMap(f, L), soNorm(2·L)is now a number. Some values can change in the last digit. - Reading a symbol that holds a large list of numbers or points is faster. A
written-out list of numbers now evaluates to itself, and coordinates are read
directly: over ten thousand points,
Length(C)went from 1.2 to 0.03 ms andPointZ(C)is about 14 times faster. - Factoring a deeply nested quotient is much faster. The third derivative of
√(x+√(x+√(x+√(x+x))))takes half the time it took 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.132.3 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|
\pi^2 | 12 | 11 | 173 | 66 | 3.9 |
\sin 1 | 22 | 22 | 217 | 190 | 5.2 |
\cos 1 | 20 | 22 | 219 | 253 | 7.0 |
\ln 2 | 16 | 18 | 340 | 4,784 | 3.8 |
e^{\pi} | 17 | 17 | 215 | 5,055 | 4.7 |
\zeta(3) | 1,397 | 1,426 | 275 | — | 51 |
\Gamma(\tfrac13) | 736 | 729 | 355 | — | 203 |
\psi(\tfrac13) | 649 | 647 | 2,812 | — | 167 |
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 integrator and Fungrim identities
(loadIntegrationRules / loadIdentities).
| Operation | CE (current) | CE + R/F | CE 0.132.3 | SymPy | math.js | Mathematica |
|---|---|---|---|---|---|---|
| Antiderivatives | ||||||
\int\frac{1}{\sqrt x}\,dx | 3.4× | 2.1× | 2.5× | 0.6× | — | 1× |
\int\frac{x}{\sqrt{1-x^2}}\,dx | 5.7× | 1.1× | 4.7× | 0.09× | — | 1× |
\int\frac{1}{x^3+1}\,dx | 3.8× | 0.6× | 3.4× | 0.3× | — | 1× |
\int\frac{\sqrt x}{1+x}\,dx | — | 1.1× | — | 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.005× | — | 1× |
| Derivatives | ||||||
\tfrac{d}{dx}\sqrt{1-x^2} | 0.02× | 0.02× | 0.02× | 0.001× | 0.004× | 1× |
| Simplification | ||||||
\sqrt{3+2\sqrt2} | 41× | 25× | 24× | — | — | 1× |
\sqrt6\,x+\sqrt2\,x | 290× | 279× | 234× | 12× | 90× | 1× |
| Evaluation | ||||||
\lim_{x\to0}\tfrac{\sin x}{x} | 27× | 14× | 24× | 2.9× | — | 1× |
\lim_{x\to\infty}(1+\tfrac1x)^x | 4.3× | 4.1× | 4.1× | 2.1× | — | 1× |
\int_1^2\tfrac1x\,dx | 2313× | 3076× | 1921× | 90× | — | 1× |
\int_{-\infty}^{\infty} e^{-x^2}\,dx | 182× | 73× | 157× | 2.2× | — | 1× |
| Solving | ||||||
x^4+x^2-1=0 | 0.2× | 0.2× | 0.2× | 0.06× | — | 1× |
x^3-x-1=0 | 1.4× | 1.4× | 1.2× | 0.04× | — | 1× |
Across the cases both solve, Compute Engine is a median 3.8× faster than Mathematica (up to 2313×).
Measured 2026-09-22 · SymPy1.14.0 · math.js 15.2.0 · Mathematica
15.0.0 · Node v26.9.0. Correctness is verified numerically against an
independent mpmath reference.0.132.3 2026-09-20
Improvements
MaxandMinof a scalar and a collection compile on theinterval-jstarget.Min(1, L),Max(0, Min(1, L))andMin(1, [x, 2, 3])used to fail to compile there. An empty collection beside a scalar contributes nothing (Min(1, [])is1).Min(L, M)also compiles.- Faster coordinate access on a large written-out list of points.
PointX,PointYandPointZover aListof more than 100 points now read the coordinates directly. On ten thousand points,PointZ(C)went from 165 ms to 79 ms. - A symbolic integration that timed out during compilation is not searched
again for a target that declares wider types for the same symbols. For
example, a search that timed out with
k: real<0..>, x: realis not repeated withk: real, x: number. The emitted code does not change.
Resolved Issues
- Integrating
xⁿ·eˣwith a symbolic exponent no longer hangs.ce.parse('\int x^n e^x dx').evaluate()did not return. The same happened withxⁿ·sin xand(2x+1)ⁿ·eˣ. These integrals have no elementary closed form and now come back unevaluated at once. Integrals with a positive integer exponent, such asx³·eˣ, are unchanged. - The record of a timed-out symbolic integration is found again when the host declares its symbols again for each compilation. In 0.132.2, a host that made the same declarations in a new scope for each compilation repeated the two-second search every time.
0.132.2 2026-09-19
Improvements
- Faster compiled point arithmetic over a list of points.
PointX(0.3(t, t) + 4[(x₁, y₁), …])now compiles to0.3t + 4[x₁, …], without building the points. On a list of 7,225 points, a call went from 2.55 ms to 0.14 ms. On theglslandwgsltargets, a list of points whose coordinates are computed at run time now compiles to afloat[N]array of coordinates. - A symbolic integration that timed out is not repeated by the next
compilation of the same integral. Compiling an
Integratesearches for a closed form for up to two seconds, then uses numeric integration. The next compilation of the same integral (for example, for another target) now goes to numeric integration at once. An assignment, an assumption, or a change of the angular unit or the precision clears this record. The emitted code does not change. - Functions over a list that mixes complex and real elements compile to
JavaScript. For example,
Abs,Divide,Power,Real,ExpandSignoverWhich(0 < L, i·L, True, 0)used to fail to compile. Before, onlyAdd,Subtract,MultiplyandNegatecompiled.
Resolved Issues
- Compiled complex arithmetic in JavaScript gives the same results at a pole
as the interpreter.
1 / (0·i)is~oo, its absolute value is+∞and its reciprocal is0; the compiled code gaveNaNfor all three.- A complex power of zero is
0for an exponent with a positive real part,~oofor a negative real exponent, andNaNotherwise (0⁻²was−∞,0⁰was1). - At a complex zero,
CotandCscare~oo, andCoth,CschandArsechare+∞.Arg(~oo)isNaN; it wasπ/4.
- A complex power of zero is
- Fixes to compiled coordinate access over a wide list of points.
- A point that is a sum of points of different sizes,
(a, b) + (1, 2, 3), now compiles toNaN, as the interpreter reports anincompatible-typeerror. It gavea + 1. - A
varsentry given as JavaScript source is no longer removed when its coordinate is not used. - An
operatorsorfunctionsoverride ofAdd,NegateorMultiplyinside a point now receives the points, not their coordinates.
- A point that is a sum of points of different sizes,
0.132.1 2026-09-19
Resolved Issues
resolveApplicationcallbacks preserve range precedence. A callback could changef(x)+1...nintof(x)+(1...n), even when it returnedundefined. This also affected offsets inside range bounds, such asL[I(l,r,i)+1...n], and explicitly parenthesized ranges.
0.132.0 2026-09-19
New Features
- New
resolveApplication(context)parse hook. It receives the head, the parsed arguments, the LaTeX source offsets and the enclosing structure of a parenthesized occurrence such asf(x). Returnapply,multiply, orundefinedto choose how that occurrence is read. The decision is kept in the raw MathJSON, so it applies even when canonicalization happens later. Explicit declarations, function parameters andresolveSymbolfacts take precedence.
Behavior Changes
- Explicit declarations take precedence over
resolveSymbol. The handler is consulted only when a name has no explicit binding. An explicitunknowndeclaration also takes precedence; a type only inferred from earlier use can be overridden. The facts the handler supplies are kept with the expression, not installed in the caller's scope. To choose the reading of each occurrence, useresolveApplication.
Resolved Issues
- In non-strict mode, a letter run before a parenthesis is one name.
ce.parse("foo(x)", { strict: false })wasf·o·o(x); it is now["foo", "x"], andmyfn(2, 3)is["myfn", 2, 3]. Without a parenthesis,foois stillf·o·o, and a run that contains a Greek constant keeps its split (pix(3)isπ·x·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
Eigenvaluesreturned 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