Cancel an inverse trigonometric function only on its own branch (#884) - #885
Merged
Conversation
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.
This was referenced Aug 11, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes three wrong answers.
Simplifycancelled an inverse trigonometric function against itsown 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.cshad this, with a comment stating an identity that is false:Outside the principal interval the composition folds back into it, so:
Simplifysaidarcsin(sin(x))atx = 330.14159...pi - 3arctan(tan(x))atx = 22-1.14159...2 - piarccos(cos(x))atx = 442.28318...2*pi - 4Simplifyclaims its output is another way of writing the same value, so these are the worstcategory 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]forarcsin,[0, pi]forarccos, and the corresponding open intervalsfor
arctanandarccotan, sincetanandcotanhave no value at the endpoints and neitherdoes the composition.
The exact answer that made the rule worth having is kept:
arcsin(sin(1/2))is still1/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 wherereturning
xis not.A condition was deliberately not attached.
x provided x >= -pi/2 and x <= pi/2would saythe 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 rightinverse and are
zwherever the inner function is defined, with no assumption needed. Theoriginal 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
pirather than halvingpi,because addition of two
EDecimals is exact and division is not. It is still only as good asthe working precision, and an argument that close to an endpoint is treated as outside — the
direction that declines to rewrite.
CircleTest.Test8was pinning the wrong answerIt asserted
arccotan(cotan(3x)) == 3x.arccotananswers in(0, pi), so atx = 2theexpression is about
2.86against6. 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
casbenchrootcheckpropchecksimpsweepThe 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 aty = 3*pi*ithe left side ispi*iwhileyis3*pi*i. I wrotethe guard and measured it — it costs two limits their answers:
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.csalready carries the
a^n/b^ncase behindIsEventuallyPositivefor that reason. The logarithmwants the same treatment in the same place, and
LogarithmExpanded— which already splitsln(b^e)for the limit machinery with no positivity check at all — is both its natural homeand 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, because1/2parses as a
Divfwhile the answer is aRationalandEntityequality is structural. Thetest simplifies both sides to get round it, and says so. A second concrete cost of
1/2havingno node of its own.