Skip to content

Commit bff7db2

Browse files
committed
feat: improved simplification
1 parent 7b7c479 commit bff7db2

6 files changed

Lines changed: 226 additions & 37 deletions

File tree

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,19 @@ function simplifyOperands(
262262
if (x.operator === 'Ln') {
263263
return simplify(x, options).at(-1)!.value;
264264
}
265+
// Simplify Abs operands to enable cancellation
266+
// (e.g., |xy| -> |x||y| so that |xy| - |x||y| = 0)
267+
// Also handle Negate(Abs(...)) which appears in subtraction expressions
268+
if (x.operator === 'Abs') {
269+
return simplify(x, options).at(-1)!.value;
270+
}
271+
if (
272+
x.operator === 'Negate' &&
273+
isBoxedFunction(x) &&
274+
x.op1?.operator === 'Abs'
275+
) {
276+
return simplify(x, options).at(-1)!.value;
277+
}
265278
// Power expressions with fractional exponents may need sign factoring
266279
// e.g., (-2x)^{3/5} should become -(2x)^{3/5} for correct real evaluation
267280
if (
@@ -419,7 +432,13 @@ function simplifyNonCommutativeFunction(
419432
because === 'ln' ||
420433
because?.startsWith('ln(') ||
421434
because?.startsWith('log_');
422-
if (!isCheaper(expr, last, options?.costFunction) && !isPowerCombination && !isLogRule)
435+
// Root sign extraction: root(-a, n) -> -root(a, n) for odd n
436+
const isRootSignRule = because?.startsWith('root(-');
437+
// Abs identity rules (|xy| -> |x||y|, |x/y| -> |x|/|y|) normalize structure
438+
const isAbsRule = because?.startsWith('|');
439+
// Quotient-power distribution: a/(b/c)^d -> a*(c/b)^d eliminates nested fractions
440+
const isQuotientPowerRule = because === 'a / (b/c)^d -> a * (c/b)^d';
441+
if (!isCheaper(expr, last, options?.costFunction) && !isPowerCombination && !isLogRule && !isRootSignRule && !isAbsRule && !isQuotientPowerRule)
423442
return steps;
424443

425444
result.at(-1)!.value = last;

src/compute-engine/symbolic/simplify-abs.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,17 @@ export function simplifyAbsPower(x: BoxedExpression): RuleStep | undefined {
263263
}
264264
}
265265

266+
// |x|^(p/q) -> x^(p/q) when p is even (Rational form)
267+
if (exp.isRational === true && exp.isInteger === false) {
268+
const num = exp.numerator;
269+
if (num && num.isEven === true) {
270+
return {
271+
value: innerBase.pow(exp),
272+
because: '|x|^(p/q) -> x^(p/q) when p is even',
273+
};
274+
}
275+
}
276+
266277
return undefined;
267278
}
268279

src/compute-engine/symbolic/simplify-log.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,28 @@ export function simplifyLog(x: BoxedExpression): RuleStep | undefined {
4040
return { value: ce.PositiveInfinity, because: 'ln(+inf) -> +inf' };
4141
}
4242

43+
// ln(p/q) -> ln(p) - ln(q) for positive rational p/q (not integer)
44+
if (arg.operator === 'Rational' && arg.isRational === true && arg.isInteger === false) {
45+
const j = arg.json;
46+
if (Array.isArray(j) && j[0] === 'Rational') {
47+
const p = j[1] as number;
48+
const q = j[2] as number;
49+
if (p > 0 && q > 0) {
50+
if (p === 1) {
51+
// ln(1/q) -> -ln(q)
52+
return {
53+
value: ce._fn('Ln', [ce.number(q)]).neg(),
54+
because: 'ln(1/q) -> -ln(q)',
55+
};
56+
}
57+
return {
58+
value: ce._fn('Ln', [ce.number(p)]).sub(ce._fn('Ln', [ce.number(q)])),
59+
because: 'ln(p/q) -> ln(p) - ln(q)',
60+
};
61+
}
62+
}
63+
}
64+
4365
// ln(x^n) -> n*ln(x) when x >= 0 or n is odd or n is irrational
4466
if (arg.operator === 'Power' && isBoxedFunction(arg)) {
4567
const base = arg.op1;
@@ -275,6 +297,29 @@ export function simplifyLog(x: BoxedExpression): RuleStep | undefined {
275297
because: 'log_c(x^n) -> n*log_c(|x|) when n even',
276298
};
277299
}
300+
// log_c(x^{p/q}) for non-integer rational p/q
301+
if (exp.isRational === true && exp.isInteger === false) {
302+
const j = exp.json;
303+
if (Array.isArray(j) && j[0] === 'Rational') {
304+
const p = j[1] as number;
305+
const q = j[2] as number;
306+
// q even: x >= 0 implied (even root), no |x| needed
307+
// q odd, p odd: preserves sign, no |x| needed
308+
// q odd, p even: x^{p/q} is non-negative, need |x|
309+
if (q % 2 === 0 || p % 2 !== 0) {
310+
return {
311+
value: exp.mul(ce._fn('Log', [powerBase, logBase])),
312+
because: 'log_c(x^{p/q}) -> (p/q)*log_c(x)',
313+
};
314+
}
315+
return {
316+
value: exp.mul(
317+
ce._fn('Log', [ce._fn('Abs', [powerBase]), logBase])
318+
),
319+
because: 'log_c(x^{p/q}) -> (p/q)*log_c(|x|) when p even',
320+
};
321+
}
322+
}
278323
}
279324
}
280325

@@ -361,6 +406,16 @@ export function simplifyLog(x: BoxedExpression): RuleStep | undefined {
361406
because: 'log_{1/c}(a) -> -log_c(a)',
362407
};
363408
}
409+
// Same rule for Rational(1, q): log_{1/q}(a) -> -log_q(a)
410+
if (logBase.operator === 'Rational') {
411+
const bj = logBase.json;
412+
if (Array.isArray(bj) && bj[0] === 'Rational' && bj[1] === 1) {
413+
return {
414+
value: ce._fn('Log', [arg, ce.number(bj[2] as number)]).neg(),
415+
because: 'log_{1/c}(a) -> -log_c(a)',
416+
};
417+
}
418+
}
364419
}
365420

366421
// Handle Power with e and Ln
@@ -757,6 +812,46 @@ export function simplifyLog(x: BoxedExpression): RuleStep | undefined {
757812
because: 'ln(a) / log_c(a) -> ln(c)',
758813
};
759814
}
815+
816+
// ln(a) / ln(b) -> k when a = b^k for positive integers a, b
817+
if (
818+
num.operator === 'Ln' &&
819+
isBoxedFunction(num) &&
820+
denom.operator === 'Ln' &&
821+
isBoxedFunction(denom)
822+
) {
823+
const a = num.op1;
824+
const b = denom.op1;
825+
if (
826+
a &&
827+
b &&
828+
a.isInteger === true &&
829+
b.isInteger === true &&
830+
a.isPositive === true &&
831+
b.isPositive === true
832+
) {
833+
const aVal = a.re;
834+
const bVal = b.re;
835+
if (
836+
Number.isFinite(aVal) &&
837+
Number.isFinite(bVal) &&
838+
bVal > 1 &&
839+
aVal > 0
840+
) {
841+
// Check if a = b^k for some integer k
842+
// Use Math.round to handle floating-point imprecision,
843+
// then verify with exact integer exponentiation
844+
const kRaw = Math.log(aVal) / Math.log(bVal);
845+
const k = Math.round(kRaw);
846+
if (Math.abs(kRaw - k) < 1e-10 && Math.pow(bVal, k) === aVal) {
847+
return {
848+
value: ce.number(k),
849+
because: 'ln(a)/ln(b) -> k when a = b^k',
850+
};
851+
}
852+
}
853+
}
854+
}
760855
}
761856
}
762857

src/compute-engine/symbolic/simplify-power.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ export function simplifyPower(x: BoxedExpression): RuleStep | undefined {
6262
}
6363
}
6464

