Make a logarithm behave like the division it is defined as (#890) - #893
Merged
Conversation
log_b(z) is ln(z)/ln(b), and three answers did not follow from that. log(1, 1) was 0. It is 0/0, and every division by zero in this library is NaN -- 0/0, 2/0 and -2/0 all are. The cause was the real shortcut in Number.Log: LogN answers 0 for log_1(1) and +oo for log_1(2), where falling through to Ln(x)/Ln(base) gives NaN for both. A base of 1 is now excluded from the shortcut, so the two paths agree. log(b, 1) was 0 for any base at all, including 1. It is 0/ln(b), which is 0 for every base but 1, so the answer carries `not b = 1`. A condition is right here, unlike the interval cases in #884: at b = 1 the expression genuinely is undefined rather than merely something else. log(b, 0) was -oo for any base. It is -oo/ln(b), so the sign of the answer is the sign of ln(b): -oo above 1 and +oo between 0 and 1, which made log(1/2, 0) wrong in exact form. It now answers where the base can be placed on one side of 1 and leaves the node alone otherwise, there being no signed answer to give for a base it cannot place. boundcheck gains the six degenerate logarithm shapes, and -- more usefully -- the two points that make a rule's own arithmetic rather than its branch degenerate, x = 1 and x = 0. All 366 existing shapes had passed at 23 points chosen for branch cuts and principal intervals; the two new points immediately turned up abs(sgn(x)) and sgn(abs(x)) both simplifying to 1 where they are 0, filed as #892. Measured: 6280 C# tests pass, 130 F# tests pass, casbench 117/119 with 0 wrong, propcheck 1340 checks with 0 failures, rootcheck 596/596, boundcheck unchanged at the five master already had.
Two conflicts, both additive on each side, so both sides are kept. BREAKING-CHANGES.md: #888's arccotan row and section went in at the same anchors as the logarithm ones. The arccotan entries come first, continuing the inverse-trigonometric entry above them. SimplificationRegressionTest.cs: #888's arccotan cases and this branch's logarithm cases were appended at the same point.
Rafael-SOWNet
added a commit
that referenced
this pull request
Aug 12, 2026
* Read abs and signum at zero, where both compositions are 0 (#892) |sgn(z)| and sgn(|z|) are 1 for every z except 0, where both are 0 -- sgn(0) is 0, which the comment directly above one of these rules already said. Both answered 1 for any argument, so a symbolic one gave a value that is wrong at one point: abs(sgn(x)) and sgn(abs(x)) simplified to 1. The rules read the argument's DomainCondition, which is a different question from the one they needed. A bare x is defined everywhere and can itself be zero; x/x is defined only away from zero and is nonzero throughout. Reading the domain conflates the two. They now decide where the argument's value can be read and leave the node alone otherwise. A condition would have been the wrong fix, as in #884 and #887: the expression is defined at zero and equal to 0 there, so `1 provided not z = 0` would trade a wrong value for a wrong domain, and there is no closed form for "1 away from zero, 0 at it" other than the expressions themselves. The four x/x cases go with them, and that is a coverage loss rather than a correction: 1 provided not x = 0 was right for those. Telling them apart needs "nonzero throughout its domain", and the two tests available here cannot -- Evaled leaves x/x as x/x, and DomainCondition is what conflated them. Simplify would answer it and must not be called from inside InnerSimplify (#403). Written down in the test file rather than left as a silent regression. This is the third rule block in two days found to have a sound half and an unsound half written as though they were symmetric, after #884 and #887. Measured: 6333 C# tests pass, 130 F# tests pass, casbench 117/119 with 0 wrong, propcheck 0 failures, rootcheck 596/596, simpsweep 10463/10463 agreeing, and boundcheck 7 disagreements down to 4 -- the two this fixes, plus the two the merged #888 and #893 fixed. * Point six .gitignore entries at where the files actually are The previous commit swept in a 1281-line BenchmarkDotNet log, because /Tests/DotnetBenchmark/benchmark_results.csv is anchored at the repository root and the directory is Sources/Tests/DotnetBenchmark/benchmark_results.csv. The entry has been inert since f0db3ee moved everything under Sources/, so the benchmark's output has been showing up in git status -- and in git add -A -- ever since. Five neighbours had the same problem and are corrected with it. The Sources/Samples entries a few lines below were already updated, which is why the file looked fine at a glance. Third path in this repository found pointing at the pre-f0db3eef layout, after amsite.fsx's publish path and NaiveStaticGenerator's read of AngouriMath.xml. Worth grepping for others. * Measure what 2.0.0 shipped without, and correct the ignore fix 2.0.0 has no column in this file: it is commit 1691 and the table stopped at the 1671st. The Math OS plan (#746) lists speed on popular use cases as a standing condition on every tier rather than a roadmap item, and the release met the correctness half of that exhaustively and the performance half not at all. Filling the gap with one new column would have been worse than leaving it. Measured here, every one of the sixteen rows came out at 0.5-0.6x of the 1671st -- a uniform factor, which is a faster machine and not faster code, and a reader comparing the columns would have seen a 1.75x improvement that does not exist. That is the mistake this file's own header warns about, so the 1671st was re-measured on the same machine minutes later and both are recorded as a pair that may be read as a ratio. The answer: no regression. Most rows move 1-5%, which by this file's standard says nothing. SimplifyEasy is +8.7%, which is above it, and the cause is likely ours rather than mysterious -- the corrections merged since the 1671st added guards that call Evaled inside a pattern's when clause on a path that used to match structurally. Recorded as a row to watch, with a note not to write more guards that way without measuring. Also corrects the previous commit here. Of the six .gitignore entries it repointed at Sources/, four name directories that no longer exist anywhere in the tree, so it made dead configuration look current. Those are deleted along with three more of the same kind; what remains are the two that match something real -- the benchmark's output directory, which is what started this, and the local ANTLR download.
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.
log_b(z)isln(z) / ln(b). Three answers did not follow from that, and one of them was0whereevery division by zero in this library is
NaN.Closes the second half of #890 — the wrong answers. The first half, the disagreement between
DomainConditionand evaluation, needs a decision and is left open.What changed
log(1, 1)0NaN— it is0/0log(1, 2)+ooNaN— dividing byln 1 = 0has no signed answerlog(b, 1)0for any base, including 10 provided not b = 1log(1/2, 0), any base below 1-oo+oolog(2, 0), any base above 1-oo, unchangedlog(x, 0)symbolic-oo0/0,2/0and-2/0are allNaNhere, so the library already has a settled convention fordividing by zero and the logarithm simply was not following it.
Causes, which are two different ones
Number.Loghad a shortcut that disagreed with its own fallback. For a positive real base it usesEDecimal.LogN, andLogNanswers0forlog_1(1)and+ooforlog_1(2)— where falling throughto
Ln(x)/Ln(base), three lines below, givesNaNfor both. A base of1is now excluded from theshortcut, so the two paths agree. That is the whole fix for the first two rows: no new arithmetic,
just stop taking a shortcut that is wrong at one point.
Two arms of
Logf.InnerSimplifystated no assumption.log(b, 1) -> 0is0/ln(b), which is0for every base but
1;log(b, 0) -> -oois-oo/ln(b), whose sign follows the sign ofln(b),so it is
+oofor a base between 0 and 1.A condition is right for
log(b, 1), and this is worth being explicit about because the twoprevious fixes in this family went the other way. In #884 and #887 attaching a condition would have
been wrong, because
arcsin(sin(3))is defined and merely has a different value. Here, atb = 1, theexpression is genuinely undefined — so narrowing the domain is what the mathematics says, and
0 provided not b = 1is the honest answer. The contract's §3 is the distinction being applied.For
log(b, 0)with a base whose side of 1 cannot be read, there is no signed answer to give, so thenode is left as written.
boundcheck gains the points that would have caught this
The harness had 366 shapes passing at 23 points chosen for branch cuts and principal intervals —
and none of them was
x = 1, solog(x, 1) -> 0looked sound at every point tried. That is a real gapin the harness, not just in the rule: "boundary" has to include the points where an identity's own
arithmetic degenerates, not only where a branch is crossed.
Added
x = 1andx = 0, plus the six degenerate logarithm shapes. The two new points immediatelyturned up two more wrong answers, filed as #892:
That is the third instance of one shape after #884 and #887 — an identity that holds off a thin set,
written as though it held everywhere. Not fixed here; #892 has the analysis, including that the
neighbouring
sgn(z)*|z| -> zin the same file is sound at zero, so that file has the samesound-half/unsound-half split as the other two.
Measured on this branch, .NET 10
casbenchpropcheckrootchecksimpsweepboundcheckTen new regression cases:
log(1, 1)isNaNthrough bothSimplifyandEvalNumerical, theordinary bases still collapse to
0, the symbolic base carries its condition and evaluates toNaNat
x = 1,log(b, 0)follows the sign of its base, and the symbolic case is left alone.