Skip to content

Commit bb2969d

Browse files
committed
fix: improve numeric robustness
1 parent 8de6c60 commit bb2969d

9 files changed

Lines changed: 306 additions & 238 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
### Numerics
44

5+
- **Centralized overflow protection**: Improved robustness of `Rational` and
6+
`ExactNumericValue` arithmetic by centralizing overflow checks and automatic
7+
promotion to `BigInt`.
58
- **\[#287\](https://github.com/cortex-js/compute-engine/issues/287) Improved
69
precision for large integer products**: Multiplications and additions of large
710
integers that would previously lose precision (exceeding
@@ -10,12 +13,13 @@
1013

1114
### Evaluation
1215

13-
- **Fixed scope leak**: Ensured that evaluation contexts are correctly popped
14-
even when an error or timeout occurs during function evaluation.
15-
- **Improved numerical evaluation performance**: `Sum`, `Product`, and `Divide`
16-
now correctly propagate the `numericApproximation` option to their arguments,
17-
significantly speeding up large numerical calculations by avoiding expensive
18-
exact rational arithmetic.
16+
- **Fixed scope leaks**: Ensured that evaluation contexts are correctly popped
17+
even when an error or timeout occurs in `BoxedFunction.evaluate()`,
18+
`findUnivariateRoots()`, and rule-boxing operations.
19+
- **Improved numerical evaluation performance**: `Sum`, `Product`, `Divide`,
20+
and statistical operators (`Mean`, `Variance`, etc.) now correctly propagate
21+
the `numericApproximation` option, significantly speeding up large numerical
22+
calculations by avoiding expensive exact arithmetic.
1923

2024
## 0.50.1 _2026-02-11_
2125

benchmarks/numeric-evaluation.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ComputeEngine } from '../src/compute-engine';
2+
3+
const ce = new ComputeEngine();
4+
5+
function benchmark(name: string, fn: () => void, iterations: number = 1) {
6+
const start = performance.now();
7+
for (let i = 0; i < iterations; i++) {
8+
fn();
9+
}
10+
const end = performance.now();
11+
console.log(`${name}: ${((end - start) / iterations).toFixed(2)}ms`);
12+
}
13+
14+
console.log('--- Numerical Evaluation Benchmarks ---');
15+
16+
// Large Sum
17+
benchmark('Sum(1/n, 1..10000).N()', () => {
18+
ce.box(['Sum', ['Divide', 1, 'n'], ['Tuple', 'n', 1, 10000]]).N();
19+
}, 5);
20+
21+
// Large Product
22+
benchmark('Product(1 + 1/n^2, 1..1000).N()', () => {
23+
ce.box(['Product', ['Add', 1, ['Divide', 1, ['Power', 'n', 2]]], ['Tuple', 'n', 1, 1000]]).N();
24+
}, 5);
25+
26+
// Statistical Mean
27+
const largeListData = Array.from({ length: 10000 }, (_, i) => i);
28+
const largeList = ce.box(['List', ...largeListData]);
29+
benchmark('Mean(largeList).N()', () => {
30+
ce.box(['Mean', largeList]).N();
31+
}, 50);
32+
33+
// Integrate
34+
benchmark('Integrate(x, 0..1).N()', () => {
35+
ce.box(['Integrate', 'x', ['Tuple', 'x', 0, 1]]).N();
36+
}, 5);

src/compute-engine/boxed-expression/rules.ts

Lines changed: 63 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -516,67 +516,64 @@ function parseRule(
516516
ce.pushScope({ parent: systemScope, bindings: new Map() });
517517
}
518518

519-
const expr = ce.parse(rule);
519+
let expr: Expression;
520+
try {
521+
expr = ce.parse(rule);
520522

521-
ce.latexDictionary = previousDictionary;
523+
ce.latexDictionary = previousDictionary;
522524

523-
if (!expr.isValid || expr.operator !== 'Rule') {
524-
if (systemScope) {
525-
ce.popScope();
525+
if (!expr.isValid || expr.operator !== 'Rule') {
526+
throw new Error(
527+
`Invalid rule "${rule}"\n| ${dewildcard(expr).toString()}\n| A rule should be of the form:\n| <match> -> <replace>; <condition>`
528+
);
526529
}
527-
throw new Error(
528-
`Invalid rule "${rule}"\n| ${dewildcard(expr).toString()}\n| A rule should be of the form:\n| <match> -> <replace>; <condition>`
529-
);
530-
}
531530

532-
if (!isFunction(expr)) {
533-
if (systemScope) {
534-
ce.popScope();
531+
if (!isFunction(expr)) {
532+
throw new Error(`Invalid rule "${rule}"`);
535533
}
536-
throw new Error(`Invalid rule "${rule}"`);
537-
}
538-
const [match_, replace_, condition] = expr.ops;
534+
const [match_, replace_, condition] = expr.ops;
539535

540-
let match = match_;
541-
let replace = replace_;
542-
if (canonical) {
543-
match = match.canonical;
544-
replace = replace.canonical;
545-
}
546-
547-
// Pop the clean scope AFTER canonicalization to avoid pollution
548-
if (systemScope) {
549-
ce.popScope();
550-
}
551-
552-
// Check that all the wildcards in the replace also appear in the match
553-
if (!includesWildcards(replace, match))
554-
throw new Error(
555-
`Invalid rule "${rule}"\n| The replace expression contains wildcards not present in the match expression`
556-
);
536+
let match = match_;
537+
let replace = replace_;
538+
if (canonical) {
539+
match = match.canonical;
540+
replace = replace.canonical;
541+
}
557542

558-
if (match.isSame(replace)) {
559-
throw new Error(
560-
`Invalid rule "${rule}"\n| The match and replace expressions are the same.\n| This may be because the rule is not necessary due to canonical simplification`
561-
);
562-
}
543+
// Check that all the wildcards in the replace also appear in the match
544+
if (!includesWildcards(replace, match))
545+
throw new Error(
546+
`Invalid rule "${rule}"\n| The replace expression contains wildcards not present in the match expression`
547+
);
563548

564-
let condFn: undefined | RuleConditionFunction = undefined;
565-
if (condition !== undefined) {
566-
// Verify that all the wildcards in the condition also appear in the match
567-
if (!includesWildcards(condition, match))
549+
if (match.isSame(replace)) {
568550
throw new Error(
569-
`Invalid rule "${rule}"\n| The condition expression contains wildcards not present in the match expression`
551+
`Invalid rule "${rule}"\n| The match and replace expressions are the same.\n| This may be because the rule is not necessary due to canonical simplification`
570552
);
553+
}
571554

572-
// Evaluate the condition as a predicate
573-
condFn = (sub: BoxedSubstitution): boolean => {
574-
const evaluated = condition.subs(sub).canonical.evaluate();
575-
return isSymbol(evaluated) && evaluated.symbol === 'True';
576-
};
577-
}
555+
let condFn: undefined | RuleConditionFunction = undefined;
556+
if (condition !== undefined) {
557+
// Verify that all the wildcards in the condition also appear in the match
558+
if (!includesWildcards(condition, match))
559+
throw new Error(
560+
`Invalid rule "${rule}"\n| The condition expression contains wildcards not present in the match expression`
561+
);
562+
563+
// Evaluate the condition as a predicate
564+
condFn = (sub: BoxedSubstitution): boolean => {
565+
const evaluated = condition.subs(sub).canonical.evaluate();
566+
return isSymbol(evaluated) && evaluated.symbol === 'True';
567+
};
568+
}
578569

579-
return boxRule(ce, { match, replace, condition: condFn, id: rule }, options);
570+
return boxRule(ce, { match, replace, condition: condFn, id: rule }, options);
571+
} finally {
572+
// Pop the clean scope AFTER canonicalization to avoid pollution
573+
if (systemScope) {
574+
ce.popScope();
575+
}
576+
}
580577
}
581578

582579
function boxRule(
@@ -654,15 +651,22 @@ function boxRule(
654651
} else {
655652
ce.pushScope();
656653
}
657-
// Match patterns should never be canonicalized - they need to preserve their
658-
// structure with wildcards for pattern matching. For example, ['Divide', '_a', '_a']
659-
// should remain as a Divide expression, not be simplified to 1.
660-
const matchExpr = parseRulePart(ce, match, {
661-
canonical: false,
662-
autoWildcard: false,
663-
});
664-
const replaceExpr = parseRulePart(ce, replace, options);
665-
ce.popScope();
654+
655+
let matchExpr: Expression | null;
656+
let replaceExpr: Expression | ((...args: any[]) => Expression | null);
657+
try {
658+
// Match patterns should never be canonicalized - they need to preserve their
659+
// structure with wildcards for pattern matching. For example, ['Divide', '_a', '_a']
660+
// should remain as a Divide expression, not be simplified to 1.
661+
matchExpr = parseRulePart(ce, match, {
662+
canonical: false,
663+
autoWildcard: false,
664+
});
665+
replaceExpr =
666+
typeof replace === 'function' ? replace : parseRulePart(ce, replace, options);
667+
} finally {
668+
ce.popScope();
669+
}
666670

667671
// Make up an id if none is provided
668672
if (!id) {
@@ -754,6 +758,7 @@ export function applyRule(
754758
substitution: BoxedSubstitution,
755759
options?: Readonly<Partial<ReplaceOptions>>
756760
): RuleStep | null {
761+
if (!rule) return null;
757762
let canonical = options?.canonical ?? (expr.isCanonical || expr.isStructural);
758763

759764
let operandsMatched = false;

src/compute-engine/boxed-expression/solve.ts

Lines changed: 52 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,9 +1121,12 @@ function solveNestedSqrtEquation(
11211121
ce.pushScope();
11221122
ce.declare(uSymbolName, { type: 'real' });
11231123

1124-
const uSolutions = findUnivariateRoots(uEquation, uSymbolName);
1125-
1126-
ce.popScope();
1124+
let uSolutions: ReadonlyArray<Expression>;
1125+
try {
1126+
uSolutions = findUnivariateRoots(uEquation, uSymbolName);
1127+
} finally {
1128+
ce.popScope();
1129+
}
11271130

11281131
if (uSolutions.length === 0) return null;
11291132

@@ -1211,59 +1214,62 @@ export function findUnivariateRoots(
12111214
// Create a lexical scope for the unknown
12121215
ce.pushScope();
12131216

1214-
// Use the declared type of the variable, if any, otherwise assume 'number'
1215-
const varType = ce.symbol(x).type.type;
1216-
ce.declare('_x', typeof varType === 'string' ? varType : 'number');
1217-
1218-
let result = exprs.flatMap((expr) =>
1219-
matchAnyRules(
1220-
expr,
1221-
rules,
1222-
{ _x: ce.symbol('_x') },
1223-
{ useVariations: true, canonical: true }
1224-
)
1225-
);
1217+
let result: Expression[] = [];
1218+
try {
1219+
// Use the declared type of the variable, if any, otherwise assume 'number'
1220+
const varType = ce.symbol(x).type.type;
1221+
ce.declare('_x', typeof varType === 'string' ? varType : 'number');
12261222

1227-
// If we didn't find a solution yet, try modifying the expression
1228-
//expr.
1229-
// Note: @todo we can try different heuristics here:
1230-
// Collection: reduce the numbers of occurrences of the unknown
1231-
// Attraction: bring the occurrences of the unknown closer together
1232-
// Function Swapping: replacing function with ones easier to solve
1233-
// - square roots: square both sides
1234-
// - logs: exponentiate both sides
1235-
// - trig functions: use inverse trig functions
1236-
// Homogenization: replace a function of the unknown by a new variable,
1237-
// e.g. exp(x) -> y, then solve for y
1238-
1239-
if (result.length === 0) {
1240-
exprs = exprs.flatMap((expr) => harmonize(expr));
12411223
result = exprs.flatMap((expr) =>
12421224
matchAnyRules(
12431225
expr,
12441226
rules,
1245-
{ _x: ce.symbol(x) },
1227+
{ _x: ce.symbol('_x') },
12461228
{ useVariations: true, canonical: true }
12471229
)
12481230
);
1249-
}
12501231

1251-
if (result.length === 0) {
1252-
exprs = exprs
1253-
.flatMap((expr) => expand(expr.canonical))
1254-
.filter((x) => x !== null) as Expression[];
1255-
exprs = exprs.flatMap((expr) => harmonize(expr));
1256-
result = exprs.flatMap((expr) =>
1257-
matchAnyRules(
1258-
expr,
1259-
rules,
1260-
{ _x: ce.symbol(x) },
1261-
{ useVariations: true, canonical: true }
1262-
)
1263-
);
1264-
}
1232+
// If we didn't find a solution yet, try modifying the expression
1233+
//expr.
1234+
// Note: @todo we can try different heuristics here:
1235+
// Collection: reduce the numbers of occurrences of the unknown
1236+
// Attraction: bring the occurrences of the unknown closer together
1237+
// Function Swapping: replacing function with ones easier to solve
1238+
// - square roots: square both sides
1239+
// - logs: exponentiate both sides
1240+
// - trig functions: use inverse trig functions
1241+
// Homogenization: replace a function of the unknown by a new variable,
1242+
// e.g. exp(x) -> y, then solve for y
1243+
1244+
if (result.length === 0) {
1245+
exprs = exprs.flatMap((expr) => harmonize(expr));
1246+
result = exprs.flatMap((expr) =>
1247+
matchAnyRules(
1248+
expr,
1249+
rules,
1250+
{ _x: ce.symbol(x) },
1251+
{ useVariations: true, canonical: true }
1252+
)
1253+
);
1254+
}
12651255

1266-
ce.popScope(); // End lexical scope for the unknown
1256+
if (result.length === 0) {
1257+
exprs = exprs
1258+
.flatMap((expr) => expand(expr.canonical))
1259+
.filter((x) => x !== null) as Expression[];
1260+
exprs = exprs.flatMap((expr) => harmonize(expr));
1261+
result = exprs.flatMap((expr) =>
1262+
matchAnyRules(
1263+
expr,
1264+
rules,
1265+
{ _x: ce.symbol(x) },
1266+
{ useVariations: true, canonical: true }
1267+
)
1268+
);
1269+
}
1270+
} finally {
1271+
ce.popScope();
1272+
}
12671273

12681274
// Validate the roots against the ORIGINAL expression (before clearing
12691275
// denominators and harmonization). This filters out extraneous roots that

0 commit comments

Comments
 (0)