Skip to content

Fix #336: gate pow-of-pow exponent composition on the real domain - #462

Open
petlenz wants to merge 2 commits into
mainfrom
fix-336-pow-pow-domain
Open

petlenz wants to merge 2 commits into
mainfrom
fix-336-pow-pow-domain

Conversation

@petlenz

@petlenz petlenz commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Closes #336. Closes #463.

(x^a)^b = x^(a·b) holds for every real x only when a and b are integers; otherwise it needs x ≥ 0. Three folds composed exponents with no such check, so they either changed the value or invented one where the expression is undefined:

Expression (x = −4) Before Now True
pow(pow(x,2), 1/2) folds to x, −4 unfolded, 4 4 (|x|)
sqrt(pow(x,2)) (same expression, other spelling) 4 4 4
pow(pow(x,1/2), 2) folds to x, −4 unfolded, NaN undefined
pow(pow(x,3), 1/3) folds to x, −4 unfolded, NaN undefined
pow(pow(x,1/2)*y, 2) x*pow(y,2), −16 unfolded, NaN undefined
pow(sqrt(x), 2) folds to x, −4 unfolded, NaN undefined
pow(pow(x,y), 2), y = 1/2 at eval folds, −4 unfolded, 4 4

Condition implemented

Fold when either:

  1. the base is provably nonnegative — a nonnegative numeric constant, or (scalar) is_nonnegative/is_positive; or
  2. both exponents are provably integer — a numeric integer, or (scalar) a symbol carrying integer{}.

Why this shape:

Sites gated

Existing tests updated

Each old assertion pinned a fold that is wrong for a negative base; each new one states the domain condition instead. All verified numerically through the evaluator.

Location Old New Why the new one is correct
ScalarExpressionTest.h:169 pow(pow(x,_2),-y) → pow(x,-2*y) → pow(pow(x,2),-y); added pd nonneg → pow(pd,-2*y) y may be 1/2: (x²)^(−1/2) is 1/|x| = 0.25 at x = −4, while x^(−1) = −0.25
:170 pow(pow(x,_3),-y) → pow(x,-3*y) → unfolded same, odd inner exponent does not help: (x³)^(−1/3) is NaN
:171 pow(pow(x,_2),-x) → pow(x,-2*x) → unfolded exponent -x is symbolic, may be fractional
:172 pow(pow(x,y),-z) → pow(x,-y*z) → unfolded; added pow(pow(pd,y),-z) → pow(pd,-y*z) both exponents symbolic; a nonnegative base lifts it
:173 pow(pow(x,y),-x) → pow(x,-x*y) → unfolded same
:367 pow(pow(x,y),z) → pow(x,y*z) → unfolded; added pb nonneg folded, and m,n assumed integer → pow(x,m*n) y = 2, z = 1/2 reproduces the #336 bug; the two added cases show both escapes
:368 pow(pow(x,y),_2) → pow(x,2*y) → unfolded y = 1/2 gives (x^(1/2))², NaN for x < 0, while x^1 = −4
:369 pow(pow(x,_2),y) → pow(x,2*y) → unfolded y = 1/2 is the reported bug exactly
:388 pow(x*pow(y,z),_2) → pow(x,2)*pow(y,2*z) → unfolded; added nonneg-base companion extraction composes z with 2, same condition
:805 pow(sqrt(x),_2) → x → pow(sqrt(x),2); added p nonneg → p sqrt(−4) is NaN, so the left side is undefined where x is −4
:807 pow(sqrt(x),_3) → pow(x,3/2) → unfolded; added p nonneg → pow(p,3/2) same
TensorToScalarExpressionTest.h:267 pow(pow(trX,x),_2) → pow(tr(X),2*x) → pow(pow(tr(X),x),2) x may be 1/2 and tr(X) may be negative; the t2s path cannot query either assumption

Unchanged and still folding: every both-integer case (:365-366, :372-374, :161-163, :167, t2s :264-266), since integer exponents compose for any real base.

New tests

ScalarEval.PowOfPowKeepsRealDomain, ScalarEval.PowOfMulKeepsRealDomain, ScalarEval.PowOfSqrtKeepsRealDomain, ScalarEval.PowOfPowSymbolicExponentsKeepRealDomain, T2sEval.PowOfPowKeepsRealDomain, AssumptionFixture.PowOfPowFoldsForNonnegativeBase, AssumptionFixture.PowOfPowKeepsFractionalExponentsForUnknownSign — numeric via the evaluators, covering negative base × {even, odd} inner × {integral, fractional} outer, symbolic exponents that are fractional at evaluation time, both nonnegative-base and assumed-integer escapes, and both spellings agreeing.

Negative control (production sources restored from main, tests kept): every new test and all four updated test bodies fail. PowOfPowFoldsForNonnegativeBase passes both ways by design — it pins folding that must not regress.

Full suite 2412/2412 (gcc-14 Debug), clang-tidy clean with --warnings-as-errors='*' on the changed .cpp, clang-format clean.

(x^a)^b = x^(a*b) holds for every real x only when a and b are integers.
With a visibly fractional exponent the fold changed the value — (x^2)^(1/2)
folded to x instead of |x|, so it evaluated -4 where sqrt(x^2) gave 4 — or
invented one where the expression is undefined, as (x^(1/2))^2 did for x < 0.

pow_pow now folds only when the base is provably nonnegative or neither
exponent is a fractional constant. Extracting pow(z,c) out of
pow(x*pow(z,c), n) composes exponents the same way, so mul_pow applies the
same check per factor.

Signed-off-by: petlenz <peterlenz89.pl@gmail.com>
Two folds still composed exponents on an unknown-sign base. pow(sqrt(x), 2)
gave x where sqrt(x) is NaN for x < 0, and pow(pow(x,y),z) multiplied
symbolic exponents that may be fractional at evaluation time — y = 2,
z = 1/2 reproduces the original (x^2)^(1/2) = |x| bug.

Both now fold only when the base is provably nonnegative; exponent
composition also accepts exponents provably integer, numeric or assumed.
The tests that pinned the old behavior now assert the unfolded form for an
unknown-sign base and the folded form for an assumed one.

Signed-off-by: petlenz <peterlenz89.pl@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant