Skip to content

Cancel an inverse trigonometric function only on its own branch (#884) - #885

Merged
Rafael-SOWNet merged 1 commit into
masterfrom
fix/principal-branch-identities
Aug 11, 2026
Merged

Cancel an inverse trigonometric function only on its own branch (#884)#885
Rafael-SOWNet merged 1 commit into
masterfrom
fix/principal-branch-identities

Conversation

@Rafael-SOWNet

@Rafael-SOWNet Rafael-SOWNet commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes three wrong answers. Simplify cancelled an inverse trigonometric function against its
own function unconditionally, and that identity holds only on the interval the inverse answers
in.

Closes #884's trigonometric half. The logarithm half of that issue is deliberately not here;
see below.

What was wrong

Patterns.Trigonometry.cs had this, with a comment stating an identity that is false:

// arcfunc(func(x)) = x
Arcsinf(Sinf(var any1)) => any1,
Arccosf(Cosf(var any1)) => any1,
Arctanf(Tanf(var any1)) => any1,
Arccotanf(Cotanf(var any1)) => any1,

Outside the principal interval the composition folds back into it, so:

Simplify said the value is
arcsin(sin(x)) at x = 3 3 0.14159... pi - 3
arctan(tan(x)) at x = 2 2 -1.14159... 2 - pi
arccos(cos(x)) at x = 4 4 2.28318... 2*pi - 4

Simplify claims its output is another way of writing the same value, so these are the worst
category in AGENTS.md — not a missing answer but a wrong one. The rules date from 6b662539
(2020-10-11) and are present in v1.4.0.

The fix

They fire only where the argument is a real number that can be placed inside the interval —
[-pi/2, pi/2] for arcsin, [0, pi] for arccos, and the corresponding open intervals
for arctan and arccotan, since tan and cotan have no value at the endpoints and neither
does the composition.

The exact answer that made the rule worth having is kept: arcsin(sin(1/2)) is still 1/2,
not a decimal. A symbolic argument is left as written, which is what SymPy
(asin(sin(x)) stays) and Mathematica both answer, and declining is a legitimate answer where
returning x is not.

A condition was deliberately not attached. x provided x >= -pi/2 and x <= pi/2 would say
the expression is undefined outside the interval, and it is not — it has a different value.
That replaces a wrong value with a wrong domain.

The other direction is untouched, and there is now a test holding it there:
sin(arcsin(z)), cos(arccos(z)), tan(arctan(z)), cotan(arccotan(z)) compose the right
inverse and are z wherever the inner function is defined, with no assumption needed. The
original block had one sound half and one unsound half written as though they were symmetric,
which is how it survived.

The interval test doubles the argument and compares against pi rather than halving pi,
because addition of two EDecimals is exact and division is not. It is still only as good as
the working precision, and an argument that close to an endpoint is treated as outside — the
direction that declines to rewrite.

CircleTest.Test8 was pinning the wrong answer

It asserted arccotan(cotan(3x)) == 3x. arccotan answers in (0, pi), so at x = 2 the
expression is about 2.86 against 6. It now asserts the expression is left alone. That is the
"the test was pinning a fudge" case from the contributing notes, and it is called out in the
commit message and in BREAKING-CHANGES.md.

Measured on this branch, .NET 10

C# tests 6270 passed, 0 failed, 14 skipped
F# tests 130 passed, 0 failed
casbench 117/119 with an elementary answer, 0 wrong, 0 error, 0 timeout — unchanged
rootcheck 596/596 clean, 0 incomplete, 0 unsound
propcheck 1340 checks, 0 failures
simpsweep 10463/10463 agree, 0 disagree, 0 timed out

The eight new regression cases assert the value at a point outside the interval rather than
a printed form, since the value is what the bug was. Five more pin that the cancellation still
happens, and exactly, inside the interval.

Why the logarithm half of #884 is not here

log_b(a^c) -> c * log_b(a) (Patterns.Power.cs:158) is unsound the same way: ln(e^y)
simplifies to y, and at y = 3*pi*i the left side is pi*i while y is 3*pi*i. I wrote
the guard and measured it — it costs two limits their answers:

lim x->+oo x^x / e^(x*ln(x) - ln(x))     is +oo,  becomes unevaluated
lim x->+oo (x^2)^x / e^(2*x*ln(x))       is 1,    becomes unevaluated

Unevaluated is honest rather than wrong, so that is a coverage loss and not a new defect — but
it is a loss for two limits that work today, and it is not the real choice. This is #802
exactly: the limit machinery needs the identity only in a neighbourhood of the destination,
where it is checkable because the base is eventually positive, and Limits/Transformations.cs
already carries the a^n/b^n case behind IsEventuallyPositive for that reason. The logarithm
wants the same treatment in the same place, and LogarithmExpanded — which already splits
ln(b^e) for the limit machinery with no positivity check at all — is both its natural home
and something to tighten in the same change.

That is written up with the measurements in
#884 so it is
a defined piece of work rather than a note in a diff, and it is kept out of here because this
change fixes three wrong answers and does not touch the logarithm.

One aside worth having

Writing the principal-branch test ran straight into #873:
Assert.Equal("1/2".ToEntity(), "arcsin(sin(1/2))".ToEntity().Simplify()) fails, because 1/2
parses as a Divf while the answer is a Rational and Entity equality is structural. The
test simplifies both sides to get round it, and says so. A second concrete cost of 1/2 having
no node of its own.

arcsin is a left inverse of sin on [-pi/2, pi/2] and nowhere else, and the three
siblings likewise on their own intervals. The four rewrites were unconditional, and
the comment above them stated the identity as though it held everywhere, so Simplify
returned a wrong value at ordinary real points: arcsin(sin(3)) came back 3 where the
expression is pi - 3, arccos(cos(4)) came back 4 where it is 2*pi - 4, and
arctan(tan(2)) came back 2 where it is 2 - pi.

They now fire only where the argument is a real number this can place inside the
interval, which keeps the exact answer that made the rule worth having --
arcsin(sin(1/2)) is 1/2 and not a decimal -- and leaves a symbolic argument as
written. SymPy and Mathematica both answer that way.

Attaching the interval as a condition would have been a second wrong answer rather
than a fix: the composition is defined for every real x, so `provided x >= -pi/2 and
x <= pi/2` claims it is undefined where it merely has another value.

The other direction of the block is sound and is untouched. sin(arcsin(z)) composes
the right inverse, so it is z wherever arcsin(z) is defined at all, and there is now
a test holding it there so that guarding one half cannot take the other with it.

CircleTest.Test8 asserted arccotan(cotan(3x)) == 3x. That is not what the expression
is -- arccotan answers in (0, pi), so at x = 2 it is about 2.86 against 6 -- so the
test was pinning a wrong answer and now asserts the expression is left alone.

The interval test doubles the argument and compares against pi rather than halving
pi, because addition of two EDecimals is exact and division is not.

Found while making the wiki's samples checkable; the same issue records a second
rewrite of the same shape, log_b(a^c) -> c*log_b(a), which is not fixed here. Guarding
it costs two Gruntz limits their answers -- measured -- because the limit machinery
needs that identity in a neighbourhood and can justify it there. That is the #802
pattern and wants the same treatment, in the limit reader, as its own change.
@Rafael-SOWNet
Rafael-SOWNet merged commit 6cd6c08 into master Aug 11, 2026
25 checks passed
Rafael-SOWNet added a commit that referenced this pull request Aug 11, 2026
Four rewrites were wrong from 2020 until #885, and every harness was green while they
were: casbench 117/119 with no wrong answers, propcheck 1340 checks clean, rootcheck
596/596, simpsweep agreeing on 10463 comparisons -- and Simplify answering 3 for
arcsin(sin(3)), whose value is pi - 3. Sampling cannot find a rule that is wrong only
at a branch cut or only off the real line, because that is where a sweep does not look.
So the check has to be a reading, and a reading needs something to read against.

Transformations.md already asks a rewrite what relation it claims and how well
justified the claim is. Every rule set answers SoundUnderAssumptions and names no
assumptions, which is honest and says nothing. This is the missing third: the
assumption set, and ten obligations a rule has to meet, numbered so a review can cite
them.

It is built from what the library already knows rather than proposing machinery.
DomainCondition is where an expression is defined; Codomain is the type of value a node
takes, Any for a bare symbol; MathS.Settings.Codomain is how the expression is being
read, complex unless asked; Providedf carries a condition on the value. Four different
things, and the file's central point is that conflating them is how rules go wrong --
with the trap that looks most like a fix spelled out, since attaching an interval to
arcsin(sin(x)) as a Providedf would claim the expression is undefined outside it when
it merely has another value there.

The branch-cut conventions are measured on a 2.0.0 build rather than asserted, and one
of them is a trap worth publishing: for a negative base, an exact rational exponent
with an odd denominator takes the real root -- (-8)^(1/3) is -2 -- while the same
exponent written as a decimal takes the principal value, 1 + 1.732i. A rewrite that
moves between the two changes the answer.

Two inconsistencies fall out of writing it, recorded rather than fixed: ln reports its
domain by the real reading (x > 0) while sqrt reports the complex one (True) under a
default complex codomain, and DomainCondition cannot read the setting at all, which is
#721 in a second place.
Rafael-SOWNet added a commit that referenced this pull request Aug 11, 2026
…888)

This library's arccotan is arctan(1/x) extended by arccotan(0) = pi/2, so its range is
(-pi/2, pi/2] and not the (0, pi) many texts use: arccotan(-1) is -pi/4. Two rewrites
assumed otherwise.

arctan(x) + arccotan(x) was pi/2 unconditionally, which is a wrong answer at every
negative real -- the sum is -pi/2 there. pi/2 * sgn(x) is the closed form and is wrong at
exactly one point, x = 0, where the sum is pi/2 and sgn is 0, so the sign is decided
where it can be read and the sum left alone otherwise. A Piecewise would be total, but
Compile throws on one, so answering that way would stop expressions compiling that
compile today. The neighbouring arcsin(x) + arccos(x) is unconditional and correct,
because arccos(x) is pi/2 - arcsin(x) by definition over the whole plane; the block had
a sound half and an unsound half again.

arccotan(cotan(x)) was guarded with [0, pi] in #885, by me, on the same wrong assumption
about the range. That admitted (pi/2, pi), where the rewrite is false --
arccotan(cotan(2)) simplified to 2 and is 2 - pi -- and refused (-pi/2, 0), where it is
true. The interval is now (-pi/2, pi/2] without zero, zero excluded because cotan has no
value there, so the composition has none either and rewriting to x would invent one. The
other three intervals from #885 check out against the same measurements.

Three tests moved, each pinning the old wrong answer for a symbolic argument, and each
now covering both signs with numbers. SortSimplifyTest's case sorted its whole sum only
because the collapse shortened it; with the collapse gone the sum stays, and the sibling
arcsin case still collapses and still sorts.

Found by work/boundcheck on its first run, which is the point of it: the shapes come from
the nodes by reflection and the points are chosen where an assumption fails, so it found
an error in my own merged fix within minutes of existing.

Measured on this branch: 6296 C# tests pass, 130 F# tests pass, casbench 117/119 with 0
wrong, propcheck 1340 checks with 0 failures, rootcheck 596/596, simpsweep 10463/10463
agreeing, boundcheck 5 disagreements down to 4 -- the rest being #884's logarithm half
and two findings filed on #887.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Simplify returns wrong answers: arcsin(sin(x)) -> x and three siblings are applied off the principal branch

1 participant