Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,38 @@
## Unreleased

### Issues Resolved

- **`evaluateAsync` yields under the relations**
([#392](https://github.com/cortex-js/compute-engine/issues/392), contributed by
[enumeratio](https://github.com/enumeratio)). `Less(Sum(1/k^2, k, 1, 400000), 2)`
aborted after 50 ms still ran to the end, seconds later, with no event-loop
tick: a lazy operator with a synchronous handler evaluates its held operands
itself. An operator can now declare `evaluatesOperands: true`, meaning its
handler only evaluates its held operands and reads nothing else of their
structure; under `evaluateAsync` the operands are then awaited with
`evaluateAsync`, in order, and the handler runs on the values. The flag is set
on `Equal`, `NotEqual`, `Less`, `LessEqual` (and so `Greater` and
`GreaterEqual`, which canonicalize to them) and `IdenticallyEqual`, so the
aborted evaluation above rejects with a `CancellationError` after about 50 ms.
The values are those of `evaluate()`: an undecidable `x^2 = 2 + 2` is still
`x^2 == 4`. A lazy operator that reads the structure of an operand
(`Numerator`, `IsSame`, `Same`) does not set the flag, and `.N()` keeps the
synchronous handler (a comparison near a tie re-reads the operand as
written). A chain of three or more operands awaits them all, where
`evaluate()` stops at the first `False` pair.

- **`evaluateAsync` yields in the body of a user function**
([#392](https://github.com/cortex-js/compute-engine/issues/392), contributed by
[enumeratio](https://github.com/enumeratio)). With `f(n) = Σ 1/k²`,
`f(400000)` aborted after 50 ms ran to the end: the body statements were
evaluated with the synchronous `evaluate()`. Under `evaluateAsync` they are now
awaited in order (`evaluateStatementsAsync`, as `Block` does) and so are the
arguments, so `f(400000)` rejects with a `CancellationError` after about 50
ms, and the values are those of `evaluate()`. An application of a literal
that is already suspended (a recursive body, or a second call from another
evaluation) and an application whose argument has a free symbol still run
synchronously. `evaluate()` is unchanged.

## 0.150.0 _2026-10-08_

### Behavior Changes
Expand Down
70 changes: 40 additions & 30 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Compute Engine — Roadmap

**Last updated:** 2026-09-30.
**Last updated:** 2026-10-08.

This document tracks **remaining** work; an item leaves this file once it lands.
Detail on completed work lives in git history, `CHANGELOG.md`, the linked source
Expand Down Expand Up @@ -109,35 +109,45 @@ below for current scores and next rungs (per-rung history in `docs/rubi/RUBI.md`

## Remaining work

### A user function, a relation and most other lazy heads still run their operands synchronously under `evaluateAsync` (OPEN, design decision — issue #392, found 2026-10-07)

`N`, `Add`, `Multiply`, `Evaluate` and `ReleaseHold` now have an
asynchronous twin that evaluates the held operands with `evaluateAsync`, so
a `Sum` under them yields and honours an abort signal. The other lazy
operators with a synchronous handler only (about 140, among them the nine
relations `Equal`, `Less`, …, and the application of a user function, whose
body statements are evaluated by `applyFunctionLiteral()` in
`boxed-function.ts` with the synchronous `evaluateStatements()`) still run
their operands to the end: `Less(Sum(1/k^2, k, 1, 400000), 2)` and `f(400000)`
with `f(n) = Σ 1/k²` do not yield and cannot be aborted. Two forms of `N`
block too: `N(x, p)` with `p` above the precision of the engine minus the
guard digits (`p ≥ 17` on a default engine), and a goal `N(x, [p, a])`,
because `_withTransientPrecision` raises the precision of the engine, a
global of the bignum library, for the time of the computation, and the
raised precision cannot span an `await` while other evaluations run; a
per-evaluation working precision would remove that limit. The asynchronous
route already has a classification of the lazy operators that DEMAND every
held operand (not `selectsOperands`, not `scoped`, not `holdClass:
'quote'`), used to await asynchronous-only descendants
(`awaitAsyncOnlyDescendants()`). Decision needed: give that class a default
asynchronous behavior (await each held operand with `evaluateAsync`, then
run the synchronous handler on the values), or add twins one by one. The
default is not safe as it stands: a lazy operator that reads the structure
of its operand (`Numerator`, `Denominator`, `Interpret`, `N` before its
twin) must not receive an evaluated operand, so the class would need a flag
that says "the handler only evaluates its operands". The function body
needs its own asynchronous application (`evaluateStatementsAsync()` exists
and is used by `Block`).
### Some lazy heads and two forms of `N` still run their operands synchronously under `evaluateAsync` (OPEN, issue #392, found 2026-10-07)

`N`, `Add`, `Multiply`, `Evaluate` and `ReleaseHold` have an asynchronous twin
that evaluates the held operands with `evaluateAsync`, so a `Sum` under them
yields and honours an abort signal. The decision for the other lazy operators
with a synchronous handler (about 140) is made: an operator opts in with
`evaluatesOperands` ("the handler only evaluates its held operands and reads
nothing else of their structure"), and the asynchronous route then awaits each
held operand in order and runs the handler on the values. A default for the whole
class of operators that demand every operand is not safe: `Numerator`,
`Denominator` and `Interpret` read the structure of the operand. The flag is set
on `Equal`, `NotEqual`, `Less`, `LessEqual` and `IdenticallyEqual`, and the
application of a user function awaits its body statements and its arguments
(`evaluateStatementsAsync()`), so `Less(Sum(1/k^2, k, 1, 400000), 2)` and
`f(400000)` with `f(n) = Σ 1/k²` now yield. What remains:

- The two forms of `N` that block: `N(x, p)` with `p` above the precision of
the engine minus the guard digits (`p ≥ 17` on a default engine), and a goal
`N(x, [p, a])`, because `_withTransientPrecision` raises the precision of the
engine, a global of the bignum library, for the time of the computation, and
the raised precision cannot span an `await` while other evaluations run. A
per-evaluation working precision would remove that limit.
- The operators that have not been audited for the flag. Each handler has to be
read: it must demand every operand and evaluate it, and it must not read the
operand as written. The relations read it in two places, which the route
accounts for (an operand that evaluates to `Missing` is handed over as
written, because its meaning can depend on the declared type of the operand;
and `.N()` keeps the synchronous handler, because a comparison near a tie
re-reads the operand to decide it exactly). A chain `a < b < c` stops at the
first `False` pair under `evaluate()` and, under the flag, awaits every
operand first: the answer is the same, the later operands run for nothing.
`Greater` and `GreaterEqual` have no handler of their own (they canonicalize
to `Less` and `LessEqual`), `IsSame` and `Same` compare structure, and the
remaining relations are not lazy.
- A user function whose application is already suspended (a recursive body, or
a second call of the literal from another evaluation) and one with a free
symbol in an argument run synchronously, because the scope of the body has
room for one suspended frame. A recursive function yields in the statements of
its outermost call only.

### `.N()` of a user function evaluates the body exactly, then numericizes the result (OPEN, performance — found 2026-10-07 by issue #392)

Expand Down
1 change: 1 addition & 0 deletions src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5500,6 +5500,7 @@ type OperatorDefinitionFlags = {
threadsConditionals: boolean | number[];
inspectsErrors: boolean;
selectsOperands: boolean;
evaluatesOperands: boolean;
namedArgumentsRequired: boolean;
missingBehavior: "reject" | "propagate" | "handle";
missingStrip: "all" | number[];
Expand Down
127 changes: 117 additions & 10 deletions src/compute-engine/boxed-expression/boxed-function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5998,7 +5998,7 @@ export class BoxedFunction
//

if (isValueDef(this._def))
return applyFunctionLiteral(this, this._def.value, options);
return applyFunctionLiteralAsync(this, this._def.value, options);

const def = this._def.operator;

Expand Down Expand Up @@ -6858,15 +6858,42 @@ export class BoxedFunction
// one that SCOPES them (`Block`, a big operator) orders and binds
// their evaluation itself; one that QUOTES them holds data. An
// `evaluateAsync` handler awaits its own operands.
const handlerOps =
this.engine._hasAsyncOnlyOperator &&
const demandsOperands =
def.lazy === true &&
def.evaluateAsync === undefined &&
!def.selectsOperands &&
!def.scoped &&
def.holdClass !== 'quote'
? await awaitAsyncOnlyDescendants(tail, options)
: tail;
def.holdClass !== 'quote';
// An operator whose handler only evaluates its operands
// (`evaluatesOperands`) has them evaluated here, in order, so that a
// long operand yields and honors the abort signal; the handler then
// evaluates values. Not under `.N()`: a comparison near a tie
// re-reads the operand as written to decide it exactly.
let handlerOps: ReadonlyArray<Expression> = tail;
let operandsEvaluated = false;
if (
demandsOperands &&
def.evaluatesOperands &&
def.evaluate !== undefined &&
!numericApproximation
) {
const operandOpts = {
...operandOptions(options),
_effects: effects,
_contextStack: contextStack,
};
const values: Expression[] = [];
for (const x of tail) values.push(await x.evaluateAsync(operandOpts));
// What an absent value (`Missing`) means can depend on the declared
// type of the operand it came from (`x: number | missing` reads as
// `NaN`), which only the operand as written carries: the handler
// then gets the operands as written, as on the synchronous route.
if (!values.some(isAbsentScalarSymbol)) {
handlerOps = values;
operandsEvaluated = true;
}
} else if (this.engine._hasAsyncOnlyOperator && demandsOperands)
handlerOps = await awaitAsyncOnlyDescendants(tail, options);
// The handler call itself is synchronous (an `evaluateAsync` handler
// returns its promise at its first `await`). For that synchronous
// part, publish this evaluation's registry in the engine slot, so
Expand All @@ -6879,10 +6906,32 @@ export class BoxedFunction
value = await runWithEvaluationEffects(
engine,
effects,
() =>
def.evaluateAsync?.(tail, opts) ?? def.evaluate?.(handlerOps, opts),
() => {
if (def.evaluateAsync !== undefined)
return def.evaluateAsync(tail, opts);
// A user function: the body statements are awaited.
const applyAwaiting = (
def.evaluate as
| {
applyAwaiting?: (
xs: ReadonlyArray<Expression>,
o: typeof opts
) =>
Promise<Expression | undefined> | Expression | undefined;
}
| undefined
)?.applyAwaiting;
return applyAwaiting !== undefined
? applyAwaiting(handlerOps, opts)
: def.evaluate?.(handlerOps, opts);
},
contextStack
);
// A handler that answers `undefined` leaves the operator as it is;
// over evaluated operands that is the operator over the values, as
// an inert comparison answers on the synchronous route.
if (value === undefined && operandsEvaluated)
value = engine._fn(this._operator, [...handlerOps]);
} catch (e) {
value = handlerThrowToErrorValue(this.engine, e, def, this._operator);
} finally {
Expand Down Expand Up @@ -8934,11 +8983,68 @@ function declaredScalarConformance(
);
}

/**
* The asynchronous route of {@link applyFunctionLiteral}: the arguments are
* awaited with `evaluateAsync`, and the body of the function literal runs
* its statements with `evaluateStatementsAsync` (`awaitStatements`), so that
* `f(400000)` with `f(n) = Σ 1/k²` yields and honors the abort signal. Every
* other step (broadcast, conformance, currying) is the synchronous one.
*/
async function applyFunctionLiteralAsync(
expr: BoxedFunction,
def: BoxedValueDefinition,
options?: Partial<EvaluateOptions>
): Promise<Expression> {
const engine = expr.engine;
// This application keeps its frames on its own copy of the context stack,
// as a scoped operator does (step 4 of `evaluateAsync`).
const effects =
options?._effects ?? engine._evaluationEffects ?? engine.effects;
const contextStack = [
...(options?._contextStack ?? engine._evalContextStack),
];
const carried = {
...options,
_effects: effects,
_contextStack: contextStack,
};
const value = def.isConstant
? def.value
: engine._getSymbolValue(expr.operator);
let ops: Expression[] | undefined;
if (value && value.type.matches('function') && !value.type.isUnknown) {
ops = [];
for (const x of expr.ops)
ops.push(await x.evaluateAsync(operandOptions(carried)));
}
return await runWithEvaluationEffects(
engine,
effects,
() => applyFunctionLiteralWith(expr, def, carried, ops, true),
contextStack
);
}

function applyFunctionLiteral(
expr: BoxedFunction,
def: BoxedValueDefinition,
options?: Partial<EvaluateOptions>
): Expression {
return applyFunctionLiteralWith(expr, def, options) as Expression;
}

/**
* `ops` are the arguments when the caller has evaluated them already. With
* `awaitBody`, the final application of the literal may answer a `Promise`
* (`ApplyOptions.awaitStatements`); no other step does.
*/
function applyFunctionLiteralWith(
expr: BoxedFunction,
def: BoxedValueDefinition,
options: Partial<EvaluateOptions> | undefined,
evaluatedOps?: Expression[],
awaitBody = false
): Expression | Promise<Expression> {
const value = def.isConstant
? def.value
: expr.engine._getSymbolValue(expr.operator);
Expand All @@ -8952,7 +9058,8 @@ function applyFunctionLiteral(
// (`operandOptions`), so a body that reads a lazy argument as a collection
// reads the whole collection, not its display preview. The body itself
// keeps the caller's options, since it produces the result.
const ops = expr.ops.map((x) => x.evaluate(operandOptions(options)));
const ops =
evaluatedOps ?? expr.ops.map((x) => x.evaluate(operandOptions(options)));
if (!value || value.type.isUnknown) {
// The cached `_def` may be a function-typed *value* placeholder (created
// by the `Assign`/`Declare` canonical pass, e.g. a block-local one-step
Expand Down Expand Up @@ -9233,7 +9340,7 @@ function applyFunctionLiteral(
return apply(
value,
ops,
options,
awaitBody ? { ...options, awaitStatements: true } : options,
'bubble',
expr._withParseScope(() => expr.engine.function(expr.operator, ops))
);
Expand Down
Loading
Loading