You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
evaluateAsync stops yielding under N, Add, Evaluate and other lazy heads without an async handler #392
The cause seems general. These heads are lazy and have only a sync evaluate, so the async path
falls back to it, and everything below them evaluates synchronously. On main, 143 lazy operators
have no evaluateAsync; the 13 that have one are Block, If, Loop, Which, And, Or, Implies, Nand, Nor, List, Set, Sum and Product.
Ask:
Give N an evaluateAsync that forwards to its operand's evaluateAsync({ numericApproximation: true, signal }), rounding as the sync handler does for N(x, n).
The same for the lazy heads that only evaluate their operands and combine them (Add, Multiply, Evaluate, ReleaseHold, function application and so on). Perhaps a default async
handler for a lazy head that evaluates its operands, rather than a twin for each.
A test that doesn't count timer ticks: run N(Sum(…)) under evaluateAsync, abort after about 50 ms,
and expect a prompt CancellationError. It only passes when the evaluation yields.
Our use: we run evaluations in workers that write a progress heartbeat on a timer, and re-run a job
whose heartbeat stops. That needs a long evaluation to yield. We'd be glad to send the PR for N, and
for the general handler if you'd take it.
Thanks for the precise report and the table. Fixed on main: N, Add, Multiply, Evaluate and ReleaseHold now have an evaluateAsync handler that awaits their held operands, so your N(Sum(…)), Add(Sum(…), 1) and Evaluate(Sum(…)) cases reject with a CancellationError about 50 ms after the abort. N(x, p) rounds as before; two forms stay synchronous for now because they raise the engine's precision for the computation, which cannot span an await while other evaluations run: p above the engine precision minus the guard digits (p ≥ 17 at the default precision), and the goal form N(x, [p, a]).
On the general handler: the engine already classifies the lazy operators that demand every held operand, but a default "await the operands, then run the sync handler" is not safe for lazy operators that read the structure of their operand (Numerator, Interpret, and N itself before this change), so it needs an opt-in flag per operator. That is tracked in ROADMAP, together with the application of a user function, whose body statements are still evaluated synchronously. A PR for either is welcome.
Equal, NotEqual, Less, LessEqual (and so Greater and GreaterEqual, which canonicalize to them) have an evaluateAsync handler that awaits each operand in order, with the same early stop as evaluate() (a chain stops at its first False pair) and the same exact re-read of a near tie under .N(). Less(Sum(1/k^2, k, 1, 400000), 2) aborted after 50 ms now rejects with a CancellationError at about 50 ms.
A user function's body statements run with evaluateStatementsAsync, and its arguments are awaited, so f(400000) with f(n) = Σ 1/k² yields and aborts too, on the operator-definition route and on the function-value route. Two cases stay synchronous: an application of a literal whose body is already suspended on the same engine (a recursive body, or a second concurrent call), and an application with a free symbol in an argument.
A lazy operator can declare evaluatesOperands: true when its handler demands and evaluates every held operand and reads nothing else of it; the asynchronous route then awaits the operands and runs the handler on the values. IdenticallyEqual sets it.
Still open, tracked in ROADMAP.md: N(x, p) with p above the engine precision minus the guard digits (p ≥ 17 at the default precision) and the goal form N(x, [p, a]), which raise the engine's precision for the computation; and the remaining lazy operators with a synchronous handler only, which have to be audited one by one for the flag or given a twin. The issue stays open for those: a PR for them is welcome, and we are waiting on one before closing.
Third part landed on main (commits 023b725 and 03b267f): N(x, p) with p above the engine precision minus the guard digits, and the goal form N(x, [p, a]), now yield under evaluateAsync and honour the abort signal. N(Sum(1/k^2, k, 1, 400000), 30) and N(…, [10, 5]) aborted after 50 ms reject with a CancellationError at about 50 ms, where they ran to the end (4 s and 10 s).
How: the precision of the big decimals is one value for the whole module, so it cannot stay raised across an await. Instead the window of a N(x, p) call, the working precision and the requested digits, travels with the asynchronous evaluation like its effects registry and context stack, and every synchronous slice of the evaluation installs it when it starts and restores the previous values when it returns. So two evaluations at different precisions interleave on one engine and each computes with its own digits, a handler that reads options.precision sees the digits of its own call, and nothing of a window is left on the engine while an evaluation is suspended. The values are those of the synchronous handler, including a nested N(y, q) inside a wider window, and a constant such as Pi is still not cached at the raised precision.
For a user evaluateAsync handler there is one rule to know: pass your options on to the operands you evaluate (x.evaluateAsync(options), or a copy that keeps the private fields). An operand evaluation started with options of your own still inherits the window in force when it starts, but a handler that awaits before it evaluates its operand starts that evaluation outside the window.
What is left of the ask is the audit of the lazy operators that still have only a synchronous handler. It is done, in ROADMAP.md under "Some lazy heads still run synchronously under evaluateAsync": the operators that read the structure of an operand, transform it symbolically, or hand it to a synchronous solver or quadrature stay synchronous by design, since an await between operands would not interrupt their long step; a smaller group (Assign, Pipe, When, Match, FlatMap, Timing, WithRandomSeed, HoldValues) could take a twin but needs a design each. Also still synchronous: an application of a function literal whose body is already suspended on the same engine, and one with a free symbol in an argument.
All of this will be in the next release. I am closing the issue, since the reported cases and the two forms of N are done; a PR for any operator of that last group is welcome, as a new issue or against the roadmap entry.
Activity
arnog commentedon Oct 8, 2026
Thanks for the precise report and the table. Fixed on main: N, Add, Multiply, Evaluate and ReleaseHold now have an evaluateAsync handler that awaits their held operands, so your N(Sum(…)), Add(Sum(…), 1) and Evaluate(Sum(…)) cases reject with a CancellationError about 50 ms after the abort. N(x, p) rounds as before; two forms stay synchronous for now because they raise the engine's precision for the computation, which cannot span an await while other evaluations run: p above the engine precision minus the guard digits (p ≥ 17 at the default precision), and the goal form N(x, [p, a]).
On the general handler: the engine already classifies the lazy operators that demand every held operand, but a default "await the operands, then run the sync handler" is not safe for lazy operators that read the structure of their operand (Numerator, Interpret, and N itself before this change), so it needs an opt-in flag per operator. That is tracked in ROADMAP, together with the application of a user function, whose body statements are still evaluated synchronously. A PR for either is welcome.
Adopt PR #423: evaluateAsync yields under the relations and in a user…
arnog commentedon Oct 9, 2026
Second part landed on
main(commit 4ce5a90, from #423 by @enumeratio):Equal,NotEqual,Less,LessEqual(and soGreaterandGreaterEqual, which canonicalize to them) have anevaluateAsynchandler that awaits each operand in order, with the same early stop asevaluate()(a chain stops at its firstFalsepair) and the same exact re-read of a near tie under.N().Less(Sum(1/k^2, k, 1, 400000), 2)aborted after 50 ms now rejects with aCancellationErrorat about 50 ms.evaluateStatementsAsync, and its arguments are awaited, sof(400000)withf(n) = Σ 1/k²yields and aborts too, on the operator-definition route and on the function-value route. Two cases stay synchronous: an application of a literal whose body is already suspended on the same engine (a recursive body, or a second concurrent call), and an application with a free symbol in an argument.evaluatesOperands: truewhen its handler demands and evaluates every held operand and reads nothing else of it; the asynchronous route then awaits the operands and runs the handler on the values.IdenticallyEqualsets it.Still open, tracked in
ROADMAP.md:N(x, p)withpabove the engine precision minus the guard digits (p ≥ 17at the default precision) and the goal formN(x, [p, a]), which raise the engine's precision for the computation; and the remaining lazy operators with a synchronous handler only, which have to be audited one by one for the flag or given a twin. The issue stays open for those: a PR for them is welcome, and we are waiting on one before closing.arnog commentedon Oct 12, 2026
Third part landed on main (commits 023b725 and 03b267f):
N(x, p)withpabove the engine precision minus the guard digits, and the goal formN(x, [p, a]), now yield under evaluateAsync and honour the abort signal.N(Sum(1/k^2, k, 1, 400000), 30)andN(…, [10, 5])aborted after 50 ms reject with aCancellationErrorat about 50 ms, where they ran to the end (4 s and 10 s).How: the precision of the big decimals is one value for the whole module, so it cannot stay raised across an await. Instead the window of a
N(x, p)call, the working precision and the requested digits, travels with the asynchronous evaluation like its effects registry and context stack, and every synchronous slice of the evaluation installs it when it starts and restores the previous values when it returns. So two evaluations at different precisions interleave on one engine and each computes with its own digits, a handler that reads options.precision sees the digits of its own call, and nothing of a window is left on the engine while an evaluation is suspended. The values are those of the synchronous handler, including a nestedN(y, q)inside a wider window, and a constant such as Pi is still not cached at the raised precision.For a user
evaluateAsynchandler there is one rule to know: pass your options on to the operands you evaluate (x.evaluateAsync(options), or a copy that keeps the private fields). An operand evaluation started with options of your own still inherits the window in force when it starts, but a handler that awaits before it evaluates its operand starts that evaluation outside the window.What is left of the ask is the audit of the lazy operators that still have only a synchronous handler. It is done, in
ROADMAP.mdunder "Some lazy heads still run synchronously under evaluateAsync": the operators that read the structure of an operand, transform it symbolically, or hand it to a synchronous solver or quadrature stay synchronous by design, since an await between operands would not interrupt their long step; a smaller group (Assign, Pipe, When, Match, FlatMap, Timing, WithRandomSeed, HoldValues) could take a twin but needs a design each. Also still synchronous: an application of a function literal whose body is already suspended on the same engine, and one with a free symbol in an argument.All of this will be in the next release. I am closing the issue, since the reported cases and the two forms of N are done; a PR for any operator of that last group is welcome, as a new issue or against the roadmap entry.