Skip to content

Read abs and signum at zero, where both compositions are 0 (#892) - #899

Merged
Rafael-SOWNet merged 3 commits into
masterfrom
fix/abs-sgn-at-zero
Aug 12, 2026
Merged

Read abs and signum at zero, where both compositions are 0 (#892)#899
Rafael-SOWNet merged 3 commits into
masterfrom
fix/abs-sgn-at-zero

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

Closes #892. The fifth wrong answer of this class, and the third rule block found with a sound half and an unsound half written as though they were symmetric — after #884 and #887.

What was wrong

|sgn(z)| and sgn(|z|) are 1 for every z except 0, where both are 0 because sgn(0) is 0
— which the comment directly above one of the two rules already says. Both answered 1 for any
argument at all.

"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

The rules read the argument's DomainCondition, which answers a different question. 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 argument cannot be zero" with "the argument's zero is
outside its domain".

The fix, and what it deliberately is not

They now decide where the argument's value can be read — 0 if it vanishes, 1 if it does not — and
leave the node as written otherwise.

Not a Providedf. The expression is defined at zero and equal to 0 there, so
1 provided not z = 0 would replace a wrong value with a wrong domain. That is §3 of
Docs/Contributing/SimplificationContract.md, and the third time this week the same trap has come up.

Not a closed form either. abs(sgn(x)) is the indicator of x != 0, and sgn(abs(x)) is the
same function, so rewriting either into the other is circular. Decide, or decline.

One coverage loss, stated rather than hidden

signum(abs(x/x)) and abs(signum(x/x)) were 1 provided not x = 0, which is correct, and they
are now left as written. Separating that case from the x case needs "nonzero throughout its domain",
and neither test available inside InnerSimplify can express it: Evaled leaves x / x as x / x,
DomainCondition is what conflated them in the first place, and Simplify would answer it but must
not be called from there (#403). Recorded in the test file and in BREAKING-CHANGES.md rather than
left as a silent regression.

Four test rows moved for that reason and two because they pinned the wrong answer; the numeric cases
(abs(sgn(0))0, sgn(abs(-3))1) were already right and are unchanged.

Measured on this branch, .NET 10

C# tests 6333 passed, 0 failed, 14 skipped
F# tests 130 passed, 0 failed
casbench 117/119, 0 wrong, 0 error, 0 timeout
propcheck 0 failures
rootcheck 596/596 clean
simpsweep 10463/10463 agree, 0 disagree
boundcheck 7 → 4 disagreements

The four remaining boundcheck findings are all filed: #884's logarithm half, log(x, x)'s over-strong
condition on #890, and the known ln(x) + ln(x+1) widening.

Second commit: six stale .gitignore paths

/Tests/DotnetBenchmark/benchmark_results.csv is anchored at the repository root while the directory
is Sources/Tests/DotnetBenchmark/…, so it has been inert since f0db3eef moved everything under
Sources/ — and the benchmark's output has been appearing in git status ever since. It swept into
my first commit here, which is how I noticed; that commit adds the log and this one removes it, so the
squashed result contains neither.

Five neighbours had the same fault and are fixed with it. This is the third path in the repository
found still pointing at the pre-f0db3eef layout
, after amsite.fsx's publish path and
NaiveStaticGenerator's read of AngouriMath.xml (both fixed in the site repo). Worth a grep for
others.

|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.
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.
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.
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

This conflicts with #901 (abs folding where the argument's sign is known), which adds an arm to the
same Absf switch, immediately below the two this PR replaces. Both conflicts are adjacent additions
rather than disagreements, and I have measured the merge on a throwaway branch: 6342 tests passed /
0 failed
, boundcheck 44 shapes rewritten with 4 disagreements — the two removed here stay removed,
and #901 adds none.

Whichever merges second, the resolution is: keep both, and put this PR's
Signumf(var signOf) when ValueWithCondition(...) arm before #901's var argument when argument.Evaled is Real .... That one is a catch-all with a guard, so it belongs last; a C# switch
takes the first matching arm, and with the catch-all first abs(sgn(2)) would reach it and be handed
back its argument sgn(2) instead of 1. Same three files each time —
Evaluation.Continuous.Arithmetics.Classes.cs, SimplificationRegressionTest.cs,
BREAKING-CHANGES.md.

One measurement from that work belongs here too, since it is about this PR's issue rather than #901's:
on master alone, boundcheck reports 6 disagreements, and the report committed in the workspace says
four because it was generated on a build that already carried this fix. So the two abs(sgn(x)) /
sgn(abs(x)) entries are visibly still there on master, and go away exactly when this merges.

@Rafael-SOWNet
Rafael-SOWNet merged commit 64c8cfd into master Aug 12, 2026
25 checks passed
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.

abs(sgn(x)) and sgn(abs(x)) simplify to 1, and both are 0 at x = 0

1 participant