Skip to content

Make a logarithm behave like the division it is defined as (#890) - #893

Merged
Rafael-SOWNet merged 2 commits into
masterfrom
fix/log-base-one
Aug 11, 2026
Merged

Make a logarithm behave like the division it is defined as (#890)#893
Rafael-SOWNet merged 2 commits into
masterfrom
fix/log-base-one

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

log_b(z) is ln(z) / ln(b). Three answers did not follow from that, and one of them was 0 where
every division by zero in this library is NaN.

Closes the second half of #890 — the wrong answers. The first half, the disagreement between
DomainCondition and evaluation, needs a decision and is left open.

What changed

was is
log(1, 1) 0 NaN — it is 0/0
log(1, 2) +oo NaN — dividing by ln 1 = 0 has no signed answer
log(b, 1) 0 for any base, including 1 0 provided not b = 1
log(1/2, 0), any base below 1 -oo +oo
log(2, 0), any base above 1 -oo, unchanged
log(x, 0) symbolic -oo left as written

0/0, 2/0 and -2/0 are all NaN here, so the library already has a settled convention for
dividing by zero and the logarithm simply was not following it.

Causes, which are two different ones

Number.Log had a shortcut that disagreed with its own fallback. For a positive real base it uses
EDecimal.LogN, and LogN answers 0 for log_1(1) and +oo for log_1(2) — where falling through
to Ln(x)/Ln(base), three lines below, gives NaN for both. A base of 1 is now excluded from the
shortcut, 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.InnerSimplify stated no assumption. log(b, 1) -> 0 is 0/ln(b), which is 0
for every base but 1; log(b, 0) -> -oo is -oo/ln(b), whose sign follows the sign of ln(b),
so it is +oo for a base between 0 and 1.

A condition is right for log(b, 1), and this is worth being explicit about because the two
previous 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, at b = 1, the
expression is genuinely undefined — so narrowing the domain is what the mathematics says, and
0 provided not b = 1 is 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 the
node 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, so log(x, 1) -> 0 looked sound at every point tried. That is a real gap
in 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 = 1 and x = 0, plus the six degenerate logarithm shapes. The two new points immediately
turned up two more wrong answers, filed as #892:

"abs(sgn(x))".Simplify()  =>  1        and at x = 0 the value is 0
"sgn(abs(x))".Simplify()  =>  1        and at x = 0 the value is 0

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| -> z in the same file is sound at zero, so that file has the same
sound-half/unsound-half split as the other two.

Measured on this branch, .NET 10

C# tests 6280 passed, 0 failed, 14 skipped
F# tests 130 passed, 0 failed
casbench 117/119, 0 wrong, 0 error, 0 timeout
propcheck 1340 checks, 0 failures
rootcheck 596/596 clean
simpsweep 10463/10463 agree, 0 disagree
boundcheck unchanged at the five master already has; the six new logarithm shapes pass

Ten new regression cases: log(1, 1) is NaN through both Simplify and EvalNumerical, the
ordinary bases still collapse to 0, the symbolic base carries its condition and evaluates to NaN
at x = 1, log(b, 0) follows the sign of its base, and the symbolic case is left alone.

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
Rafael-SOWNet merged commit ca8e9f5 into master Aug 11, 2026
25 checks passed
Rafael-SOWNet added a commit that referenced this pull request Aug 11, 2026
#893's logarithm section went into BREAKING-CHANGES.md at the same anchor this branch
uses, so both are kept -- as with #888 before it. Three of the entries in this file now
sit next to each other because three changes to the same table landed in the same
afternoon, which is a good problem to have.
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.
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.

1 participant