From 78c9f28708ec9d92d8367abd0e34cbe89b87610f Mon Sep 17 00:00:00 2001 From: Roman Kelesidis <54049465+belomaxorka@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:36:36 +0700 Subject: [PATCH] Fix reciprocal power simplification loop `n/n1 -> n*n1^-1` rewrites `1/X` as `1 * X^-1`, and distributing a power over that product creates a `1^n` factor. Nothing eliminates it, so every pass merges a freshly created `1^n` into the accumulated one through `n^n1 * n^n2 -> n^(n1+n2)`. The exponent keeps growing, the expression string never repeats, and `_simplify` never reaches its fixed point. Add `1^n -> 1` after the power distribution rules, so the unit factor is removed where it appears. Expressions that already converged keep their results, except that stray `1^...` factors are no longer left behind in the output. Co-Authored-By: Claude Opus 5 --- src/function/algebra/simplify.js | 5 +++ .../function/algebra/simplify.test.js | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/function/algebra/simplify.js b/src/function/algebra/simplify.js index 4c7dffe8ad..9e2ee288b0 100644 --- a/src/function/algebra/simplify.js +++ b/src/function/algebra/simplify.js @@ -264,6 +264,11 @@ export const createSimplify = /* #__PURE__ */ factory(name, dependencies, ( s: '(n1*n2)^(-1) -> n2^(-1) * n1^(-1)', assuming: { multiply: { commutative: false } } }, + // Distributing a power over the unit factor that 'n/n1 -> n*n1^-1' leaves + // behind produces 1^n. Without this rule that factor is never eliminated + // and accumulates a larger exponent on every pass, so simplification of a + // symbolic power of a reciprocal never converges. See #3693. + { l: '1^n', r: '1' }, // expand nested exponentiation { diff --git a/test/unit-tests/function/algebra/simplify.test.js b/test/unit-tests/function/algebra/simplify.test.js index 6a34e0fc06..ac0e287654 100644 --- a/test/unit-tests/function/algebra/simplify.test.js +++ b/test/unit-tests/function/algebra/simplify.test.js @@ -387,6 +387,36 @@ describe('simplify', function () { simplifyAndCompare('8 - n', '8 - n') }) + it('should remove unit factors raised to a power', function () { + simplifyAndCompare('1^x', '1') + simplifyAndCompare('1^(2*x)', '1') + simplifyAndCompare('1^x*y', 'y') + simplifyAndCompare('(1*x)^y', 'x^y') + simplifyAndCompare('(x/2)^y', 'x^y/2^y') + }) + + it('should terminate when simplifying symbolic powers of reciprocals', function () { + const expressions = [ + ['(1/(x*(y-1)))^(1/(y-1))', '(1/x/(y-1))^(1/(y-1))'], + ['(1/(x*y))^z', '(1/x/y)^z'], + ['(2/(x*y))^z', '2^z*(1/x/y)^z'], + ['(1/(2*x))^y', '(1/2/x)^y'], + ['(1/(x*y*z))^w', '(1/y/z/x)^w'] + ] + for (const [expression, expected] of expressions) { + let passes = 0 + const rules = [node => { + // Fail a non-terminating rule cycle without hanging the test runner. + assert.ok(++passes < 20, 'Simplification did not converge: ' + expression) + return node + }, ...math.simplify.rules] + const simplified = math.simplify(expression, rules) + assert.strictEqual(simplified.toString(), math.parse(expected).toString()) + const scope = { x: 2, y: 3, z: 0.5, w: 1.5 } + assert.ok(Math.abs(simplified.evaluate(scope) - math.evaluate(expression, scope)) < 1e-14) + } + }) + it('should handle non-existing functions like a pro', function () { simplifyAndCompare('foo(x)', 'foo(x)') simplifyAndCompare('foo(1)', 'foo(1)') @@ -466,6 +496,7 @@ describe('simplify', function () { simplifyAndCompare('x*y*x^(-1)', 'x*y*x^(-1)', {}, optsNCM) simplifyAndCompare('x*y/x', 'x*y*x^(-1)', {}, optsNCM) simplifyAndCompare('x*y*(1/x)', 'x*y*x^(-1)', {}, optsNCM) + simplifyAndCompare('(1/(x*y))^z', '(y^(-1)*x^(-1))^z', {}, optsNCM) // 'n+n->2*n' & 'n1*n3+n2*n3 -> (n1+n2)*n3' cannot apply for NCA simplifyAndCompare('z+2+z', 'z+2+z', {}, optsNCA) simplifyAndCompare('2*z+3+2*z', '2*z+3+2*z', {}, optsNCA)