65+
// Sign extraction for odd roots: root(-a, n) -> -root(a, n) when n is odd
66+
if (rootIndex.isOdd === true && arg.isNegative === true) {
67+
return {
68+
value: ce._fn('Root', [arg.neg(), rootIndex]).neg(),
69+
because: 'root(-a, n) -> -root(a, n) when n odd',
70+
};
71+
}
72+
6573
// root(sqrt(x), n) -> x^{1/(2n)} (nth root of square root)
6674
if (arg.operator === 'Sqrt' && isBoxedFunction(arg) && arg.op1) {
6775
const innerBase = arg.op1;
@@ -336,6 +344,11 @@ export function simplifyPower(x: BoxedExpression): RuleStep | undefined {
336344

337345
if (!base || !exp) return undefined;
338346

347+
// x^1 -> x
348+
if (exp.is(1)) {
349+
return { value: base, because: 'x^1 -> x' };
350+
}
351+
339352
// 0^x -> 0 when x is positive (including symbolic like π)
340353
// Note: 0^0 = NaN and 0^(-x) = ComplexInfinity are handled elsewhere
341354
if (base.is(0) && exp.isPositive === true) {
@@ -658,7 +671,7 @@ export function simplifyPower(x: BoxedExpression): RuleStep | undefined {
658671
isBoxedFunction(denom) &&
659672
denom.op1.operator === 'Divide' &&
660673
isBoxedFunction(denom.op1) &&
661-
denom.op1.op2.is(0) === false
674+
denom.op1.op2.is(0) !== true
662675
) {
663676
const fracNum = denom.op1.op1;
664677
const fracDenom = denom.op1.op2;

src/compute-engine/symbolic/simplify-rules.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,13 @@ export const SIMPLIFY_RULES: Rule[] = [
344344
if (num.is(0) || num.isSame(denom)) return undefined;
345345
}
346346

347+
// Skip Ln/Log divisions — let simplifyLog handle ln(a)/ln(b), etc.
348+
if (
349+
(num.operator === 'Ln' || num.operator === 'Log') &&
350+
(denom.operator === 'Ln' || denom.operator === 'Log')
351+
)
352+
return undefined;
353+
347354
// Skip if both operands are powers with the same base (let simplifyPower handle it)
348355
// This preserves symbolic forms like e^x / e^2 -> e^{x-2}
349356
if (
@@ -368,6 +375,13 @@ export const SIMPLIFY_RULES: Rule[] = [
368375
denom.op1.isSame(num)
369376
)
370377
return undefined;
378+
// Skip a / (b/c)^d — let simplifyPower handle it
379+
if (
380+
denom.operator === 'Power' &&
381+
isBoxedFunction(denom) &&
382+
denom.op1.operator === 'Divide'
383+
)
384+
return undefined;
371385

372386
return { value: num.div(denom), because: 'division' };
373387
}
@@ -443,8 +457,12 @@ export const SIMPLIFY_RULES: Rule[] = [
443457
//
444458
(x): RuleStep | undefined => {
445459
if (!isBoxedFunction(x)) return undefined;
446-
if (x.operator === 'Ln')
460+
if (x.operator === 'Ln') {
461+
// Skip ln of non-integer rationals — simplifyLog decomposes ln(p/q) → ln(p) - ln(q)
462+
if (x.op1.operator === 'Rational' && x.op1.isInteger === false)
463+
return undefined;
447464
return { value: x.op1.ln(x.ops[1]), because: 'ln' };
465+
}
448466
if (x.operator === 'Log') {
449467
const logBase = x.ops[1] ?? 10;
450468
// Skip edge cases that simplifyLog handles correctly:
@@ -460,6 +478,39 @@ export const SIMPLIFY_RULES: Rule[] = [
460478
// Skip log_c(c^x) — simplifyLog returns x directly
461479
if (x.op1.operator === 'Power' && isBoxedFunction(x.op1) && x.op1.op1?.isSame(baseExpr))
462480
return undefined;
481+
// Skip Power args — simplifyLog handles these with proper sign/abs tracking:
482+
// irrational exponents, non-integer rationals, and even exponents (need |x|)
483+
if (x.op1.operator === 'Power' && isBoxedFunction(x.op1) && x.op1.op2) {
484+
const exp = x.op1.op2;
485+
if (exp.isRational === false || (exp.isRational === true && exp.isInteger === false))
486+
return undefined;
487+
if (exp.isEven === true)
488+
return undefined;
489+
}
490+
// Skip reciprocal bases (Rational(1,q)) — simplifyLog has a dedicated rule
491+
if (baseExpr.operator === 'Rational') {
492+
const bj = baseExpr.json;
493+
if (Array.isArray(bj) && bj[0] === 'Rational' && bj[1] === 1)
494+
return undefined;
495+
}
496+
// Skip Multiply args containing a factor that is Power(base, ...) —
497+
// simplifyLog has log_c(c^x * y) → x + log_c(y) rule
498+
if (x.op1.operator === 'Multiply' && isBoxedFunction(x.op1)) {
499+
for (const factor of x.op1.ops) {
500+
if (factor.operator === 'Power' && isBoxedFunction(factor) && factor.op1?.isSame(baseExpr))
501+
return undefined;
502+
}
503+
}
504+
// Skip Divide args containing base match in numerator or denominator —
505+
// simplifyLog has log_c(c^x/y) and log_c(y/c^x) rules
506+
if (x.op1.operator === 'Divide' && isBoxedFunction(x.op1)) {
507+
const num = x.op1.op1;
508+
const denom = x.op1.op2;
509+
if (num?.operator === 'Power' && isBoxedFunction(num) && num.op1?.isSame(baseExpr))
510+
return undefined;
511+
if (denom?.operator === 'Power' && isBoxedFunction(denom) && denom.op1?.isSame(baseExpr))
512+
return undefined;
513+
}
463514
return { value: x.op1.ln(logBase), because: 'log' };
464515
}
465516
return undefined;

0 commit comments

Comments
 (0)