Skip to content

Commit ed4f608

Browse files
committed
feat: QOL improvements
1 parent 052071d commit ed4f608

15 files changed

Lines changed: 2218 additions & 35 deletions

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,27 @@ export function _setSerializeJson(fn: SerializeJsonFn) {
6464
_serializeJson = fn;
6565
}
6666

67+
type ExpandFn = (expr: Expression) => Expression;
68+
let _expandForIs: ExpandFn;
69+
/** @internal */
70+
export function _setExpandForIs(fn: ExpandFn) {
71+
_expandForIs = fn;
72+
}
73+
74+
const EXPANDABLE_OPS = new Set(['Multiply', 'Power', 'Negate', 'Divide']);
75+
76+
/** Return true if at least one side has an operator where expansion could
77+
* produce a structurally different result. */
78+
function _couldBenefitFromExpand(
79+
a: _BoxedExpression,
80+
b: Expression | number | bigint | boolean | string
81+
): boolean {
82+
if (EXPANDABLE_OPS.has(a.operator)) return true;
83+
if (typeof b === 'object' && b !== null && 'operator' in b)
84+
return EXPANDABLE_OPS.has(b.operator);
85+
return false;
86+
}
87+
6788
/**
6889
* _BoxedExpression
6990
*
@@ -471,13 +492,38 @@ export abstract class _BoxedExpression implements Expression {
471492
return [this, this.engine.One];
472493
}
473494

495+
toRational(): [number, number] | null {
496+
return null;
497+
}
498+
499+
factors(): ReadonlyArray<Expression> {
500+
return [this];
501+
}
502+
474503
is(
475504
other: Expression | number | bigint | boolean | string,
476505
tolerance?: number
477506
): boolean {
478507
// Fast path: exact structural/value check
479508
if (this.isSame(other)) return true;
480509

510+
// Try expansion — catches equivalences like (x+1)^2 vs x^2+2x+1
511+
// even when the expression has free variables (where the numeric
512+
// fallback below would bail out).
513+
//
514+
// Only expand when at least one side contains Multiply, Power, or
515+
// Negate — those are the only operators where expansion can produce
516+
// a structurally different result. This avoids needlessly
517+
// reconstructing Add trees (expand recurses into Add operands).
518+
if (_expandForIs && _couldBenefitFromExpand(this, other)) {
519+
const expandedThis = _expandForIs(this);
520+
if (expandedThis !== this && expandedThis.isSame(other)) return true;
521+
if (other instanceof _BoxedExpression) {
522+
const expandedOther = _expandForIs(other);
523+
if (expandedThis.isSame(expandedOther)) return true;
524+
}
525+
}
526+
481527
// Numeric fallback only when there are no free variables
482528
if (this.freeVariables.length > 0) return false;
483529

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { canonicalMultiply, mul, div, Product } from './arithmetic-mul-div';
5252
import { add } from './arithmetic-add';
5353
import { pow } from './arithmetic-power';
5454
import { asSmallInteger } from './numerics';
55+
import { gcd } from '../numerics/numeric';
5556
import { _BoxedExpression } from './abstract-boxed-expression';
5657
import { DEFAULT_COMPLEXITY, sortOperands } from './order';
5758
import {
@@ -629,6 +630,42 @@ export class BoxedFunction
629630
return [this, this.engine.One];
630631
}
631632

633+
factors(): ReadonlyArray<Expression> {
634+
const op = this.operator;
635+
if (op === 'Multiply') {
636+
const result: Expression[] = [];
637+
for (const arg of this.ops) result.push(...arg.factors());
638+
return result;
639+
}
640+
if (op === 'Negate') {
641+
return [this.engine.number(-1), ...this.op1.factors()];
642+
}
643+
return [this];
644+
}
645+
646+
toRational(): [number, number] | null {
647+
const op = this.operator;
648+
if (op === 'Divide' || op === 'Rational') {
649+
const num = this.op1.re;
650+
const den = this.op2.re;
651+
if (
652+
Number.isInteger(num) &&
653+
Number.isInteger(den) &&
654+
den !== 0
655+
) {
656+
const g = gcd(Math.abs(num), Math.abs(den));
657+
const sign = den < 0 ? -1 : 1;
658+
return [(sign * num) / g, (sign * den) / g];
659+
}
660+
return null;
661+
}
662+
if (op === 'Negate') {
663+
const r = this.op1.toRational();
664+
return r ? [-r[0], r[1]] : null;
665+
}
666+
return null;
667+
}
668+
632669
//
633670
//
634671
// ALGEBRAIC OPERATIONS

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,25 @@ export class BoxedNumber
455455
];
456456
}
457457

458+
toRational(): [number, number] | null {
459+
if (typeof this._value === 'number') {
460+
if (!Number.isFinite(this._value)) return null;
461+
if (Number.isInteger(this._value)) return [this._value, 1];
462+
return null;
463+
}
464+
// NumericValue — check it's a pure rational (no radical, no imaginary)
465+
if (this._value.im !== 0) return null;
466+
const exact = this._value.asExact;
467+
if (!exact) return null;
468+
const ev = exact as ExactNumericValue;
469+
if (ev.radical !== 1) return null;
470+
const r = ev.rational;
471+
const num = Number(r[0]);
472+
const den = Number(r[1]);
473+
if (!Number.isFinite(num) || !Number.isFinite(den)) return null;
474+
return [num, den];
475+
}
476+
458477
subs(
459478
sub: Substitution,
460479
options?: { canonical?: CanonicalOptions }

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { stochasticEqual } from './stochastic-equal';
1212

1313
// Lazy reference to break circular dependency:
1414
// expand → arithmetic-add → boxed-tensor → abstract-boxed-expression → compare
15-
type ExpandFn = (expr: Expression | undefined) => Expression | null;
15+
type ExpandFn = (expr: Expression) => Expression;
1616
let _expand: ExpandFn;
1717
/** @internal */
1818
export function _setExpand(fn: ExpandFn) {
@@ -135,8 +135,8 @@ export function eq(
135135
}
136136

137137
// Try structural equality after expand+simplify first
138-
a = (_expand(a) ?? a).simplify();
139-
b = (_expand(b) ?? b).simplify();
138+
a = _expand(a).simplify();
139+
b = _expand(b).simplify();
140140
if (same(a, b)) return true;
141141

142142
// Fall back to stochastic evaluation at random sample points

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

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,6 @@ export function expandFunction(
129129
//
130130
if (h === 'Divide') {
131131
const num = expand(ops[0]);
132-
if (num === null) return null;
133132
if (isFunction(num, 'Add'))
134133
return add(...num.ops.map((x) => x.div(ops[1])));
135134
return ce._fn('Divide', [num, ops[1]]);
@@ -160,14 +159,14 @@ export function expandFunction(
160159
//
161160
// Negate
162161
//
163-
if (h === 'Negate') return expand(ops[0])?.neg() ?? null;
162+
if (h === 'Negate') return expand(ops[0]).neg();
164163

165164
//
166165
//
167166
// Add
168167
//
169168

170-
if (h === 'Add') return add(...ops.map((x) => expand(x) ?? x));
169+
if (h === 'Add') return add(...ops.map((x) => expand(x)));
171170

172171
//
173172
// Power
@@ -188,11 +187,11 @@ export function expandFunction(
188187
* If the exression is a relational operator, expand the operands.
189188
* Return null if the expression cannot be expanded.
190189
*/
191-
export function expand(expr: Expression | undefined): Expression | null {
190+
export function expand(expr: Expression): Expression {
192191
// To expand an expression, we need to use its canonical form
193-
expr = expr?.canonical;
192+
expr = expr.canonical;
194193

195-
if (!expr || typeof expr.operator !== 'string') return null;
194+
if (typeof expr.operator !== 'string') return expr;
196195

197196
//
198197
// Expand relational operators
@@ -201,14 +200,16 @@ export function expand(expr: Expression | undefined): Expression | null {
201200
const ops = isFunction(expr) ? expr.ops : [];
202201
return expr.engine._fn(
203202
expr.operator,
204-
ops.map((x) => expand(x) ?? x)
203+
ops.map((x) => expand(x))
205204
);
206205
}
207206

208-
return expandFunction(
209-
expr.engine,
210-
expr.operator,
211-
isFunction(expr) ? expr.ops : []
207+
return (
208+
expandFunction(
209+
expr.engine,
210+
expr.operator,
211+
isFunction(expr) ? expr.ops : []
212+
) ?? expr
212213
);
213214
}
214215

@@ -217,11 +218,11 @@ export function expand(expr: Expression | undefined): Expression | null {
217218
*
218219
* `expand()` only expands the top level of the expression.
219220
*/
220-
export function expandAll(expr: Expression): Expression | null {
221-
if (!expr.operator || !isFunction(expr)) return null;
221+
export function expandAll(expr: Expression): Expression {
222+
if (!expr.operator || !isFunction(expr)) return expr;
222223

223-
const ops = expr.ops.map((x) => expandAll(x) ?? x);
224+
const ops = expr.ops.map((x) => expandAll(x));
224225

225226
const result = expr.engine.function(expr.operator, ops);
226-
return expand(result) ?? result;
227+
return expand(result);
227228
}

src/compute-engine/boxed-expression/init-lazy-refs.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import { expand } from './expand';
66
import { _setExpand } from './compare';
77

88
import { serializeJson, _setProduct } from './serialize';
9-
import { _setSerializeJson } from './abstract-boxed-expression';
9+
import { _setSerializeJson, _setExpandForIs } from './abstract-boxed-expression';
1010

1111
import { Product } from './arithmetic-mul-div';
1212

1313
import { compile } from '../compilation/compile-expression';
1414
import { _setCompile } from './stochastic-equal';
1515

1616
_setExpand(expand);
17+
_setExpandForIs(expand);
1718
_setSerializeJson(serializeJson);
1819
_setProduct(Product);
1920
_setCompile(compile);

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export function getPolynomialCoefficients(
221221
const coeffs: Expression[] = new Array(degree + 1).fill(ce.Zero);
222222

223223
// Expand the expression to get standard form
224-
const expanded = expand(expr) ?? expr;
224+
const expanded = expand(expr);
225225

226226
// Helper to add a term's coefficient at a specific degree
227227
const addCoefficient = (coef: Expression, deg: number): boolean => {

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,10 @@ function extractLinearCoefficients(
174174
if (isFunction(equation, 'Equal')) {
175175
const lhs = equation.op1;
176176
const rhs = equation.op2;
177-
expr = expand(lhs.sub(rhs)) ?? lhs.sub(rhs);
177+
expr = expand(lhs.sub(rhs));
178178
} else {
179179
// Assume equation = 0
180-
expr = expand(equation) ?? equation;
180+
expr = expand(equation);
181181
}
182182

183183
// Check that all variables appear with degree at most 1 (linear)
@@ -680,9 +680,9 @@ export function solvePolynomialSystem(
680680
const normalized = equations.map((eq) => {
681681
if (isFunction(eq, 'Equal')) {
682682
const diff = eq.op1.sub(eq.op2);
683-
return (expand(diff) ?? diff).simplify();
683+
return expand(diff).simplify();
684684
}
685-
return (expand(eq) ?? eq).simplify();
685+
return expand(eq).simplify();
686686
});
687687

688688
// Try product + sum pattern first
@@ -1182,12 +1182,12 @@ function extractLinearConstraint(
11821182

11831183
if (op === 'Less' || op === 'LessEqual') {
11841184
const diff1 = lhs.sub(rhs);
1185-
expr = (expand(diff1) ?? diff1).simplify();
1185+
expr = expand(diff1).simplify();
11861186
strict = op === 'Less';
11871187
} else {
11881188
// Greater or GreaterEqual: flip to Less form
11891189
const diff2 = rhs.sub(lhs);
1190-
expr = (expand(diff2) ?? diff2).simplify();
1190+
expr = expand(diff2).simplify();
11911191
strict = op === 'Greater';
11921192
}
11931193

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,10 +1151,10 @@ export function findUnivariateRoots(
11511151
const ce = expr.engine;
11521152

11531153
if (isFunction(expr, 'Equal')) {
1154-
const lhs = expand(expr.op1) ?? expr.op1;
1155-
const rhs = expand(expr.op2) ?? expr.op2;
1154+
const lhs = expand(expr.op1);
1155+
const rhs = expand(expr.op2);
11561156
expr = lhs.sub(rhs).simplify();
1157-
} else expr = (expand(expr) ?? expr).simplify();
1157+
} else expr = expand(expr).simplify();
11581158

11591159
// Save the expression BEFORE clearing denominators and other transformations.
11601160
// This is crucial for validating roots: clearing denominators and harmonization

src/compute-engine/free-functions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export function assign(
9090
getDefaultEngine().assign(arg1 as any, arg2 as any);
9191
}
9292

93-
export function expand(expr: LatexString | ExpressionInput): Expression | null {
93+
export function expand(expr: LatexString | ExpressionInput): Expression {
9494
return expandExpr(toExpression(expr));
9595
}
9696

@@ -107,7 +107,7 @@ export function solve(
107107

108108
export function expandAll(
109109
expr: LatexString | ExpressionInput
110-
): Expression | null {
110+
): Expression {
111111
return expandAllExpr(toExpression(expr));
112112
}
113113

0 commit comments

Comments
 (0)