Skip to content

evaluateAsync yields under the relations and inside a user function's body (evaluatesOperands flag) - #423

Closed
enumeratio wants to merge 1 commit into
cortex-js:mainfrom
enumeratio:async-held-operands
Closed

enumeratio wants to merge 1 commit into
cortex-js:mainfrom
enumeratio:async-held-operands

Conversation

@enumeratio

Copy link
Copy Markdown
Contributor

Part of #392. Both of these now reject about 50 ms after the abort, where before each ran to the end:

ce.assign('f', ce.box(['Function', ['Block', ['Sum', ['Divide', 1, ['Power', 'k', 2]], ['Limits', 'k', 1, 'n']]], 'n']));
const ac = new AbortController();
setTimeout(() => ac.abort(), 50);
await ce.parse('\\sum_{k=1}^{400000} \\frac{1}{k^2} < 2').evaluateAsync({ signal: ac.signal });
await ce.box(['f', 400000]).evaluateAsync({ signal: ac.signal });

evaluatesOperands, a new opt-in flag on a lazy operator definition, defaulting to false. It says the evaluate handler only evaluates its held operands and reads nothing else of them. Under evaluateAsync, an operator with the flag and no evaluateAsync handler has its held operands awaited in order, and then its synchronous handler runs on the values. selectsOperands, scoped and quoting operators never take this route.

We set the flag on Equal, NotEqual, Less, LessEqual and IdenticallyEqual. That also covers Greater and GreaterEqual, which canonicalize to Less and LessEqual. We left IsSame and Same without it because they compare structure.

There are two cases where the relations still read the operand as written, so those stay on the synchronous handler:

  • .N(): a near-tie re-reads the operand to decide exactly.
  • An operand that evaluates to Missing: its declared type decides what it means.

A chain of three or more operands awaits all of them, where evaluate() stops at the first False pair. The answer is the same, but the later operands still run.

User functions: the body statements run with evaluateStatementsAsync(), as Block does, and the arguments are awaited. This applies on both the operator-definition route and the function-value route. Two kinds of application stay synchronous, because the body's scope has room for one suspended frame:

  • a second application of a literal that's already suspended (a recursive body, or a concurrent call);
  • an application with a free symbol in an argument.

ROADMAP lists what's left: the two N forms that raise the precision, and the lazy operators we haven't audited for the flag.

…ndler (the relations) yield and abort under evaluateAsync; await the body statements of a user function; tests, CHANGELOG, ROADMAP. Part of cortex-js#392

- boxed-function: await each held operand for an operator with the flag (not under .N(), not when an operand is Missing); a user function awaits arguments and body statements
- function-utils: awaitStatements option on apply; one suspended application per literal
- relations: Equal, NotEqual, Less, LessEqual, IdenticallyEqual set the flag
- tests: evaluate-async-evaluates-operands
arnog added a commit that referenced this pull request Oct 9, 2026
… function's body

Part of #392, contributed by enumeratio. `Equal`, `NotEqual`, `Less` and
`LessEqual` get an `evaluateAsync` handler that awaits each operand in
order, with the same early stop as `evaluate()` (a chain stops at the
first `False` pair) and the same exact re-read of a near tie under
`.N()`. A user function's body statements run with
`evaluateStatementsAsync` under `evaluateAsync`, and its arguments are
awaited. A lazy operator can declare `evaluatesOperands: true` when its
handler demands and evaluates every held operand; the asynchronous route
then awaits them and runs the handler on the values. The flag is set on
`IdenticallyEqual`.

Changes to the PR after review: the chainable relations use a twin
instead of the flag, so a chain keeps its short circuit; the flag route
hands over the written operand only at absent positions; the recursion
guards release their counts at return and the in-flight set is keyed by
the body scope, so a suspended application holds back neither a
concurrent symbolic call nor another engine; the callee is read once
before the arguments; the memo store after an await is guarded by the
dependency snapshot.
@arnog

arnog commented Oct 9, 2026

Copy link
Copy Markdown
Member

Thank you, this landed on main with your authorship noted in the CHANGELOG. It could not merge as is (it conflicted with 0.151.0), so I applied the diff by hand and made a few changes after review:

  • The four chainable relations (Equal, NotEqual, Less, LessEqual) do not use evaluatesOperands. The flag awaits every operand, where evaluate() stops a chain at its first False pair, so 3 < 2 < X would have run X. They have an evaluateAsync twin instead (chainedRelationHandlers in relational-operator.ts) with the same early stop and the same exact re-read at a near tie, so .N() takes the asynchronous route too. The flag stays on IdenticallyEqual.
  • On the flag route, an absent value no longer hands every operand back as written: only the absent positions keep the written operand, so a long neighbour is not evaluated twice.
  • guardSymbolicRecursion and wrapRecursion do not hold their counts across the await. A held count made a concurrent f(y) throw SymbolicRecursion out of evaluate(). The in-flight set is keyed by the body scope rather than the literal's hash, so an identical literal on another engine still yields.
  • The callee literal is read once, before the arguments are awaited.
  • The memo store after the await checks the dependency snapshot taken before it, so a value reassigned by a concurrent evaluation does not get a stale result memoized.

The remaining items you listed (the two N forms that raise the precision, the operators not yet audited for the flag) stay in ROADMAP.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants