diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a0929c3..34d8df8f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 0e08f1531..2ef4e7c07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 @@ -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) diff --git a/src/api.md b/src/api.md index b6533180a..a4fb8e3bd 100644 --- a/src/api.md +++ b/src/api.md @@ -5500,6 +5500,7 @@ type OperatorDefinitionFlags = { threadsConditionals: boolean | number[]; inspectsErrors: boolean; selectsOperands: boolean; + evaluatesOperands: boolean; namedArgumentsRequired: boolean; missingBehavior: "reject" | "propagate" | "handle"; missingStrip: "all" | number[]; diff --git a/src/compute-engine/boxed-expression/boxed-function.ts b/src/compute-engine/boxed-expression/boxed-function.ts index 707f7dc22..20ea8b190 100644 --- a/src/compute-engine/boxed-expression/boxed-function.ts +++ b/src/compute-engine/boxed-expression/boxed-function.ts @@ -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; @@ -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 = 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 @@ -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, + o: typeof opts + ) => + Promise | 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 { @@ -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 +): Promise { + 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 ): 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 | undefined, + evaluatedOps?: Expression[], + awaitBody = false +): Expression | Promise { const value = def.isConstant ? def.value : expr.engine._getSymbolValue(expr.operator); @@ -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 @@ -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)) ); diff --git a/src/compute-engine/boxed-expression/boxed-operator-definition.ts b/src/compute-engine/boxed-expression/boxed-operator-definition.ts index 55a134699..720b7049d 100644 --- a/src/compute-engine/boxed-expression/boxed-operator-definition.ts +++ b/src/compute-engine/boxed-expression/boxed-operator-definition.ts @@ -57,7 +57,8 @@ import type { TypeProvenanceEntry, } from '../global-types.js'; -import { applicable } from '../function-utils.js'; +import { applicable, type ApplyOptions } from '../function-utils.js'; +import { runWithEvaluationEffects } from '../effects-registry.js'; import { DEFAULT_COMPLEXITY } from './constants.js'; import { isExpression, isFunction } from './type-guards.js'; @@ -108,6 +109,7 @@ const OPERATOR_DEF_KEYS = new Set([ 'threadsConditionals', 'inspectsErrors', 'selectsOperands', + 'evaluatesOperands', 'namedArgumentsRequired', 'missingBehavior', 'missingStrip', @@ -307,6 +309,7 @@ export class _BoxedOperatorDefinition implements BoxedOperatorDefinition { inspectsErrors = false; selectsOperands = false; + evaluatesOperands = false; namedArgumentsRequired = false; missingBehavior?: 'reject' | 'propagate' | 'handle'; missingStrip: 'all' | number[] = 'all'; @@ -1582,6 +1585,7 @@ export class _BoxedOperatorDefinition implements BoxedOperatorDefinition { threadsConditionals: this.threadsConditionals, inspectsErrors: this.inspectsErrors, selectsOperands: this.selectsOperands, + evaluatesOperands: this.evaluatesOperands, namedArgumentsRequired: this.namedArgumentsRequired, missingBehavior: this.missingBehavior, missingStrip: this.missingStrip, @@ -1654,6 +1658,7 @@ export class _BoxedOperatorDefinition implements BoxedOperatorDefinition { this.threadsConditionals = s.threadsConditionals ?? false; this.inspectsErrors = s.inspectsErrors; this.selectsOperands = s.selectsOperands; + this.evaluatesOperands = s.evaluatesOperands ?? false; this.namedArgumentsRequired = s.namedArgumentsRequired; this.missingBehavior = s.missingBehavior; this.missingStrip = s.missingStrip; @@ -1837,6 +1842,13 @@ export class _BoxedOperatorDefinition implements BoxedOperatorDefinition { !this.selectsOperands || this.lazy, `Operator Definition "${this.name}": 'selectsOperands' requires 'lazy'` ); + this.evaluatesOperands = def.evaluatesOperands ?? this.evaluatesOperands; + // A strict operator's operands are already evaluated when its handler + // runs, so there is nothing left for the flag to do. + console.assert( + !this.evaluatesOperands || this.lazy, + `Operator Definition "${this.name}": 'evaluatesOperands' requires 'lazy'` + ); this.namedArgumentsRequired = def.namedArgumentsRequired ?? this.namedArgumentsRequired; this.missingBehavior = def.missingBehavior ?? this.missingBehavior; @@ -2324,6 +2336,35 @@ export class _BoxedOperatorDefinition implements BoxedOperatorDefinition { Object.defineProperty(evaluate, 'toString', { value: () => boxedFn.toString(), }); // For debugging/_printScope + // The asynchronous route applies the literal with its statements + // awaited (`ApplyOptions.awaitStatements`), on a private copy of the + // evaluation-context stack: the frame of the call stays on it while + // the application is suspended, and an application of another + // evaluation does not see it. It travels with the handler, so a + // handler that replaces this one takes it away. + Object.defineProperty(evaluate, 'applyAwaiting', { + value: (xs: ReadonlyArray, options: ApplyOptions) => { + const engine = this.engine; + const effects = + options._effects ?? engine._evaluationEffects ?? engine.effects; + const stack = [ + ...(options._contextStack ?? engine._evalContextStack), + ]; + const carried: ApplyOptions = { + ...options, + _effects: effects, + _contextStack: stack, + awaitStatements: true, + }; + return runWithEvaluationEffects( + engine, + effects, + () => + fn(xs, this.lazy ? { ...carried, holdArguments: true } : carried), + stack + ); + }, + }); } else if (typeof def.evaluate === 'function') { evaluate = def.evaluate; } else { diff --git a/src/compute-engine/function-utils.ts b/src/compute-engine/function-utils.ts index 78aaeaeb2..e16717163 100644 --- a/src/compute-engine/function-utils.ts +++ b/src/compute-engine/function-utils.ts @@ -27,6 +27,7 @@ import type { Scope, } from './global-types.js'; import type { EffectHandlers } from './types-effects.js'; +import { runWithEvaluationEffects } from './effects-registry.js'; import { isSymbol, isFunction, @@ -1895,6 +1896,16 @@ export type ApplyOptions = Partial & { * binder's own. Only meaningful with `holdArguments`. */ bindParameters?: readonly string[]; + /** + * Run the body statements with `evaluateStatementsAsync()`, so that the + * application returns a `Promise` that yields between time slices and + * honors the abort signal. Set by the asynchronous route of a named + * function (`applyFunctionLiteralAsync`) and by nothing else, together + * with the `_effects` and `_contextStack` of that evaluation. Declined + * (the application is synchronous) when another application of the same + * literal is already suspended, or when an argument has a free symbol. + */ + awaitStatements?: boolean; }; /** @@ -2937,6 +2948,16 @@ class SymbolicRecursion { */ const activeApplications = new Map(); +/** + * The literals (by structural hash) with an application suspended at an + * `await` (`ApplyOptions.awaitStatements`). A suspended application leaves + * the body scope re-parented to its call frame, and the scope has room for one + * such frame: a second application of the literal, from a concurrent + * evaluation or from a recursive call in the body, runs synchronously, to + * completion, inside it. + */ +const asyncInFlight = new Set(); + /** * Does an evaluated argument contain a free symbol — a symbol occurrence, * not bound by a binder inside the argument, whose binding has no value? That @@ -3031,15 +3052,30 @@ function guardSymbolicRecursion( return (params, options) => { const depth = activeApplications.get(key) ?? 0; activeApplications.set(key, depth + 1); + let pending = false; try { - return fn(params, options); + const result = fn(params, options); + // A suspended application keeps its count until it settles. Its + // arguments are ground (`awaitStatements`), so it raises no + // `SymbolicRecursion` of its own. + if (isPromise(result)) { + pending = true; + return result.finally(() => { + const live = (activeApplications.get(key) ?? 1) - 1; + if (live <= 0) activeApplications.delete(key); + else activeApplications.set(key, live); + }) as unknown as Expression; + } + return result; } catch (e) { if (e instanceof SymbolicRecursion && e.literal === key && depth === 0) return undefined; throw e; } finally { - if (depth === 0) activeApplications.delete(key); - else activeApplications.set(key, depth); + if (!pending) { + if (depth === 0) activeApplications.delete(key); + else activeApplications.set(key, depth); + } } }; } @@ -3056,14 +3092,28 @@ function wrapRecursion( ) => Expression | undefined { return (params, options) => { ce._enterRecursion(); + let pending = false; try { - return fn(params, options); + const result = fn(params, options); + // A suspended application (`awaitStatements`) holds its level until it + // settles. + if (isPromise(result)) { + pending = true; + return result.finally(() => + ce._exitRecursion() + ) as unknown as Expression; + } + return result; } finally { - ce._exitRecursion(); + if (!pending) ce._exitRecursion(); } }; } +function isPromise(x: unknown): x is Promise { + return x instanceof Promise; +} + /** * The scopes that the scoped subexpressions nested inside a function * literal's body own: a `Loop` or `Comprehension` scope (its index @@ -4310,11 +4360,10 @@ function makeLambda( // The exact answer of a numerically requested application, kept for // the memo (see the numeric pass below). let exactResult: Expression | undefined = undefined; - try { - result = unwrapReturn( - ce, - evaluateStatements(ce, iterationStatements ?? bodyFn.ops) - ); + // What follows the statements runs the same on both routes: `settle` in + // the frame, `cleanup` when the frame leaves, `conclude` after. + const settle = (statements: Expression): void => { + result = unwrapReturn(ce, statements); // A function body whose final value is a *bare symbol* bound to a // user-defined function literal (`helper(x) = …`, which creates an @@ -4409,33 +4458,80 @@ function makeLambda( numericApproximation: true, }); } - } finally { + }; + const cleanup = (): void => { ce.popScope(); bodyScope.parent = savedParent; exitAppliedBodyScope(bodyScope); restoreBodyScopeParams(bodyScope, hiddenBindings); exitNestedBodyScopes(ce, nested); - } + }; + const conclude = (): Expression => { + if ( + memoKey !== undefined && + sequenceReadStopCount() === sequenceStopsBefore + ) { + // The exact entry is stored whether or not the numeric pass produced + // a valid result: the exact answer is what a recursive body, which + // applies itself exactly, looks up. + const exactEntry = exactResult?.isValid ? exactResult : undefined; + const resultEntry = result.isValid ? result : undefined; + if (exactEntry !== undefined || resultEntry !== undefined) { + const memo = applicationMemoForStore(ce, fnExpr); + if (memo !== undefined) { + if (exactEntry !== undefined) + memo.results.set(`E${precisionKey}${memoArgs}`, exactEntry); + if (resultEntry !== undefined) + memo.results.set(memoKey, resultEntry); + } + } + } + return bodyResultValue(result); + }; + // Await the statements (`awaitStatements`). Everything after the first + // `await` runs with this evaluation's registry and context stack in + // place of the engine's: other evaluations run in between. A recursive + // body is not entered twice (`asyncInFlight`), nor an application with a + // free symbol in an argument, whose recursion guard is the synchronous + // one above. if ( - memoKey !== undefined && - sequenceReadStopCount() === sequenceStopsBefore + options?.awaitStatements === true && + options._effects !== undefined && + iterationStatements === undefined && + !asyncInFlight.has(fnExpr.hash) && + !evaluatedArgs.some((a) => containsFreeSymbol(a)) ) { - // The exact entry is stored whether or not the numeric pass produced - // a valid result: the exact answer is what a recursive body, which - // applies itself exactly, looks up. - const exactEntry = exactResult?.isValid ? exactResult : undefined; - const resultEntry = result.isValid ? result : undefined; - if (exactEntry !== undefined || resultEntry !== undefined) { - const memo = applicationMemoForStore(ce, fnExpr); - if (memo !== undefined) { - if (exactEntry !== undefined) - memo.results.set(`E${precisionKey}${memoArgs}`, exactEntry); - if (resultEntry !== undefined) memo.results.set(memoKey, resultEntry); + const { signal, _effects: effects, _contextStack: stack } = options; + const inContext = (f: () => T): T => + runWithEvaluationEffects(ce, effects, f, stack); + asyncInFlight.add(fnExpr.hash); + return (async () => { + try { + const statements = await evaluateStatementsAsync( + ce, + bodyFn.ops, + signal, + effects, + stack + ); + inContext(() => settle(statements)); + } finally { + inContext(cleanup); + asyncInFlight.delete(fnExpr.hash); } - } + return inContext(conclude); + // The lambda is typed synchronous: a `Promise` comes back only for + // the caller that set `awaitStatements`. + })() as unknown as Expression; + } + + try { + settle(evaluateStatements(ce, iterationStatements ?? bodyFn.ops)); + } finally { + cleanup(); } - return bodyResultValue(result); + return conclude(); }; return wrapRecursion(ce, guardSymbolicRecursion(fnExpr, invoke)); diff --git a/src/compute-engine/library/relational-operator.ts b/src/compute-engine/library/relational-operator.ts index 3295f646d..564ded18a 100644 --- a/src/compute-engine/library/relational-operator.ts +++ b/src/compute-engine/library/relational-operator.ts @@ -471,6 +471,7 @@ export const RELOP_LIBRARY: SymbolDefinitions = { signature: '(any, any) -> boolean', lazy: true, + evaluatesOperands: true, // Comparisons follow IEEE 754 for `NaN` and Kleene for the `Missing` symbol // (the Julia model, §3.D amended 2026-07-24). A `Missing` operand makes the @@ -687,6 +688,7 @@ export const RELOP_LIBRARY: SymbolDefinitions = { // `lazy` for the same reason as `Equal`: the `canonical` handler needs the // raw, direction-intact operands to decompose a chain (`a ≡ b ≡ c`). lazy: true, + evaluatesOperands: true, // Same absence semantics as `Equal` (§3.D): a `Missing` operand makes the // comparison `Missing` (Kleene), a `NaN` operand makes it `False` (IEEE). @@ -829,6 +831,7 @@ export const RELOP_LIBRARY: SymbolDefinitions = { // for chain decomposition (see `canonicalComparisonChain`); a chained // `a ≠ b ≠ c` becomes `And(a ≠ b, b ≠ c)`. lazy: true, + evaluatesOperands: true, canonical: (args, { engine: ce }) => canonicalRelational(ce, 'NotEqual', args), @@ -964,6 +967,7 @@ export const RELOP_LIBRARY: SymbolDefinitions = { ), lazy: true, + evaluatesOperands: true, // Broadcast element-wise over a list operand so `L > 0` (canonicalizes to // `Less(0, L)`) yields a `list` mask for Desmos `L[L>0]` filtering. broadcastable: true, @@ -1100,6 +1104,7 @@ export const RELOP_LIBRARY: SymbolDefinitions = { ), lazy: true, + evaluatesOperands: true, // Broadcast element-wise over a list operand (see `Less`). broadcastable: true, canonical: (ops, { engine: ce }) => diff --git a/src/compute-engine/types-definitions.ts b/src/compute-engine/types-definitions.ts index e7f4143b1..d28df6af2 100644 --- a/src/compute-engine/types-definitions.ts +++ b/src/compute-engine/types-definitions.ts @@ -2293,6 +2293,32 @@ export type OperatorDefinitionFlags = { */ selectsOperands: boolean; + /** + * If `true`, this `lazy` operator's `evaluate` handler only EVALUATES its + * held operands (with the options it receives) and reads nothing else of + * their structure, so the asynchronous route may evaluate them for it. + * + * **Only valid on a `lazy` operator** (asserted in + * `_BoxedOperatorDefinition`). A lazy operator with a synchronous handler + * and no `evaluateAsync` runs its operands to the end under + * `evaluateAsync()`: `Less(Sum(1/k^2, k, 1, 400000), 2)` neither yields + * nor honors an abort signal. With this flag, the asynchronous route + * awaits each held operand with `evaluateAsync()`, in operand order, and + * then runs the synchronous handler on the values, so the sum yields. + * + * The contract on the handler: it demands every operand and evaluates it, + * and does nothing else with the operand as written. A handler that reads + * the structure of an operand (`Numerator`, `Interpret`, `IsSame`), or that + * treats an evaluated operand differently from the one as written, must + * not set it. Two further limits, for now: an operator that is + * `selectsOperands`, `scoped` or `holdClass: 'quote'` never takes this + * route, and the route is skipped under a numeric evaluation (`N()`), + * where a handler may need the exact operand to decide a tie. + * + * **Default**: `false` + */ + evaluatesOperands: boolean; + /** * Whether every argument of an application MUST be written with its * parameter's name (`Person(firstName: "Alan", age: 42)`). diff --git a/src/compute-engine/types-expression.ts b/src/compute-engine/types-expression.ts index dea34a97b..18ba5318c 100644 --- a/src/compute-engine/types-expression.ts +++ b/src/compute-engine/types-expression.ts @@ -253,6 +253,10 @@ type OperatorDefinitionFlags = { * to evaluate, so an error in an operand it does not choose is dead code. * Only valid on a `lazy` operator. See `types-definitions.ts`. */ selectsOperands: boolean; + /** True when this lazy operator's handler only evaluates its held operands, + * so the asynchronous route may evaluate them for it. Only valid on a + * `lazy` operator. See `types-definitions.ts`. */ + evaluatesOperands: boolean; /** True when every argument must be written with its parameter's name (an * object-type constructor). See `types-definitions.ts`. */ namedArgumentsRequired: boolean; diff --git a/test/compute-engine/evaluate-async-evaluates-operands.test.ts b/test/compute-engine/evaluate-async-evaluates-operands.test.ts new file mode 100644 index 000000000..093050b71 --- /dev/null +++ b/test/compute-engine/evaluate-async-evaluates-operands.test.ts @@ -0,0 +1,311 @@ +import { ComputeEngine } from '../../src/compute-engine'; + +// GitHub issue #392, second part: a lazy operator whose handler only +// evaluates its held operands (`evaluatesOperands`: the relations), and the +// application of a user function (its body statements), yield to the event +// loop under `evaluateAsync` and honour an abort signal. Before, they ran +// their operands to the end synchronously. + +const SLOW_SUM = [ + 'Sum', + ['Divide', 1, ['Power', 'k', 2]], + ['Limits', 'k', 1, 400000], +]; +const SMALL_SUM = [ + 'Sum', + ['Divide', 1, ['Power', 'k', 2]], + ['Limits', 'k', 1, 200], +]; + +// Defines `f(n) = Σ_{k=1}^{n} 1/k²`. +function defineSumFunction(ce: ComputeEngine, name = 'f') { + ce.assign( + name, + ce.box([ + 'Function', + [ + 'Block', + ['Sum', ['Divide', 1, ['Power', 'k', 2]], ['Limits', 'k', 1, 'n']], + ], + 'n', + ]) + ); +} + +describe('a relation over a long operand yields and honours the abort signal', () => { + const ce = new ComputeEngine(); + + // Aborted after 50 ms: a run that yields rejects with a + // `CancellationError`, and a macrotask queued before the evaluation runs + // during it. A run to the end (seconds) finishes with a value first. + const abortsPromptly = async (expr: any) => { + const ac = new AbortController(); + setTimeout(() => ac.abort(), 50); + let yielded = false; + setImmediate(() => { + yielded = true; + }); + const start = Date.now(); + const boxed = typeof expr === 'string' ? ce.parse(expr) : ce.box(expr); + await expect( + boxed.evaluateAsync({ signal: ac.signal }) + ).rejects.toMatchObject({ name: 'CancellationError' }); + expect(yielded).toBe(true); + if (process.env.CE_PERF === '1') + expect(Date.now() - start).toBeLessThan(1500); + }; + + const cases: [string, any][] = [ + ['Less(Sum, 2)', ['Less', SLOW_SUM, 2]], + ['Less(2, Sum)', ['Less', 2, SLOW_SUM]], + ['LessEqual(Sum, 2)', ['LessEqual', SLOW_SUM, 2]], + ['Greater(Sum, 2)', ['Greater', SLOW_SUM, 2]], + ['GreaterEqual(Sum, 2)', ['GreaterEqual', SLOW_SUM, 2]], + ['Equal(Sum, 2)', ['Equal', SLOW_SUM, 2]], + ['NotEqual(Sum, 2)', ['NotEqual', SLOW_SUM, 2]], + ['IdenticallyEqual(Sum, 2)', ['IdenticallyEqual', SLOW_SUM, 2]], + ['Less(1, Sum, 3), a chain', ['Less', 1, SLOW_SUM, 3]], + ['Less(1, 2 + Sum), a nested operand', ['Less', 1, ['Add', 2, SLOW_SUM]]], + ['parsed: Sum < 2', '\\sum_{k=1}^{400000} \\frac{1}{k^2} < 2'], + ['parsed: Sum = 2', '\\sum_{k=1}^{400000} \\frac{1}{k^2} = 2'], + ]; + for (const [name, expr] of cases) + test(name, async () => abortsPromptly(expr)); +}); + +describe('the application of a user function yields and honours the abort signal', () => { + const abortsPromptly = async ( + setup: (ce: ComputeEngine) => any, + numericApproximation = false + ) => { + const ce = new ComputeEngine(); + defineSumFunction(ce); + const boxed = setup(ce); + const ac = new AbortController(); + setTimeout(() => ac.abort(), 50); + let yielded = false; + setImmediate(() => { + yielded = true; + }); + await expect( + boxed.evaluateAsync({ signal: ac.signal, numericApproximation }) + ).rejects.toMatchObject({ name: 'CancellationError' }); + expect(yielded).toBe(true); + // The frame of the call left the engine's stack: the function is usable. + expect(ce.box(['f', 3]).evaluate().toString()).toBe('49/36'); + }; + + test('f(400000)', async () => abortsPromptly((ce) => ce.box(['f', 400000]))); + test('f(400000), numeric', async () => + abortsPromptly((ce) => ce.box(['f', 400000]), true)); + test('parsed f(400000)', async () => + abortsPromptly((ce) => ce.parse('f(400000)'))); + test('f(Sum), a long argument', async () => + abortsPromptly((ce) => ce.box(['f', ['Floor', SLOW_SUM]]))); + test('1 + f(400000)', async () => + abortsPromptly((ce) => ce.box(['Add', 1, ['f', 400000]]))); + // A name declared `function` and assigned a literal is a value, not an + // operator definition: its application takes another route. + test('a function held as a value', async () => + abortsPromptly((ce) => { + ce.declare('v', 'function'); + ce.assign( + 'v', + ce.box([ + 'Function', + [ + 'Block', + ['Sum', ['Divide', 1, ['Power', 'k', 2]], ['Limits', 'k', 1, 'n']], + ], + 'n', + ]) + ); + return ce.box(['v', 400000]); + })); + test('Less(f(400000), 2)', async () => + abortsPromptly((ce) => ce.box(['Less', ['f', 400000], 2]))); +}); + +describe('the operators and the functions give the values of the synchronous route', () => { + const ce = new ComputeEngine(); + defineSumFunction(ce); + ce.assign( + 'fib', + ce.box([ + 'Function', + [ + 'Block', + [ + 'If', + ['Less', 'n', 2], + 'n', + ['Add', ['fib', ['Subtract', 'n', 1]], ['fib', ['Subtract', 'n', 2]]], + ], + ], + 'n', + ]) + ); + ce.assign('g', ce.parse('(a, b) \\mapsto a + b')); + ce.assign('h', ce.parse('x \\mapsto x + 1')); + + const same = async (mj: any, numericApproximation = false) => { + const expr = typeof mj === 'string' ? ce.parse(mj) : ce.box(mj); + const sync = expr.evaluate({ numericApproximation }); + const async = await expr.evaluateAsync({ numericApproximation }); + expect(async.toString()).toBe(sync.toString()); + // `toString()` quotes the symbols `True` and `False`. + return async.toString().replace(/"/g, ''); + }; + + const relations: [string, any, string][] = [ + ['Less(Sum, 2)', ['Less', SMALL_SUM, 2], 'True'], + ['Less(2, Sum)', ['Less', 2, SMALL_SUM], 'False'], + ['LessEqual(Sum, Sum)', ['LessEqual', SMALL_SUM, SMALL_SUM], 'True'], + ['Greater(Sum, 1)', ['Greater', SMALL_SUM, 1], 'True'], + ['Equal(Sum, Sum)', ['Equal', SMALL_SUM, SMALL_SUM], 'True'], + ['NotEqual(Sum, 2)', ['NotEqual', SMALL_SUM, 2], 'True'], + [ + 'IdenticallyEqual(1 + 1, 2)', + ['IdenticallyEqual', ['Add', 1, 1], 2], + 'True', + ], + // A comparison that cannot be decided stays inert over the VALUES of its + // operands, as on the synchronous route. + [ + 'Equal(x^2, 2 + 2)', + ['Equal', ['Power', 'x', 2], ['Add', 2, 2]], + 'x^2 == 4', + ], + ['Less(x, 1 + 1)', ['Less', 'x', ['Add', 1, 1]], 'x < 2'], + // A chain; the verdict does not depend on the operands after a `False`. + ['Less(1, 2, 3)', ['Less', 1, 2, 3], 'True'], + ['Less(3, 2, Sum)', ['Less', 3, 2, SMALL_SUM], 'False'], + ['Equal(1, 2, Sum)', ['Equal', 1, 2, SMALL_SUM], 'False'], + // Collections broadcast; absent operands are Kleene. + ['Less(List, 3)', ['Less', ['List', 1, 5], 3], '[True,False]'], + ['Equal(List, List)', ['Equal', ['List', 1, 2], ['List', 1, 2]], 'True'], + ['Equal(Missing, 1)', ['Equal', 'Missing', 1], 'Missing'], + ['Less(NaN, 1)', ['Less', 'NaN', 1], 'False'], + ]; + for (const [name, mj, expected] of relations) + test(`${name} = ${expected}`, async () => { + expect(await same(mj)).toBe(expected); + }); + + // Under `.N()` a comparison near a tie re-reads the operand as written to + // decide it exactly: the operands stay with the handler there. + test('N(1/2 - 10^-30 < 1/2) decides the near tie exactly', async () => { + const tie = [ + 'Less', + ['Subtract', ['Rational', 1, 2], ['Power', 10, -30]], + ['Rational', 1, 2], + ]; + expect(await same(tie, true)).toBe('True'); + expect(await same(['Equal', ['Rational', 1, 10], 0.1], true)).toBe('True'); + }); + + // The type of the operand as written decides what an absent value is: a + // `number | missing` operand reads as `NaN` (a comparison with it is + // `False`), not as the Kleene `Missing`. + test('an operand typed number | missing that is Missing reads as NaN', async () => { + const engine = new ComputeEngine(); + engine.declare('m', { type: 'number | missing' }); + engine.assign('m', engine.Missing); + const expr = engine.box(['Less', 'm', 1]); + const sync = expr.evaluate().toString(); + expect((await expr.evaluateAsync()).toString()).toBe(sync); + }); + + test('a user function: values, currying, rest, recursion', async () => { + expect(await same(['f', 200])).toBe( + ce.box(['f', 200]).evaluate().toString() + ); + expect(await same(['f', 200], true)).toBe( + ce.box(['f', 200]).evaluate({ numericApproximation: true }).toString() + ); + expect(await same(['g', 1, 2])).toBe('3'); + expect(await same(['g', 1])).toBe(ce.box(['g', 1]).evaluate().toString()); + expect(await same(['h', ['Add', 1, 2]])).toBe('4'); + // A symbolic argument keeps the application, as it does synchronously. + await same(['h', 'y']); + await same(['f', 'y']); + // A recursive body: the inner applications run synchronously. + expect(await same(['fib', 12])).toBe('144'); + // An application inside a comparison. + expect(await same(['Less', ['f', 200], 2])).toBe('True'); + // A list argument maps. + await same(['h', ['List', 1, 2, 3]]); + // The parse route. + expect(await same('f(100) + h(1)')).toBe( + ce.parse('f(100) + h(1)').evaluate().toString() + ); + }); + + test('a function called twice at once, finished in either order', async () => { + const engine = new ComputeEngine(); + defineSumFunction(engine); + const expected = (n: number) => engine.box(['f', n]).evaluate().toString(); + const a = engine.box(['f', 800]).evaluateAsync(); + const b = engine.box(['f', 20]).evaluateAsync(); + const c = engine.box(['Add', ['f', 30], ['f', 40]]).evaluateAsync(); + expect((await b).toString()).toBe(expected(20)); + expect((await a).toString()).toBe(expected(800)); + expect((await c).toString()).toBe( + engine + .box(['Add', ['f', 30], ['f', 40]]) + .evaluate() + .toString() + ); + expect(engine.contextStack.length).toBe( + new ComputeEngine().contextStack.length + ); + }); + + // The operators that read the structure of an operand do not take the + // route: an operand of `Numerator` is not evaluated first. + test('operators that read the structure of an operand are unaffected', async () => { + expect( + await same(['Numerator', ['Divide', ['Add', 'x', 1], ['Add', 'x', 2]]]) + ).toBe('x + 1'); + await same(['Numerator', ['Divide', 4, 6]]); + expect(await same(['IsSame', ['Add', 1, 1], 2])).toBe('False'); + expect(await same(['Same', ['Add', 'x', 1], 1])).toBe('False'); + }); +}); + +describe('evaluatesOperands is declared on a lazy operator', () => { + test('the flag defaults to false and is set on the relations', () => { + const ce = new ComputeEngine(); + const flag = (name: string) => + (ce.lookupDefinition(name) as any)?.operator?.evaluatesOperands; + for (const name of [ + 'Equal', + 'NotEqual', + 'Less', + 'LessEqual', + 'IdenticallyEqual', + ]) + expect(flag(name)).toBe(true); + for (const name of ['Numerator', 'IsSame', 'Same', 'Add']) + expect(flag(name)).toBe(false); + }); + + test('a declared operator takes the route', async () => { + const ce = new ComputeEngine(); + ce.declare('Twice', { + signature: '(number) -> number', + lazy: true, + evaluatesOperands: true, + evaluate: ([x]) => ce.number(2 * (x.evaluate().re ?? 0)), + } as never); + const ac = new AbortController(); + setTimeout(() => ac.abort(), 50); + await expect( + ce.box(['Twice', SLOW_SUM]).evaluateAsync({ signal: ac.signal }) + ).rejects.toMatchObject({ name: 'CancellationError' }); + expect((await ce.box(['Twice', SMALL_SUM]).evaluateAsync()).re).toBeCloseTo( + 2 * 1.6399, + 3 + ); + }); +});