Compute Engine Changelog
Coming Soon
Breaking Changes
-
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
-
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
-
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
-
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.) -
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
-
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.
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
-
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 bug fixes 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
0.30.2 2025-07-15
Breaking Changes
-
The
expr.valueproperty reflects the value of the expression if it is a number literal or a symbol with a literal value. If you previously used theexpr.valueproperty to get the value of an expression, you should now use theexpr.N().valueOf()method instead. ThevalueOf()method is suitable for interoperability with JavaScript, but it may result in a loss of precision for numbers with more than 15 digits. -
BoxedExpr.sgnnow returns undefined for complex numbers, or symbols with a complex-number value. -
The
ce.assign()method previously acceptedce.assign("f(x, y)", ce.parse("x+y")). This is now deprecated. Usece.assign("f", ce.parse("(x, y) \\mapsto x+y")instead. -
It was previously possible to invoke
expr.evaluate()orexpr.N()on a non-canonical expression. This will now return the expression itself.To evaluate a non-canonical expression, use
expr.canonical.evaluate()orexpr.canonical.N().That's also the case for the methods
numeratorDenominator(),numerator(), anddenominator().In addition, invoking the methods
inv(),abs(),add(),mul(),div(),pow(),root(),ln()will throw an error if the expression is not canonical.
New Features and Improvements
-
Collections now support lazy materialization. This means that the elements of some collection are not computed until they are needed. This can significantly improve performance when working with large collections, and allow working with infinite collections. For example:
ce.box(['Map', 'Integers', 'Square']).evaluate().print();// -> [0, 1, 4, 9, 16, ...]Materialization can be controlled with the
materializationoption of theevaluate()method. Lazy collections are materialized by default when converted to a string or LaTeX, or when assigned to a variable. -
The bindings of symbols and function expressions is now consistently done during canonicalization.
-
It was previously not possible to change the type of an identifier from a function to a value or vice versa. This is now possible.
-
Antiderivatives are now computed symbolically:
ce.parse(`\\int_0^1 \\sin(\\pi x) dx`).evaluate().print();
// -> 2 / pi
ce.parse(`\\int \\sin(\\pi x) dx`).evaluate().print();
// -> -cos(pi * x) / pi
Requesting a numeric approximation of the integral will use a Monte Carlo method:
ce.parse(`\\int_0^1 \\sin(\\pi x) dx`).N().print();
// -> 0.6366
-
Numeric approximations of integrals is several order of magnitude faster.
-
Added Number Theory functions:
Totient,Sigma0,Sigma1,SigmaMinus1,IsPerfect,Eulerian,Stirling,NPartition,IsTriangular,IsSquare,IsOctahedral,IsCenteredSquare,IsHappy,IsAbundant. -
Added Combinatorics functions:
Choose,Fibonacci,Binomial,CartesianProduct,PowerSet,Permutations,Combinations,Multinomial,SubfactorialandBellNumber. -
The
symboltype can be refined to match a specific symbol. For examplesymbol<True>. The typeexpressioncan be refined to match expressions with a specific operator, for exampleexpression<Add>is a type that matches expressions with theAddoperator. The numeric types can be refined with a lower and upper bound. For exampleinteger<0..10>is a type that matches integers between 0 and 10. The typereal<1..>matches real numbers greater than 1 andrational<..0>matches non-positive rational numbers. -
Numeric types can now be constrained with a lower and upper bound. For example,
real<0..10>is a type that matches real numbers between 0 and 10. The typeinteger<1..>matches integers greater than or equal to 1. -
Collections that can be indexed (
list,tuple) are now a subtype ofindexed_collection. -
The
maptype has been replaced withdictionaryfor collections of arbitrary key-value pairs andrecordfor collections of structured key-value pairs. -
Support for structural typing has been added. To define a structural type, use
ce.declareType()with thealiasflag, for example:ce.declareType("point", "tuple<x: integer, y: integer>",{ alias: true }); -
Recursive types are now supported by using the
typekeyword to forward reference types. For example, to define a type for a binary tree:ce.declareType("binary_tree","tuple<value: integer, left: type binary_tree?, right: type binary_tree?>",); -
The syntax for variadic arguments has changeed. To indicate a variadic argument, use a
+or*after the type, for example:ce.declare('f', '(number+) -> number');Use
+for a non-empty list of arguments and*for a possibly empty list. -
Added a rule to solve the equation
a^x + b = 0 -
The LaTeX parser now supports the
\placeholder[]{},\phantom{},\hphantom{},\vphantom{},\mathstrut,\strutand\smash{}commands. -
The range of recognized sign values, i.e. as returned from
BoxedExpression.sgnhas been simplified (e.g. '...-infinity' and 'nan' have been removed) -
The Power canonical-form is less aggressive - only carrying-out ops. as listed in doc. - is much more careful in its consideration of operand types & values... (for example, typically, exponents are required to be numbers: e.g.
x^1will simplify, butx^y(wherey===0), orx^{1+0}, will not)
Issues Resolved
-
Ensure expression LaTeX serialization is based on MathJSON generated with matching "pretty" formatting (or not), therefore resulting in LaTeX with less prettification, where
prettify === false(#daef87f) -
Symbols declare with a
constantflag are now not marked as "inferred" -
Some
BoxedSymbolsproperties now more consistently returnundefined, instead of aboolean(i.e. because the symbol is non-bound) -
Some
expr.root()computations -
Canonical-forms
- Fixes the
Numberform - Forms (at least,
Number,Power) do not mistakenly fully canonicalize operands - This (partial canonicalization) now substitutes symbols (constants) with a
holdUntilvalue of"never"during/prior-to canonicalization (i.e. just like for full canonicalization)
- Fixes the
0.29.1 2025-03-31
- #231 During evaluation, some numbers, for example
10e-15were incorrectly rounded to 0.
0.28.0 2025-02-06
Issues Resolved
-
#211 More consistent canonicalization and serialization of exact numeric values of the form
(a√b)/c. -
#219 The
invisibleOperatorcanonicalization previously also canonicalized some multiplication. -
#218 Improved performance of parsing invisible operators, including fixing some cases where the parsing was incorrect.
-
#216 Correctly parse subscripts with a single character, for example
x_1. -
#216 Parse some non-standard integral signs, for example
\int x \cdot \differentialD x(both the\cdotand the\differentialDare non-standard). -
#210 Numeric approximation of odd nth roots of negative numbers evaluate correctly.
-
#153 Correctly parse integrals with
\limits, e.g.\int\limits_0^1 x^2 \mathrm{d} x. -
Correctly serialize to ASCIIMath
Delimiterexpressions. -
When inferring the type of numeric values do not constrain them to be
real. As a result:ce.assign('a', ce.parse('i'));ce.parse('a+1').evaluate().print();now returns
1 + iinstead of throwing a type error. -
Correctly parse and evaluate unary and binary
\pmand\mpoperators.
New Features and Improvements
-
expr.isEqual()will now return true/false if the expressions include the same unknowns and are structurally equal after expansion and simplifications. For example:console.info(ce.parse('(x+1)^2').isEqual(ce.parse('x^2+2x+1')));// -> true
Asynchronous Operations
Some computations can be time-consuming, for example, computing a very large factorial. To prevent the browser from freezing, the Compute Engine can now perform some operations asynchronously.
To perform an asynchronous operation, use the expr.evaluateAsync method. For
example:
try {
const fact = ce.parse('(70!)!');
const factResult = await fact.evaluateAsync();
factResult.print();
} catch (e) {
console.error(e);
}
It is also possible to interrupt an operation, for example by providing a
pause/cancel button that the user can press. To do so, use an AbortController
object and a signal. For example:
const abort = new AbortController();
const signal = abort.signal;
setTimeout(() => abort.abort(), 500);
try {
const fact = ce.parse('(70!)!');
const factResult = await fact.evaluateAsync({ signal });
factResult.print();
} catch (e) {
console.error(e);
}
In the example above, we trigger an abort after 500ms.
It is also possible to control how long an operation can run by setting the
ce.timeLimit property with a value in milliseconds. For example:
ce.timeLimit = 1000;
try {
const fact = ce.parse('(70!)!');
fact.evaluate().print();
} catch (e) {
console.error(e);
}
The time limit applies to either the synchronous or asynchronous evaluation.
The default time limit is 2,000ms (2 seconds).
When an operation is canceled either because of a timeout or an abort, a
CancellationError is thrown.
0.27.0 2024-12-02
-
#217 Correctly parse LaTeX expressions that include a command followed by a
*such as\\pi*2. -
#217 Correctly calculate the angle of trigonometric expressions with an expression containing a reference to
Pi, for example\\sin(\\pi^2). -
The
Factorialfunction will now time out if the argument is too large. The timeout is signaled by throwing aCancellationError. -
When specifying
exp.toMathJSON({shorthands:[]}), i.e., not to use shorthands in the MathJSON, actually avoid using shorthands. -
Correctly use custom multiply, plus, etc. for LaTeX serialization.
-
When comparing two numeric values, the tolerance is now used to determine if the values are equal. The tolerance can be set with the
ce.toleranceproperty. -
When comparing two expressions with
isEqual()the values are compared structurally when necessary, or with a stochastic test when the expressions are too complex to compare structurally. -
Correctly serialize nested superscripts, e.g.
x^{y^z}. -
The result of evaluating a
Holdexpression is now the expression itself. -
To prevent evaluation of an expression temporarily, use the
Unevaluatedfunction. The result of evaluating anUnevaluatedexpression is its argument. -
The type of a
Holdexpression was incorrectly returned asstring. It now returns the type of its argument. -
The statistics function (
Mean,Median,Variance,StandardDeviation,Kurtosis,Skewness,Mode,QuartilesandInterQuartileRange) now accept as argument either a collection or a sequence of values.ce.parse("\\mathrm{Mean}([7, 2, 11])").evaluate().print();// -> 20/3ce.parse("\\mathrm{Mean}(7, 2, 11)").evaluate().print();// -> 20/3 -
The
VarianceandStandardDeviationfunctions now have variants for population statistics,PopulationVarianceandPopulationStandardDeviation. The default is to use sample statistics.ce.parse("\\mathrm{PopulationVariance}([7, 2, 11])").evaluate().print();// -> 13.555ce.parse("\\mathrm{Variance}([7, 2, 11])").evaluate().print();// -> 20.333 -
The statistics function can now be compiled to JavaScript:
const code = ce.parse("\\mathrm{Mean}(7, 2, 11)").compile();console.log(code());// -> 13.555 -
The statistics function calculate either using machine numbers or bignums depending on the precision. The precision can be set with the
precisionproperty of the Compute Engine. -
The argument of compiled function is now optional.
-
Compiled expressions can now reference external JavaScript functions. For example:
ce.defineFunction('Foo', {signature: 'number -> number',evaluate: ([x]) => ce.box(['Add', x, 1]),});const fn = ce.box(['Foo', 3]).compile({functions: { Foo: (x) => x + 1 },})!;console.info(fn());// -> 4ce.defineFunction('Foo', {signature: 'number -> number',evaluate: ([x]) => ce.box(['Add', x, 1]),});function foo(x) {return x + 1;}const fn = ce.box(['Foo', 3]).compile({functions: { Foo: foo },})!;console.info(fn());// -> 4Additionally, functions can be implicitly imported (in case they are needed by other JavaScript functions):
ce.defineFunction('Foo', {signature: 'number -> number',evaluate: ([x]) => ce.box(['Add', x, 1]),});function bar(x, y) {return x + y;}function foo(x) {return bar(x, 1);}const fn = ce.box(['Foo', 3]).compile({functions: { Foo: 'foo' },imports: [foo, bar],})!;console.info(fn());// -> 4 -
Compiled expression can now include an arbitrary preamble (JavaScript source) that is executed before the compiled function is executed. This can be used to define additional functions or constants.
ce.defineFunction('Foo', {signature: 'number -> number',evaluate: ([x]) => ce.box(['Add', x, 1]),});const code = ce.box(['Foo', 3]).compile({preamble: "function Foo(x) { return x + 1};",}); -
The
holdfunction definition flag has been renamed tolazy
0.26.4 2024-10-17
- #201 Identifiers of the form
A_\text{1}were not parsed correctly. - #202 Fixed serialization of integrals and bigops.
0.26.3 2024-10-17
- Correctly account for
fractionalDigitswhen formatting numbers. - #191 Correctly handle
\\lnot\\foralland\\lnot\\exists. - #206 The square root of 1000000 was canonicalized to 0.
- #207 When a square root with a literal base greater than 1e6 was preceded by a non-integer literal number, the literal number was ignored during canonicalization.
- #208 #204 Correctly evaluate numeric approximation of roots, e.g.
\\sqrt[3]{125}. - #205
1/ln(0)was incorrectly evaluated to1. It now returns0.
0.26.1 2024-10-04
Issues Resolved
- #194 Correctly handle the precedence of unary negate, for example in
-5^{\frac12}or-5!. - When using a function definition with
ce.declare(), do not generate a runtime error.
New Features and Improvements
- Added
.expand()method to boxed expression. This method expands the expression, for examplece.parse("(x+1)^2").expand()will returnx^2 + 2x + 1.
0.26.0 2024-10-01
Breaking Changes
-
The property
expr.headhas been deprecated. Useexpr.operatorinstead.expr.headis still supported in this version but will be removed in a future update. -
The MathJSON utility functions
head()andop()have been renamed tooperator()andoperand()respectively. -
The methods for algebraic operations (
add,div,mul, etc...) have been moved from the Compute Engine to the Boxed Expression class. Instead of callingce.add(a, b), calla.add(b).Those methods also behave more consistently: they apply some additional simplication rules over canonicalization. For example, while
ce.parse('1 + 2')return["Add", 1, 2],ce.box(1).add(2)will return3. -
The
ce.numericModeoption has been removed. Instead, set thece.precisionproperty to the desired precision. Set the precision to"machine"for machine precision calculations (about 15 digits). Set it to"auto"for a default of 21 digits. Set it to a number for a greater fixed precision. -
The MathJSON Dictionary element has been deprecated. Use a
Dictionaryexpression instead. -
The
ExtendedRealNumbers,ExtendedComplexNumbersdomains have been deprecated. Use theRealNumbersandComplexNumbersdomains instead. -
The "Domain" expression has been deprecated. Use types instead (see below).
-
Some
BoxedExpressionproperties have been removed:- Instead of
expr.isZero, useexpr.is(0). - Instead of
expr.isNotZero, use!expr.is(0). - Instead of
expr.isOne, useexpr.is(1). - Instead of
expr.isNegativeOne, useexpr.is(-1).
- Instead of
-
The signature of
ce.declare()has changed. In particular, theNhandler has been replaced withevaluate.
// Before
ce.declare('Mean', {
N: (ce: IComputeEngine): BoxedExpression => {
return ce.number(1);
},
});
// Now
ce.declare('Mean', { evaluate: (ops, { engine }) => ce.number(1) });
New Features and Improvements
-
New Simplification Engine
The way expressions are simplified has been completely rewritten. The new engine is more powerful and more flexible.
The core API remains the same: to simplify an expression, use
expr.simplify().To use a custom set of rules, pass the rules as an argument to
simplify():expr.simplify({rules: ["|x:<0| -> -x","|x:>=0| -> x",]});There are a few changes to the way rules are represented. The
priorityproperty has been removed. Instead, rules are applied in the order in which they are defined.A rule can also now be a function that takes an expression and returns a new expression. For example:
expr.simplify({rules: [(expr) => {if (expr.operator !== 'Abs') return undefined;const x = expr.args[0];return x.isNegative ? x.negate() : expr;}]});This can be used to perform more complex transformations at the cost of more verbose JavaScript code.
The algorithm for simplification has been simplified. It attempts to apply each rule in the rule set in turn, then restarts the process until no more rules can be applied or the result of applying a rule returns a previously seen expression.
Function definitions previously included a
simplifyhandler that could be used to perform simplifications specific to this function. This has been removed. Instead, use a rule that matches the function and returns the simplified expression. -
Types
Previously, an expression was associated with a domain such as
RealNumbersorComplexNumbers. This has been replaced with a more flexible system of types.A type is a set of values that an expression can take. For example, the type
realis the set of real numbers, the typeintegeris the set of integers,The type of an expression can be set with the
typeproperty. For example:const expr = ce.parse('\\sqrt{-1}');console.info(expr.type); // -> imaginaryThe type of a symbol can be set when declaring the symbol. For example:
ce.declare('x', 'imaginary');In addition to primitive types, the type system supports more complex types such union types, intersection types, and function types.
For example, the type
real|imaginaryis the union of the real and imaginary numbers.When declaring a function, the type of the arguments and the return value can be specified. For example, to declare a function
fthat takes two integers and returns a real number:ce.declare('f', '(integer, integer) -> real');The sets of numbers are defined as follows:
number- any number, real or complex, including NaN and infinitynon_finite_number- NaN or infinityrealfinite_real- finite real numbers (exclude NaN and infinity)imaginary- imaginary numbers (complex numbers with a real part of 0)finite_imaginarycomplex- complex numbers with a real and imaginary part not equal to 0finite_complexrationalfinite_rationalintegerfinite_integer
To check the type of an expression, use the
isSubtypeOf()method. For example:let expr = ce.parse('5');console.info(expr.type.isSubtypeOf('rational')); // -> trueconsole.info(expr.type.isSubtypeOf('integer')); // -> trueexpr = ce.parse('\\frac{1}{2}');console.info(expr.type.isSubtypeOf('rational')); // -> trueconsole.info(expr.type.isSubtypeOf('integer')); // -> falseAs a shortcut, the properties
isReal,isRational,isIntegerare available on boxed expressions. For example:let expr = ce.parse('5');console.info(expr.isInteger); // -> trueconsole.info(expr.isRational); // -> trueThey are equivalent to
expr.type.isSubtypeOf('integer')andexpr.type.isSubtypeOf('rational')respectively.To check if a number has a non-zero imaginary part, use:
let expr = ce.parse('5i');console.info(expr.isNumber && expr.isReal === false); // -> true -
Collections
Support for collections has been improved. Collections include
List,Set,Tuple,Range,Interval,LinspaceandDictionary.It is now possible to check if an element is contained in a collection using an
Elementexpression. For example:let expr = ce.parse('[1, 2, 3]');ce.box(['Element', 3, expr]).print(); // -> Truece.box(['Element', 5, expr]).print(); // -> FalseTo check if a collection is a subset of another collection, use the
Subsetexpression. For example:ce.box(['Subset', 'Integers', 'RealNumbers']).print(); // -> TrueCollections can also be compared for equality. For example:
let set1 = ce.parse('\\lbrace 1, 2, 3 \\rbrace');let set2 = ce.parse('\\lbrace 3, 2, 1 \\rbrace');console.info(set1.isEqual(set2)); // -> trueThere are also additional convenience methods on boxed expressions:
expr.isCollectionexpr.contains(element)expr.sizeexpr.isSubsetOf(other)expr.indexOf(element)expr.at(index)expr.each()expr.get(key)
-
Exact calculations
The Compute Engine has a new backed for numerical calculations. The new backed can handle arbitrary precision calculations, including real and complex numbers. It can also handle exact calculations, preserving calculations with rationals and radicals (square root of integers). For example
1/2 + 1/3is evaluated to5/6instead of0.8(3).To get an approximate result, use the
N()method, for examplece.parse("\\frac12 + \\frac13").N().Previously the result of calculations was not always an exact number but returned a numerical approximation instead.
This has now been improved by introducing a
NumericValuetype that encapsulates exact numbers and by doing all calculations in this type. Previously the calculations were handled manually in the various evaluation functions. This made the code complicated and error prone.A
NumericValueis made of:- an imaginary part, represented as a fixed-precision number
- a real part, represented either as a fixed or arbitrary precision number or as the product of a rational number and the square root of an integer.
For example:
- 234.567
- 1/2
- 3√5
- √7/3
- 4-3i
While this is a significant change internally, the external API remains the same. The result of calculations should be more predictable and more accurate.
One change to the public API is that the
expr.numericValueproperty is now either a machine precision number or aNumericValueobject. -
Rule Wildcards
When defining a rule as a LaTeX expression, single character identifiers are interpreted as wildcards. For example, the rule
x + x -> 2xwill match any expression with two identical terms. The wildcard corresponding toxis_x.It is now possible to define sequence wildcards and optional sequence wildcards. Sequence wildcards match 1 or more expressions, while optional sequence wildcards match 0 or more expressions.
They are indicated in LaTeX as
...xand...x?respectively. For example:expr.simplify("x + ...y -> 2x");If
exprisa + b + cthe rule will match and return2aexpr.simplify("x + ...y? -> 3x");If
exprisa + b + cthe rule will match and return3a. Ifexprisathe rule will match and return3a. -
Conditional Rules
Rules can now include conditions that are evaluated at runtime. If the condition is not satisfied, the rules does not apply.
For example, to simplify the expression
|x|:expr.simplify({rules: ["|x_{>=0}| -> x","|x_{<0}| -> -x",]});The condition is indicated as a subscript of the wildcard. The condition can be one of:
-
boolean- a boolean value, True or False -
string- a string of characters -
number- a number literal -
symbol -
expression -
numeric- an expression that has a numeric value, i.e. 2√3, 1/2, 3.14 -
integer- an integer value, -2, -1, 0, 1, 2, 3, ... -
natural- a natural number, 0, 1, 2, 3, ... -
real- real numbers, including integers -
imaginary- imaginary numbers, i.e. 2i, 3√-1 (not including real numbers) -
complex- complex numbers, including real and imaginary -
rational- rational numbers, 1/2, 3/4, 5/6, ... -
irrational- irrational numbers, √2, √3, π, ... -
algebraic- algebraic numbers, rational and irrational -
transcendental- transcendental numbers, π, e, ... -
positive- positive real numbers, > 0 -
negative- negative real numbers, < 0 -
nonnegative- nonnegative real numbers, >= 0 -
nonpositive- nonpositive real numbers, <= 0 -
even- even integers, 0, 2, 4, 6, ... -
odd- odd integers, 1, 3, 5, 7, ... -
prime:A000040 - prime numbers, 2, 3, 5, 7, 11, ... -
composite:A002808 - composite numbers, 4, 6, 8, 9, 10, ... -
notzero- a value that is not zero -
notone- a value that is not one -
finite- a finite value, not infinite -
infinite -
constant -
variable -
function -
operator -
relation- an equation or inequality -
equation -
inequality -
vector- a tensor of rank 1 -
matrix- a tensor of rank 2 -
list- a collection of values -
set- a collection of unique values -
tuple- a fixed length list -
single- a tuple of length 1 -
pair- a tuple of length 2 -
triple- a tuple of length 3 -
collection- a list, set, or tuple -
tensor- a nested list of values of the same type -
scalar- not a tensor or list
or one of the following expressions:
>0'->positive,\gt0'->positive,<0'->negative,\lt0'->negative,>=0'->nonnegative,\geq0'->nonnegative,<=0'->nonpositive,\leq0'->nonpositive,!=0'->notzero,\neq0'->notzero,!=1'->notone,\neq1'->notone,\in\Z'->integer,\in\mathbb{Z}'->integer,\in\N'->natural,\in\mathbb{N}'->natural,\in\R'->real,\in\mathbb{R}'->real,\in\C'->complex,\in\mathbb{C}'->complex,\in\Q'->rational,\in\mathbb{Q}'->rational,\in\Z^+'->integer,positive,\in\Z^-'->intger,negative,\in\Z^*'->nonzero,\in\R^+'->positive,\in\R^-'->negative,\in\R^*'->real,nonzero,\in\N^*'->integer,positive,\in\N_0'->integer,nonnegative,\in\R\backslash\Q'->irrational,
More complex conditions can be specified following a semi-colon, for example:
expr.simplify({x -> 2x; x < 10});Note that this syntax complements the existing rule syntax, and can be used together with the existing, more verbose, rule syntax.
expr.simplify({rules: [{match: "x + x", replace: "2x", condition: "x < 10"}]});This advanced syntax can specify more complex conditions, for example above the rule will only apply if
xis less than 10. -
-
Improved results for
Expand. In some cases the expression was not fully expanded. For example,4x(3x+2)-5(5x-4)now returns12x^2 - 17x + 20. Previously it returned4x(3x+2)+25x-20. -
AsciiMath serialization The
expr.toString()method now returns a serialization of the expression using the AsciiMath format.The serialization to AsciiMath can be customized using the
toAsciiMath()method. For example:console.log(ce.box(['Sigma', 2]).toAsciiMath({functions: {Sigma: 'sigma'}}));// -> sigma(2) -
The tolerance can now be specified with a value of
"auto"which will use the precision to determine a reasonable tolerance. The tolerance is used when comparing two numbers for equality. The tolerance can be specified with thece.toleranceproperty or in the Compute Engine constructor. -
Boxed expressions have some additional properties:
expr.isNumberLiteral- true if the expression is a number literal.This is equivalent to checking ifexpr.numericValueis notnull.expr.re- the real part of the expression, if it is a number literal,undefinedif not a number literal.expr.im- the imaginary part of the expression, if it is a number literal,undefinedif not a number literal.expr.bignumRe- the real part of the expression as a bignum, if it is a number literal,undefinedif not a number literal or a bignum representation is not available.expr.bignumIm- the imaginary part of the expression as a bignum, if it is a number literal,undefinedif not a number literal or if a bignum representation is not available.expr.root()to get the root of the expression. For example,expr.root(3)will return the cube root of the expression.- Additionally, the relational operators (
expr.isLess(), expr.isEqual(), etc...) now accept a number argument. For example,expr.isGreater(1)will return true if the expression is greater than 1.
-
Added LaTeX syntax to index collections. If
ais a collection:a[i]is parsed as["At", "a", "i"].a[i,j]is parsed as["At", "a", "i", "j"].a_iis parsed as["At", "a", "i"].a_{i,j}is parsed as["At", "a", "i", "j"].
-
Added support for Kronecker delta notation, i.e.
\delta_{ij}, which is parsed as["KroneckerDelta", "i", "j"]and is equal to 1 ifi = jand 0 otherwise.When a single index is provided the value of the function is 1 if the index is 0 and 0 otherwise
When multiple index are provided, the value of the function is 1 if all the indexes are equal and 0 otherwise.
-
Added support for Iverson Bracket notation, i.e.
[a = b], which is parsed as["Boole", ["Equal", "a", "b"]]and is equal to 1 if its argument is true and 0 otherwise. The argument is expected to be a relational expression. -
Implemented
UniqueandTallyon collections.Uniquereturns a collection with only the unique elements of the input collection, andTallyreturns a collection with the count of each unique element.console.log(ce.box(['Unique', ['List', 1, 2, 3, 1, 2, 3, 4, 5]]).value);// -> [1, 2, 3, 4, 5]console.log(ce.box(['Tally', ['List', 1, 2, 3, 1, 2, 3, 4, 5]]).value);// -> [['List', 1, 2, 3, 4, 5], ['List', 2, 2, 2, 1, 1]] -
Implemented the
Map,FilterandTabulatefunctions. These functions can be used to transform collections, for example:// Using LaTeXconsole.log(ce.parse('\\mathrm{Map}([3, 5, 7], x \\mapsto x^2)').toString());// -> [9, 25, 49]// Using boxed expressionsconsole.log(ce.box(['Map', ['List', 3, 5, 7], ['Square', '_']]).value);// -> [9, 25, 49]console.log(ce.box(['Tabulate',['Square', '_'], 5]).value);// -> [1, 4, 9, 16, 25]Tabulatecan be used with multiple indexes. For example, to generate a 4x4 unit matrix:console.log(ce.box(['Tabulate', ['If', ['Equal', '_1', '_2'], 1, 0]], 4, 4).value);// -> [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]// Using the Kronecker delta notation:console.log(ce.parse('\\mathrm{Tabulate}(i, j \\mapsto \\delta_{ij}, 4, 4)').value);// -> [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]] -
Added
Randomfunction.["Random"]returns a real pseudo-random number betwen 0 and 1.["Random", 10]returns an integer between 0 and 9,["Random", 5, 10]returns an integer between 5 and 10. -
Extended the definition of
expr.isConstant. Previously, it only applied to symbols, e.g.Pi. Now it apply to all expressions.expr.isConstantis true if the expression is a number literal, a symbol with a constant value, or a pure function with constant arguments. -
The boxed expression properties
isPositive,isNegative,isNonNegative,isNonPositive,isZero,isNotZeronow return a useful value for most function expressions. For example,ce.parse('|x + 1|').isPositiveis true.If the value cannot be determined, the property will return
undefined. For example,ce.parse('|x + 1|').isZeroisundefined.If the expression is not a real number, the property will return
NaN. For example,ce.parse('i').isPositiveisNaN. -
Added
Choosefunction to compute binomial coefficients, i.e.Choose(5, 2)is equal to 10. -
The fallback for non-constructible complex values of trigonometric functions is now implemented via rules.
-
The canonical order of the arguments has changed and should be more consistent and predictable. In particular, for polynomials, the monomial order is now degrevlex.
-
Canonical expressions can now include a
Rootexpression. For example, the canonical form of\\sqrt[3]{5}is["Root", 5, 3]. Previously, these were represented as["Power", 5, ["Divide", 1, 3]]. -
The function definitions no longer have a
Nhandler. Instead theevaluatehandler has an optional{numericApproximation}argument.
Issues Resolved
-
#188 Throw an error when invalid expressions are boxed, for example
ce.box(["Add", ["3"]]). -
Some LaTeX renderer can't render
\/, so use/instead. -
When definitions are added to the LaTeX dictionary, they now take precedence over the built-in definitions. This allows users to override the built-in definitions.
-
Improved parsing of functions, including when a mixture of named and positional arguments are used.
-
#175 Matching some patterns when the target had not enough operands would result in a runtime error.
0.25.1 2024-06-27
Issues Resolved
- #174 Fixed some simplifications, such as
\frac{a^n}{a^m} = a^{n-m)
New Features
-
Rules can be defined using a new shorthand syntax, where each rule is a string of LaTeX:
expr.simplify(["\\frac{x}{x} -> 1", "x + x -> 2x"]);
Single letter variables are assumed to be wildcards, so x is interpreted as
the wildcard _x.
Additionally, the expanded form can also include LaTeX strings. The previous syntax using expressions can still be used, and the new and old syntax can be mixed.
For example:
expr.simplify([
{
match: "\\frac{x}{x}",
replace: "1"
},
{
match: ["Add", "x", "x"],
replace: "2x"
}
]);
The condition function can also be expressed as a LaTeX string.
expr.simplify([ { match: "\\frac{x}{x}", replace: 1, condition: "x != 0" }, ]);
The shorthand syntax can be used any where a ruleset is expected, including with
the ce.rule() function.
- A new
ce.getRuleSet()method gives access to the built-in rules. - #171 The
SubtractandDividefunction can now accept an arbitrary number of arguments. For example,["Subtract", 1, 2, 3]is equivalent to["Subtract", ["Subtract", 1, 2], 3].
0.25.0 2024-06-25
Breaking Changes
-
The canonical form of expressions has changed. It is now more consistent and simpler and should produce more predictable results.
For example, previously
ce.parse("1-x^2")would produce["Subtract", 1, ["Square", "x"]].While this is a readable form, it introduces some complications when manipulating the expression: both the
SubtractandSquarefunctions have to be handled, in addition toAddandPower.The new canonical form of this expression is
["Add", 1, ["Negate", ["Power", "x", 2]]]. It is a bit more verbose, but it is simpler to manipulate. -
The
ce.serialize()method has been replaced withexpr.toLatex()andexpr.toMathJson(). Thece.latexOptionsandce.jsonSerializationOptionsproperties have been removed. Instead, pass the formating options directly to thetoLatex()andtoMathJson()methods. Thece.parse()method now takes an optional argument to specify the format of the input string. -
The default JSON serialization of an expression has changed.
Previously, the default JSON serialization, accessed via the
.jsonproperty, had some transformations applied to it (sugaring) to make the JSON more human readable.For example,
ce.parse("\frac12").jsonwould return the symbol"Half"instead of["Divide", 1, 2].However, this could lead to some confusion when manipulating the JSON directly. Since the JSON is intended to be used by machine more than humans, these additional transformations have been removed.
The
expr.jsonproperty now returns the JSON representing the expression, without any transformations.To get a version of JSON with some transformations applied use the
ce.toMathJson()function.expr = ce.box(["Subtract", 1, ["Square", "x"]]);console.log(expr.json);// -> ["Add", 1, ["Negate", ["Power", "x", 2]]]expr.toMathJson()// -> ["Subtract", 1, ["Square", "x"]]expr.toMathJson({exclude: "Square"})// -> ["Subtract", 1, ["Power", "x", 2]]In practice, the impact of both of these changes should be minimal. If you were manipulating expressions using
BoxedExpression, the new canonical form should make it easier to manipulate expressions. You can potentially simplify your code by removing special cases for functions such asSquareandSubtract.If you were using the JSON serialization directly, you may also be able to simplify you code since the default output from
expr.jsonis now more consistent and simpler. -
The name of some number formatting options has changed. The number formatting options are an optional argument of
ce.parse()andce.toLatex(). See theNumberFormatandNumberSerializationFormattypes. -
The values +infinity, -infinity and NaN are now represented preferably with the symbols
PositiveInfinity,NegativeInfinityandNaNrespectively. Previously they were represented with numeric values, i.e.{num: "+Infinity"},{num: "-Infinity"}and{num: "NaN"}. The numeric values are still supported, but the symbols are preferred. -
The method
expr.isNothinghas been removed. Instead, useexpr.symbol === "Nothing".
New Features
-
When serializing to LaTeX, the output can be "prettified". This involves modifying the LaTeX output to make it more pleasant to read, for example:
a+\\frac{-b}{c}->a-\\frac{b}{c}a\\times b^{-1}->\\frac{a}{b}\\frac{a}{b}\\frac{c}{d}->\\frac{a\\cdot c}{b\\cdot d}--2->2
This is on by default and can be turned off by setting the
prettifyoption tofalse. For example:ce.parse("a+\\frac{-b}{c}").toLatex({prettify: true})// -> "a-\\frac{b}{c}"ce.parse("a+\\frac{-b}{c}").toLatex({prettify: false})// -> "a+\\frac{-b}{c}" -
Numbers can have a different digit group length for the whole and fractional part of a number. For example,
ce.toLatex(ce.parse("1234.5678"), {digitGroup: [3, 0]})will return1\,234.5678. -
Numbers can now be formatted using South-East Asian Numbering System, i.e. lakh and crore. For example:
ce.toLatex(ce.parse("12345678"), {digitGroup: "lakh"})// -> "1,23,45,678" -
Expressions with Integrate functions can now be compiled to JavaScript. The compiled function can be used to evaluate the integral numerically. For example:
const f = ce.parse("\\int_0^1 x^2 dx");const compiled = f.compile();console.log(compiled()); // -> 0.33232945619482307 -
#82 Support for angular units. The default is radians, but degrees can be used by setting
ce.angularUnit = "deg". Other possible values are "grad" and "turn". This affects how unitless numbers with a trigonometric function are interpreted. For example,sin(90)will return 1 whence.angularUnitis "deg", 0.8939966636005579 whence.angularUnitis "grad" and 0 whence.angularUnitis "turn". -
Added
expr.map(fn)method to apply a function to each subexpression of an expression. This can be useful to apply custom canonical forms and compare two expressions. -
An optional canonical form can now be specified with the
ce.function().
Issues Resolved
- #173 Parsing
1++2would result in an expression with aPreIncrementfunction. It is now correctly parsed as["Add", 1, 2]. - #161 Power expressions would not be processed when their argument was a Divide expression.
- #165 More aggressive simplification of expressions with exponent greater than 3.
- #169 Calculating a constant integral (and integral that did not depend on the variable) would result in a runtime error.
- #164 Negative mixed fractions (e.g.
-1\frac23) are now parsed correctly. - #162 Numeric evaluation of expressions with large exponents could result in machine precision numbers instead of bignum numbers.
- #155 The expression
["Subtract", ["Multiply", 0.5, "x"], ["Divide", "x", 2]]will now evaluate to0. - #154 In some cases, parsing implicit argument of trig function return more
natural results, for example
\cos a \sin bis now parsed as(\cos a)(\sin b)and not\cos (a \sin b). - #147 The associativity of some operators, including
/was not applied correctly, resulting in unexpected results. For example,1/2/3would be parsed as["Divide", 1, ["Divide", 2, 3]]instead of["Divide", ["Divide", 1, 2], 3]. - #146 When parsing an expression like
x(x+1)wherexis an undeclared symbol, do not infer thatxis a function. Instead, infer thatxis a variable and that the expression is a product. - #145 The expression
["Or", "False", "False"], that is when all the arguments areFalse, is now evaluates toFalse. - Fixed canonical form of
e^x^2, and more generally apply power rule in more cases. - Added missing "Sech" and "Csch" functions.
- The digit grouping serializing would place the separator in the wrong place for some numbers.
- The
avoidExponentsInRangeformating option would not always avoid exponents in the specified range.
0.24.0 2024-02-23
Issues Resolved
- Fix parsing of very deeply nested expressions.
- Correctly apply rules to deeply nested expressions.
expr.print()now correctly prints the expression when using the minified version of the library.expr.isEqual()now correctly compares equalities and inequalities.expr.match()has been improved and works correctly in more cases. The signature of thematchfunction has been changed so that the pattern is the first argument, i.e. instead ofpattern.match(expr)useexpr.match(pattern).- Fix
expr.print()when using the minified version of the library. - #142 Accept complex expressions as the subcript of
\lnand\login LaTeX. - #139 Parse quantifiers
\foralland\existsin LaTeX.
0.23.1 2024-01-27
Issues Resolved
- Using a custom canonical order of
"Multiply"would not distribute theNegatefunction. - #141 The canonical form
"Order"was applied to non-commutative functions.
0.23.0 2024-01-01
New Features
- Added
ExpandAllfunction to expand an expression recursively. - Added
Factorfunction to factor an expression. - Added
Togetherfunction to combine rational expressions into a single fraction.
Issues Resolved
- The expression
\frac5 7is now parsed correctly as\frac{5}{7}instead of\frac{5}{}7. - Do not sugar non-canonical expression. Previously,
ce.parse('\\frac{1}{2}', {canonical: false})would returnHalfinstead of['Divide', '1', '2']. - #132 Attempting to set a value to 0 with
ce.defineSymbol("count", {value: 0})would fail: the symbol would be undefined. - Correctly evaluate power expressions in some cases, for example
(\sqrt2 + \sqrt2)^2. - Comparison of expressions containing non-exact numbers could fail. For
example:
2(13.1+3.1x)and26.2+6.2xwould not be considered equal.
Improvements
- Significant improvements to symbolic computation. Now, boxing, canonicalization and evaluation are more consistent and produce more predictable results.
- Adedd the
\negcommand, synonym for\lnot->Not. - Relational expressions (inequalities, etc...) are now properly factored.
- Integers are now factored when simplifying, i.e.
2x = 4x->x = 2x.
0.22.0 2023-11-13
Breaking Changes
-
Rule Syntax
The syntax to describe rules has changed. The syntax for a rule was previously a tuple
[lhs, rhs, {condition} ]. The new syntax is an object with the propertiesmatch,replaceandcondition. For example:- previous syntax:
[["Add", "_x", "_x"], ["Multiply", 2, "_x"]] - new syntax:
{match: ["Add", "_x", "_x"], replace: ["Multiply", 2, "_x"]}
The
conditionproperty is optional, and is either a boxed function or a JavaScript function. For example, to add a condition that checks that_xis a number literal:{match: ["Add", "_x", "_x"],replace: ["Multiply", 2, "_x"],condition: ({_x}) => _x.isNumberLiteral} - previous syntax:
-
CanonicalFormThe
CanonicalOrderfunction has been replaced by the more flexibleCanonicalFormfunction. TheCanonicalFormfunction takes an expression and a list of transformations to apply. To apply the same transformations asCanonicalOrder, use:['CanonicalForm', expr, 'Order']These canonical forms can also be specified with
box()andparse()options:ce.box(expr, { canonical: "Order" });ce.parse("x^2 + 2x + 1", { canonical: "Order" });
Work In Progress
- Linear algebra functions:
Rank,Shape,Reshape,Flatten,Determinant,Trace,Transpose,ConjugateTranspose,Inverse. See the Linear Algebra reference guide. Some of these function may not yet return correct result in all cases.
New Features
- Added a
expr.print()method as a synonym forconsole.log(expr.toString()). - Added an
exactoption (false by default) to theexpr.match()pattern matching method. Whentruesome additional patterns are automatically recognized, for example,xwill match["Multiply", '_a', 'x']whenexactisfalse, but not whenexactistrue.
Improvements
- The equation solver used by
expr.solve()has been improved and can now solve more equations. - The pattern matching engine has been improved and can now match more expressions, including sequences for commutative functions.
0.21.0 2023-11-02
New Features
-
#125 Parse and serialize environemnts, i.e.
\begin{matrix} 1 & 2 \\ 3 & 4 \end{matrix}will be parsed as["Matrix", ["List", ["List", 1, 2], ["List", 3, 4]]].A new section on Linear Algebra has some details on the supported formats.
The linear algebra operations are limited at the moment, but will be expanded in the future.
-
Added
IsSamefunction, which is the function expression corresponding toexpr.isSame(). -
AddedCanonicalOrderfunction, which sorts the arguments of commutative functions into canonical order. This is useful to compare two non-canonical expressions for equality.
ce.box(["CanonicalOrder", ["Add", 1, "x"]]).isSame(
ce.box(["CanonicalOrder", ["Add", "x", 1]])
);
// -> true
Issue Resolved
- When evaluating a sum (
\sum) with a bound that is not a number, return the sum expression instead of an error.
0.20.2 2023-10-31
Issues Resolved
- Fixed numerical evaluation of integrals and limits when parsed from LaTeX.
console.info(ce.parse("\\lim_{x \\to 0} \\frac{\\sin(x)}{x}").value);
// -> 1
console.info(ce.parse("\\int_{0}^{2} x^2 dx").value);
// -> 2.6666666666666665
0.20.1 2023-10-31
Issues Resolved
- Fixed evaluation of functions with multiple arguments
- Fixed compilation of some function assignments
- Improved serialization of function assignment
0.20.0 2023-10-30
Breaking Changes
-
Architectural changes: the invisible operator is used to represent the multiplication of two adjacent symbols, i.e.
2x. It was previously handled during parsing, but it is now handled during canonicalization. This allows more complex syntactic structures to be handled correctly, for examplef(x) := 2x: previously, the left-hand-side argument would have been parsed as a function application, while in this case it should be interpreted as a function definition.A new
InvisibleOperatorfunction has been added to support this.The
applyInvisibleOperatorparsing option has been removed. To support custom invisible operators, use theInvisibleOperatorfunction.
Issues Resolved
- #25 Correctly parse chained relational operators, i.e.
a < b <= c - #126 Logic operators only accepted up to two arguments.
- #127 Correctly compile
Logwith bases other than 10. - Correctly parse numbers with repeating patterns but no fractional digits, i.e.
0.(1234) - Correctly parse
|1+|a|+2|
New Features and Improvements
- Function assignment can now be done with this syntax:
f(x) := 2x+1. This syntax is equivalent tof := x -> 2x+1. - Implement the
ModandCongruentfunction. - Correctly parse
11 \bmod 5(Mod) and26\equiv 11 \pmod5(Congruent) - Better handle empty argument lists, i.e.
f() - When a function is used before being declared, infer that the symbol is a
function, e.g.
f(12)will infer thatfis a function (and not a variablefmultiplied by 12) - When a constant is followed by some parentheses, don't assume this is a
function application, e.g.
\pi(3+n)is now parsed as["Multiply", "Pi", ["Add", 3, "n"]]instead of["Pi", ["Add", 3, "n"]] - Improved parsing of nested lists, sequences and sets.
- Improved error messages when syntax errors are encountered during LaTeX parsing.
- When parsing with the canonical option set to false, preserve more closely the original LaTeX syntax.
- When parsing text strings, convert some LaTeX commands to Unicode, including
spacing commands. As a result,
ce.parse("\\text{dead\;beef}_{16}")correctly gets evaluated to 3,735,928,559.
0.19.1 2023-10-26
Issues Resolved
- Assigning a function to an indentifier works correctly now, i.e.
ce.parse("\\operatorname{f} := x \\mapsto 2x").evaluate();
0.19.0 2023-10-25
Breaking Changes
- The
domainproperty of the function definitionsignatureis deprecated and replaced with theparams,optParams,restParamandresultproperties instead. Thedomainproperty is still supported for backward compatibility, but will be removed in a future version.
Issues Resolved
- When invoking a declared function in a numeric operation, correctly infer the result type.
["Assign", "f", ["Add", "_", 1]]
["Add", ["f", 1], 1]
// -> 3
Previously a domain error was returned, now f is inferred to have a numeric
return type.
- Fixed a runtime error when inverting a fraction, i.e.
\frac{3}{4}^{-1} - The tangent of π/2 now correctly returns
ComplexInfinity. - The exact values of some constructible trigonometric operations (e.g.
\tan 18\degree = \frac{\sqrt{25-10\sqrt5}}{5}) returned incorrect results. The unit test case was incorrect and did not detect the problem. The unit test case has been fixed and the returned values are now correct.
New Features
- Implemented
UnionandIntersectionof collections, for example:
["Intersection", ["List", 3, 5, 7], ["List", 2, 5, 9]]
// -> ["Set", 5]
["Union", ["List", 3, 5, 7], ["List", 2, 5, 9]]
// -> ["Set", 3, 5, 7, 2, 9]
-
Parse ranges, for example
1..5or1, 3..10. Ranges are collections and can be used anywhere collections can be used. -
The functions
Sum,Product,Min,Max, and the statistics functions (Mean,Median,Variance, etc...) now handle collection arguments: collections:["Range"],["Interval"],["Linspace"]expressions["List"]or["Set"]expressions["Tuple"],["Pair"],["Pair"],["Triple"]expressions["Sequence"]expressions
-
Most mathematical functions are now threadable, that is their arguments can be collections, for example:
["Sin", ["List", 0, 1, 5]]
// -> ["List", 0, 0.8414709848078965, -0.9589242746631385]
["Add", ["List", 1, 2], ["List", 3, 4]]
// -> ["List", 4, 6]
- Added
GCDandLCMfunctions
["GCD", 10, 5, 15]
// -> 5
["LCM", 10, 5, 15]
// -> 30
-
Added
Numerator,Denominator,NumeratorDenominatorfunctions. These functions can be used on non-canonical expressions. -
Added
HeadandTailfunctions which can be used on non-canonical expressions. -
Added
display-quotientandinline-quotientstyle for formatting of division expressions in LaTeX.
Improvements
- Improved parsing of
\degreecommand
ce.parse("30\\degree)
// -> ["Divide", "Pi", 6]
- Improved interoperability with JavaScript:
expr.valuewill return a JavaScript primitive (number,boolean,string, etc...) when possible. This is a more succinct version ofexpr.N().valueOf().
0.18.1 2023-10-16
Issues Resolved
- Parsing of whole numbers while in
rationalmode would return incorrect results. - The
NDfunction to evaluate derivatives numerically now return correct values.
ce.parse("\\mathrm{ND}(x \\mapsto 3x^2+5x+7, 2)").N();
// -> 17.000000000001
Improvements
- Speed up
NIntegrateby temporarily switching the numeric mode tomachinewhile computing the Monte Carlo approximation.
0.18.0 2023-10-16
New Features
- Expanded LaTeX dictionary with
\max,\min,\sup,\infand\limfunctions - Added
SupremumandInfimumfunctions - Compilation of
Blockexpressions, local variables, return statements and conditionalsIf. - Added numerical evaluation of limits with
Limitfunctions andNLimitfunctions, using a Richardson Extrapolation.
console.info(ce.parse("\\lim_{x\\to0} \\frac{\\sin x}{x}").N().json);
// -> 1
console.info(
ce.box(["NLimit", ["Divide", ["Sin", "_"], "_"], 0]).evaluate().json
);
// -> 1
console.info(ce.parse("\\lim_{x\\to \\infty} \\cos \\frac{1}{x}").N().json);
// -> 1
-
Added
AssignandDeclarefunctions to assign values to symbols and declare symbols with a domain. -
Blockevaluations with local variables work now. For example:
ce.box(["Block", ["Assign", "c", 5], ["Multiply", "c", 2]]).evaluate().json;
// -> 10
-
When decimal numbers are parsed they are interpreted as inexact numbers by default, i.e. "1.2" ->
{num: "1.2"}. To force the number to be interpreted as a rational number, setce.latexOptions.parseNumbers = "rational". In that case, "1.2" ->["Rational", 12, 10], an exact number.While regular decimals are considered "inexact" numbers (i.e. they are assumed to be an approximation), rationals are assumed to be exact. In most cases, the safest thing to do is to consider decimal numbers as inexact to avoid introducing errors in calculations. If you know that the decimal numbers you parse are exact, you can use this option to consider them as exact numbers.
Improvements
- LaTeX parser: empty superscripts are now ignored, e.g.
4^{}is interpreted as4.
0.17.0 2023-10-12
Breaking Changes
- The
Nothingdomain has been renamed toNothingDomain - The
Functions,Maybe,Sequence,Dictionary,ListandTupledomain constructors have been renamed toFunctionOf,OptArg,VarArg,DictionaryOf,ListOfandTupleOf, respectively. - Domains no longer require a
["Domain"]expression wrapper, so for examplece.box("Pi").domainreturns"TranscendentalNumbers"instead of["Domain", "TranscendentalNumbers"]. - The
VarArgdomain constructor now indicates the presence of 0 or more arguments, instead of 1 or more arguments. - The
MaybeBooleansdomain has been dropped. Use["Union", "Booleans", "NothingDomain"]instead. - The
ce.defaultDomainhas been dropped. The domain of a symbol is now determined by the context in which it is used, or by thece.assume()method. In some circumstances, the domain of a symbol can beundefined.
New Features
- Symbolic derivatives of expressions can be calculated using the
Dfunction. For example,ce.box(["D", ce.parse("x^2 + 3x + 1"), "x"]).evaluate().latexreturns"2x + 3".
Improvements
- Some frequently used expressions are now available as predefined constants,
for example
ce.Pi,ce.Trueandce.Numbers. - Improved type checking and inference, especially for functions with complicated or non-numeric signatures.
Bugs Fixed
- Invoking a function repeatedly would invoke the function in the original scope rather than using a new scope for each invocation.
0.16.0 2023-09-29
Breaking Changes
- The methods
ce.let()andce.set()have been renamed toce.declare()andce.assign()respectively. - The method
ce.assume()requires a predicate. - The signatures of
ce.assume()andce.ask()have been simplified. - The signature of
ce.pushScope()has been simplified. - The
expr.freeVarsproperty has been renamed toexpr.unknowns. It returns the identifiers used in the expression that do not have a value associated with them. Theexpr.freeVariablesproperty now return the identifiers used in the expression that are defined outside of the local scope and are not arguments of the function, if a function.
New Features
-
Domain Inference when the domain of a symbol is not set explicitly (for example with
ce.declare()), the domain is inferred from the value of the symbol or from the context of its usage. -
Added
Assume,Identity,Which,Parse,N,Evaluate,Simplify,Domain. -
Assignments in LaTeX:
x \\coloneq 42produce["Assign", "x", 42] -
Added
ErfInv(inverse error function) -
Added
Factorial2(double factorial)
Functions
-
Functions can now be defined:
- using
ce.assign()orce.declare() - evaluating LaTeX:
(x, y) \mapsto x^2 + y^2 - evaluating MathJSON:
["Function", ["Add", ["Power", "x", 2], ["Power", "y", 2]]], "x", "y"]
- using
-
Function can be applied using
\operatorname{apply}or the operators\rhdand\lhd:\operatorname{apply}(f, x)f \rhd xx \lhd f
See Adding New Definitions and Functions.
Control Structures
- Added
FixedPoint,Block,If,Loop - Added
Break,ContinueandReturnstatements
Calculus
- Added numeric approximation of derivatives, using an 8-th order centered
difference approximation, with the
NDfunction. - Added numeric approximation of integrals, using a Monte Carlo method with
rebasing for improper integrals, with the
NIntegratefunction - Added symbolic calculation of derivatives with the
Dfunction.
Collections
Added support for collections such as lists, tuples, ranges, etc...
See Collections
Collections can be used to represent various data structures, such as lists, vectors, matrixes and more.
They can be iterated, sliced, filtered, mapped, etc...
["Length", ["List", 19, 23, 5]]
// -> 3
["IsEmpty", ["Range", 1, 10]]
// -> "False"
["Take", ["Linspace", 0, 100, 50], 4]
// -> ["List", 0, 2, 4, 6]
["Map", ["List", 1, 2, 3], ["Function", "x", ["Power", "x", 2]]]
// -> ["List", 1, 4, 9]
["Exclude", ["List", 33, 45, 12, 89, 65], -2, 2]
// -> ["List", 33, 12, 65]
["First", ["List", 33, 45, 12, 89, 65]]
// -> 33
Improvements
- The documentation has been significantly rewritten with help from an AI-powered writing assistant.
Issues Resolved
- The LaTeX string returned in
["Error"]expression was incorrectly tagged asLatexinstead ofLatexString.
0.15.0 2023-09-14
Improvements
- The
ce.serialize()function now takes an optionalcanonicalargument. Set it tofalseto prevent some transformations that are done to produce more readable LaTeX, but that may not match exactly the MathJSON. For example, by defaultce.serialize(["Power", "x", -1])returns\frac{1}{x}whilece.serialize(["Power", "x", -1], {canonical: false})returnsx^{-1}. - Improved parsing of delimiters, i.e.
\left(,\right], etc... - Added complex functions
Real,Imaginary,Arg,Conjugate,AbsArg. See Complex - Added parsing and evaluation of
\Re,\Im,\arg,^\star(Conjugate). - #104 Added the
["ComplexRoots", x, n]function which returns the nthroot ofx. - Added parsing and evaluation of statistics functions
Mean,Median,StandardDeviation,Variance,Skewness,Kurtosis,Quantile,Quartiles,InterquartileRange,Mode,Count,Erf,Erfc. See Statistics
0.14.0 2023-09-13
Breaking Changes
- The entries in the LaTeX syntax dictionary can now have LaTeX triggers
(
latexTrigger) or triggers based on identifiers (symbolTrigger). The former replaces thetriggerproperty. The latter is new. An entry with atriggerIdentifierofaveragewill match\operatorname{average},\mathrm{average}and other variants. - The
ce.latexOptionsandce.jsonSerializationOptionsproperties are more robust. They can be modified directly or one of their properties can be modified.
Improvements
-
Added more functions and symbols supported by
expr.compile():Factorialpostfix operator5!Gammafunction\Gamma(2)LogGammafunction\operatorname{LogGamma}(2)Gcdfunction\operatorname{gcd}(20, 5)Lcmfunction\operatorname{lcm}(20, 5)Chopfunction\operatorname{chop}(0.00000000001)Halfconstant\frac{1}{2}- 'MachineEpsilon' constant
GoldenRatioconstantCatalanConstantconstantEulerGammaconstant\gammaMaxfunction\operatorname{max}(1, 2, 3)Minfunction\operatorname{min}(13, 5, 7)- Relational operators:
Less,Greater,LessEqual,GreaterEqual, 'Equal', 'NotEqual' - Some logical operators and constants:
And,Or,Not,True,False
-
More complex identifiers syntax are recognized, including
\mathbin{},\mathord{}, etc...\operatorname{}is the recommended syntax, though: it will display the identifier in upright font and with the propert spacing, and is properly enclosing. Some commands, such as\mathrm{}are not properly enclosing: two adjacent\mathrm{}command could be merged into one. -
Environments are now parsed and serialized correctly.
-
When parsing LaTeX, function application is properly handled in more cases, including custom functions, e.g.
f(x) -
When parsing LaTeX, multiple arguments are properly handled, e.g.
f(x, y) -
Add LaTeX syntax for logical operators:
And:\land,\operatorname{and}(infix or function)Or:\lor,\operatorname{or}(infix or function)Not:\lnot,\operatorname{not}(prefix or function)Xor:\veebar(infix)Nand:\barwedge(infix)Nor:^^^^22BD(infix)Implies:\implies(infix)Equivalent:\iff(infix)
-
When a postfix operator is defined in the LaTeX syntax dictionary of the form
^plus a single token, a definition with braces is added automatically so that both forms will be recognized. -
Extended the LaTeX dictionary with:
floorceilroundsgnexpabsgcdlcmapply
-
Properly handle inverse and derivate notations, e.g.
\sin^{-1}(x),\sin'(x),\cos''(x),\cos^{(4)}(x)or even\sin^{-1}''(x)
0.13.0 2023-09-09
New Features
- Compilation Some expressions can be compiled to Javascript. This is useful
to evaluate an expression many times, for example in a loop. The compiled
expression is faster to evaluate than the original expression. To get the
compiled expression, use
expr.compile(). Read more at Compiling
Issues Resolved and Improvements
- Fixed parsing and serialization of extended LaTeX synonyms for
eandi. - Fixed serialization of
Half. - Fixed serialization of
Which - Improved serialization of
["Delimiter"]expressions.
0.12.7 2023-09-08
Improvements
- Made customization of the LaTeX dictionary simpler. The
ce.latexDictionaryproperty can be used to access and modify the dictionary. The documentation has been updated.
0.12.6 2023-09-08
Breaking Changes
- New API for the
Parserclass.
Improvements and Bux Fixes
- The
ComputeEnginenow exports thebignum()andcomplex()methods that can be used to create bignum and complex numbers from strings or numbers. The methodsisBigNum()andisComplex()have also been added to check if a value is a bignum (Decimal) or complex (Complex) number, for example as returned byexpr.numericValue. - #69
\leqwas incorrectly parsed asEqualsinstead ofLessEqual - #94 The
\expcommand was not parsed correctly. - Handle
PlusMinusin infix and prefix position, i.e.a\pm band\pm a. - Improved parsing, serialization
- Improved simplification
- Improved evaluation of
SumandProduct - Support complex identifiers (i.e. non-latin scripts, emojis).
- Fixed serialization of mixed numbers.
0.12.1 2022-12-01
Work around unpckg.com issue with libraries using BigInt.
0.12.0 2022-11-27
Breaking Changes
- The
expr.symbolsproperty return an array ofstring. Previously it returned an array ofBoxedExpression.
Improvements
- Rewrote the rational computation engine to use JavaScript
bigintinstead ofDecimalinstances. Performance improvements of up to 100x. expr.freeVarsprovides the free variables in an expression.- Improved performance of prime factorization of big num by x100.
- Added
["RandomExpression"] - Improved accuracy of some operations, for example
expr.parse("1e999 + 1").simplify()
Issues Resolved
- When
ce.numericMode === "auto", square roots of negative numbers would return an expression instead of a complex number. - The formatting of LaTeX numbers when using
ce.latexOptions.notation = "engineering"or"scientific"was incorrect. - The trig functions no longer "simplify" to the less simple exponential formulas.
- The canonical order of polynomials now orders non-lexicographic terms of degree 1 last, i.e. "ax^2+ bx+ c" instead of "x + ax^2 + bx".
- Fixed evaluation of inverse functions
- Fixed
expr.isLess,expr.isGreater,expr.isLessEqual,expr.isGreaterEqualand["Min"],["Max"]
0.11.0 2022-11-18
Breaking Changes
- The signature of
ce.defineSymbol(),ce.defineFunction()andce.pushScope()have changed
Improvements
- When a constant should be held or substituted with its value can now be more
precisely controlled. The
holdsymbol attribute is nowholdUntiland can specify at which stage the substitution should take place.
Issues Resolved
- Some constants would return a value as bignum or complex even when the
numericModedid not allow it. - Changing the value or domain of a symbol is now correctly taken into account.
Changes can be made with
ce.assume(),ce.set()orexpr.value. - When a symbol does not have a value associated with it, assumptions about it (e.g. "x > 0") are now correctly tracked and reflected.
0.10.0 2022-11-17
Breaking Changes
expr.isLiteralhas been removed. Useexpr.numericValue !== nullandexpr.string !== nullinstead.
Issues Resolved
- Calling
ce.forget()would not affect expressions that previously referenced the symbol.
Improvements
- More accurate calculations of some trig functions when using bignums.
- Improved performance when changing a value with
ce.set(). Up to 10x faster when evaluating a simple polynomial in a loop. ce.strictcan be set tofalseto bypass some domain and validity checks.
0.9.0 2022-11-15
Breaking Changes
- The head of a number expression is always
Number. Useexpr.domainto be get more specific info about what kind of number this is. - By default,
ce.box()andce.parse()return a canonical expression. A flag can be used if a non-canonical expression is desired. - The API surface of
BoxedExpressionhas been reduced. The propertiesmachineValue,bignumValue,asFloat,asSmallInteger,asRationaletc... have been replaced with a singlenumericValueproperty. parseUnknownSymbolis nowparseUnknownIdentifier
Improvements
-
Support angles in degrees with
30\degree,30^\circand\ang{30}. -
More accurate error expressions, for example if there is a missing closing delimiter an
["Error", ["ErrorCode", "'expected-closing-delimiter'", "')'"]]is produced. -
["Expand"]handles more cases -
The trig functions can now have a regular exponent, i.e.
\cos^2(x)in addition to-1for inverse, and a combination of\prime,\doubleprimeand'for derivatives. -
ce.assume()handle more expressions and can be used to define new symbols by domain or value. -
Better error message when parsing, e.g.
\sqrt(2)(instead of\sqrt{2}) -
Better simplification for square root expressions:
\sqrt{25x^2}->5x
-
Improved evaluation of
["Power"]expressions, including for negative arguments and non-integer exponents and complex arguments and exponents. -
Added
Arccot,Arcoth,Arcsch,Arcscc,ArsechandArccsc -
expr.solve()returns result for polynomials of order up to 2. -
The
pattern.match()function now work correctly for commutative functions, i.e.ce.pattern(['Add', '_a', 'x']).match(ce.parse('x+y')) -> {"_a": "y"} -
Added
ce.let()andce.set()to declare and assign values to identifiers. -
Preserve exact calculations involving rationals or square root of rationals.
\sqrt{\frac{49}{25}}->\frac{7}{5}
-
Addition and multiplication provide more consistent results for
evaluate()andN(). Evaluate returns an exact result when possible.- EXACT
- 2 + 5 -> 7
- 2 + 5/7 -> 19/7
- 2 + √2 -> 2 + √2
- 2 + √(5/7) -> 2 + √(5/7)
- 5/7 + 9/11 -> 118/77
- 5/7 + √2 -> 5/7 + √2
- 10/14 + √(18/9) -> 5/7 + √2
- √2 + √5 -> √2 + √5
- √2 + √2 -> 2√2
- sin(2) -> sin(2)
- sin(π/3) -> √3/2
- APPROXIMATE
- 2 + 2.1 -> 4.1
- 2 + √2.1 -> 3.44914
- 5/7 + √2.1 -> 2.16342
- sin(2) + √2.1 -> 2.35844
- EXACT
-
More consistent behavior of the
autonumeric mode: calculations are done withbignumandcomplexin most cases. -
JsonSerializationOptionshas a new option to specify the numeric precision in the MathJSON serialization. -
Shorthand numbers can now be strings if they do not fit in a float-64:
// Before
["Rational", { "num": "1234567890123456789"}, { "num": "2345678901234567889"}]
// Now
["Rational", "1234567890123456789", "2345678901234567889"]
\sumis now correctly parsed and evaluated. This includes creating a local scope with the index and expression value of the sum.
Bugs Fixed
- The parsing and evaluation of log functions could produce unexpected results
- The
\gammacommand now correctly maps to["Gamma"] - Fixed numeric evaluation of the
["Gamma"]function when using bignum - #57 Substituting
0(i.e. withexpr.subs({})) did not work. - #60 Correctly parse multi-char symbols with underscore, i.e.
\mathrm{V_a} - Parsing a number with repeating decimals and an exponent would drop the exponent.
- Correct calculation of complex square roots
\sqrt{-49}->7i
- Calculations were not always performed as bignum in
"auto"numeric mode if the precision was less than 15. Now, if the numeric mode is"auto", calculations are done as bignum or complex numbers. - If an identifier contained multiple strings of digits, it would not be
rendered to LaTeX correctly, e.g.
V20_20. - Correctly return
isRealfor real numbers
0.8.0 2022-10-02
Breaking Changes
-
Corrected the implementation of
expr.toJSON(),expr.valueOf()and added the esoteric[Symbol.toPrimitive]()method. These are used by JavaScript when interacting with other primitive types. A major change is thatexpr.toJSON()now returns anExpressionas an object literal, and not a string serialization of theExpression. -
Changed from "decimal" to "bignum". "Decimal" is a confusing name, since it is used to represent both integers and floating point numbers. Its key characteristic is that it is an arbitrary precision number, aka "bignum". This affects
ce.numericModewhich now usesbignuminstead ofdecimal,expr.decimalValue->expr.bignumValue,decimalValue()->bignumValue()
Bugs Fixed
- Numerical evaluation of expressions containing complex numbers when in
decimalorautomode produced incorrect results. Example:e^{i\\pi}
0.7.0 2022-09-30
Breaking Changes
- The
ce.latexOptions.preserveLatexdefault value is nowfalse - The first argument of the
["Error"]expression (default value) has been dropped. The first argument is now an error code, either as a string or an["ErrorCode"]expression.
Features
- Much improved LaTeX parser, in particular when parsing invalid LaTeX. The
parser now avoids throwing, but will return a partial expression with
["Error"]subexpressions indicating where the problems were. - Implemented new domain computation system (similar to type systems in programming languages)
- Added support for multiple signatures per function (ad-hoc polymorphism)
- Added
FixedPoint,Loop,Product,Sum,Break,Continue,Block,If,Let,Set,Function,Apply,Return - Added
Min,Max,Clamp - Parsing of
\sum,\prod,\int. - Added parsing of log functions,
\lb,\ln,\ln_{10},\ln_2, etc... - Added
expr.subexpressions,expr.getSubexpressions(),expr.errors,expr.symbols,expr.isValid. - Symbols can now be used to represent functions, i.e.
ce.box('Sin').domaincorrectly returns["Domain", "Function"]. - Correctly handle rational numbers with a numerator or denominator outside the range of a 64-bit float.
- Instead of a
Missingsymbol an["Error", "'missing'"]expression is used. - Name binding is now done lazily
- Correctly handle MathJSON numbers with repeating decimals, e.g.
1.(3). - Correctly evaluate inverse functions, e.g.
ce.parse('\\sin^{-1}(.5)).N() - Fixed some LaTeX serialization issues
Read more at Core Reference and [Arithmetic Reference] (https://cortexjs.io/compute-engine/reference/arithmetic/)
Bugs Fixed
- #43 If the input of
ce.parse()is an empty string, return an empty string forexpr.latexorexpr.json.latex: that is, ensure verbatim LaTeX round-tripping - Evaluating some functions, such as
\arccoswould result in a crash - Correctly handle parsing of multi-token decimal markers, e.g.
{,}
0.6.0 2022-04-18
Improvements
- Parse more cases of tabular environments
- Handle simplify and evaluate of inert functions by default
- Avoid unnecessary wrapping of functions when serializing LaTeX
- Parse arguments of LaTeX commands (e.g.
\vec{}) - #42 Export static
ComputeEngine.getLatexDictionary - Parse multi-character constants and variables, e.g.
\mathit{speed}and\mathrm{radius} - Parse/serialize some LaTeX styling commands:
\displaystyle,\tinyand more
0.5.0 2022-04-05
Improvements
- Correctly parse tabular content (for example in
\begin{pmatrix}...\end{pmatrix} - Correctly parse LaTeX groups, i.e.
{...} - Ensure constructible trigonometric values are canonical
- Correct and simplify evaluation loop for
simplify(),evaluate()andN(). - #41 Preserve the parsed LaTeX verbatim for top-level expressions
- #40 Correctly calculate the synthetic LaTeX metadata for numbers
- Only require Node LTS (16.14.2)
- Improved documentation, including Dark Mode support
0.4.4 2022-03-27
Improvements
- Added option to specify custom LaTeX dictionaries in
ComputeEngineconstructor expr.valueOfreturns rational numbers as[number, number]when applicable- The non-ESM builds (
compute-engine.min.js) now targets vintage JavaScript for improved compatibility with outdated toolchains (e.g. Webpack 4) and environments. The ESM build (compute-engine.min.esm.js) targets evergreen JavaScript (currently ECMAScript 2020).
0.4.3 2022-03-21
Transition Guide from 0.4.2
The API has changed substantially between 0.4.2 and 0.4.3, however adapting code to the new API is very straightforward.
The two major changes are the introduction of the BoxedExpression class and
the removal of top level functions.
Boxed Expression
The BoxedExpression class is a immutable box (wrapper) that encapsulates a
MathJSON Expression. It provides some member functions that can be used to
manipulate the expression, for example expr.simplify() or expr.evaluate().
The boxed expresson itself is immutable. For example, calling expr.simplify()
will return a new, simplified, expression, without modifying expr.
To create a "boxed" expression from a "raw" MathJSON expression, use ce.box().
To create a boxed expression from a LaTeX string, use ce.parse().
To access the "raw" MathJSON expression, use the expr.json property. To
serialize the expression to LaTeX, use the expr.latex property.
The top level functions such as parse() and evaluate() are now member
functions of the ComputeEngine class or the BoxedExpression class.
There are additional member functions to examine the content of a boxed
expression. For example, expr.symbol will return null if the expression is
not a MathJSON symbol, otherwise it will return the name of the symbol as a
string. Similarly, expr.ops return the arguments (operands) of a function,
expr.asFloat return null if the expression does not have a numeric value
that can be represented by a float, a number otherwise, etc...
Canonical Form
Use expr.canonical to obtain the canonical form of an expression rather than
the ce.format() method.
The canonical form is less aggressive in its attempt to simplify than what was
performed by ce.format().
The canonical form still accounts for distributive and associative functions,
and will collapse some integer constants. However, in some cases it may be
necessary to invoke expr.simplify() in order to get the same results as
ce.format(expr).
Rational and Division
In addition to machine floating points, arbitrary precision numbers and complex numbers, the Compute Engine now also recognize and process rational numbers.
This is mostly an implementation detail, although you may see
["Rational", 3, 4], for example, in the value of a expr.json property.
If you do not want rational numbers represented in the value of the .json
property, you can exclude the Rational function from the serialization of JSON
(see below) in which case Divide will be used instead.
Note also that internally (as a result of boxing), Divide is represented as a
product of a power with a negative exponent. This makes some pattern detection
and simplifications easier. However, when the .json property is accessed,
product of powers with a negative exponents are converted to a Divide, unless
you have included Divide as an excluded function for serialization.
Similarly, Subtract is converted internally to Add, but may be serialized
unless excluded.
Parsing and Serialization Customization
Rather than using a separate instance of the LatexSyntax class to customize
the parsing or serialization, use a ComputeEngine instance and its
ce.parse() method and the expr.latex property.
Custom dictionaries (to parse/serialize custom LaTeX syntax) can be passed as an
argument to the ComputeEngine constructor.
For more advanced customizations, use ce.latexOptions = {...}. For example, to
change the formatting options of numbers, how the invisible operator is
interpreted, how unknown commands and symbols are interpreted, etc...
Note that there are also now options available for the "serialization" to
MathJSON, i.e. when the expr.json property is used. It is possible to control
for example if metadata should be included, if shorthand forms are allowed, or
whether some functions should be avoided (Divide, Sqrt, Subtract, etc...).
These options can be set using ce.jsonSerializationOptions = {...}.
Comparing Expressions
There are more options to compare two expressions.
Previously, match() could be used to check if one expression matched another
as a pattern.
If match() returned null, the first expression could not be matched to the
second. If it returned an object literal, the two expressions matched.
The top-level match() function is replaced by the expr.match() method.
However, there are two other options that may offer better results:
expr.isSame(otherExpr)return true ifexprandotherExprare structurally identical. Structural identity is closely related to the concept of pattern matching, that is["Add", 1, "x"]and["Add", "x", 1]are not the same, since the order of the arguments is different. It is useful for example to compare some input to an answer that is expected to have a specific form.expr.isEqual(otherExpr)return true ifexprandotherExprare mathematically identical. For examplece.parse("1+1").isEqual(ce.parse("2"))will return true. This is useful if the specific structure of the expression is not important.
It is also possible to evaluate a boolean expression with a relational operator,
such as Equal:
console.log(ce.box(["Equal", expr, 2]).evaluate().symbol);
// -> "True"
console.log(expr.isEqual(ce.box(2)));
// -> true
Before / After
| Before | After |
|---|---|
expr = ["Add", 1, 2] | expr = ce.box(["Add", 1, 2]) |
expr = ce.evaluate(expr) | expr = expr.evaluate() |
console.log(expr) | console.log(expr.json) |
expr = new LatexSyntax().parse("x^2+1") | expr = ce.parse("x^2+1") |
new LatexSyntax().serialize(expr) | expr.latex |
ce.simplify(expr) | expr.simplify() |
await ce.evaluate(expr) | expr.evaluate() |
ce.N(expr) | expr.N() |
ce.domain(expr) | expr.domain |
ce.format(expr...) | expr.canonical expr.simplify() |
0.3.0 2021-06-18
Improvements
- In LaTeX, parse
\operatorname{foo}as the MathJSON symbol"foo".