RFA-308 · Case file with fixtures · Case 280 of 694 · Runtime evidence
f64::signum Distinguishes Negative Zero
IEEE floating-point zero carries a sign bit. Rust equality treats -0.0 and +0.0 as equal, but signum preserves their sign distinction by returning -1.0 or +1.0 respectively.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- all targets
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Floating-point zero retains an IEEE sign bit, and signum observes that encoded sign rather than classifying zero only through numeric comparison.
- First discriminating check
- Test both signed zeros, NaN, infinities, and subnormal values and state whether the domain preserves or canonicalizes zero sign.
I normalized a direction with value.signum() and expected every zero to produce positive one or perhaps zero. One calculation produced -0.0, which compares equal to 0.0, yet signum() returned -1.0. The sign bit had survived even though ordinary equality hid the distinction.
The failing program asserts that negative zero has positive signum. Rust returns negative one.
Floating-point zero has two signed representations
IEEE floating-point encodes a sign bit even when exponent and fraction encode zero. Rust therefore has positive zero and negative zero. They compare equal with ==, and both satisfy value == 0.0.
f64::signum returns 1.0 when the number has a positive sign and -1.0 when it has a negative sign. That includes positive and negative zero. It returns NaN for NaN.
The operation answers “which sign is encoded?” rather than “is this value mathematically below zero?” Those questions diverge at zero and NaN.
Comparisons do not recover the sign bit
This branch erases the zero distinction:
if value < 0.0 { -1.0 } else { 1.0 }
Negative zero is not less than zero, so the result is positive one. That may be the desired domain policy, but it is not equivalent to signum.
is_sign_negative inspects the sign and returns true for negative zero. I use it when sign-bit identity is explicitly meaningful. I use numeric comparisons when the domain wants ordered magnitude semantics.
The names in my code reflect that decision: encoded_sign and direction_after_zero_normalization should not be the same helper by accident.
Negative zero can be produced by normal calculations
It is not only a parsed curiosity. Underflow, negation, multiplication or division involving signed operands, and some mathematical functions can produce it. Serializers and external systems may also preserve or canonicalize it differently.
Reciprocal makes the distinction visible: positive zero leads toward positive infinity and negative zero toward negative infinity under IEEE behavior. Some branch cuts in complex or transcendental calculations use the direction from which zero was approached.
For physical directions, finance, and user interfaces, this distinction may be unwanted. I canonicalize zero only at the domain boundary that says the two forms are equivalent:
let normalized = if value == 0.0 { 0.0 } else { value };
That intentionally creates positive zero. I do not apply it blindly inside numerical algorithms that use signed zero information.
Hashing and ordering need deliberate policies
Floating-point values do not implement ordinary total Ord because NaN and IEEE comparisons complicate ordering. total_cmp provides a total order and distinguishes negative zero from positive zero.
If I build a key wrapper, deduplication rule, or serialized canonical form, I must decide whether the zeros are one domain value or two representations. Equality, ordering, and hashing must agree with that decision.
Comparing raw bits preserves every NaN payload and signed zero. Canonicalizing can improve stable caches and interchange, but changes information. There is no context-free best policy.
Signum also needs a NaN decision
NaN.signum() is NaN. Testing it with equality against NaN fails because NaN is not equal to itself. The correct check for this behavior is is_nan().
A control-flow expression such as if signum > 0.0 takes neither meaningful direction for a NaN result. If non-finite input is invalid, I reject it with is_finite() before deriving a direction. If it is allowed, the result type may need an explicit unknown state rather than another floating number.
I avoid using signum as a compact three-way comparator. Zero does not yield zero, and NaN does not fit an ordinary order.
What I test
The repaired program verifies negative zero maps to negative one, positive zero maps to positive one, and NaN stays NaN.
For a direction helper I also test finite positive and negative values, both infinities, the smallest subnormal values with each sign, both zeros, and NaN. If the contract canonicalizes zero, I assert the result's bits or sign predicate rather than relying only on equality.
At serialization boundaries I round-trip -0.0 through the actual format and downstream reader. Some textual formats emit -0, some parse it back with the sign, and some schemas or consumers erase it.
The core principle is that floating-point equality and representation answer different questions. Negative zero equals positive zero numerically, while signum deliberately observes its negative sign. I decide whether the domain preserves that sign before choosing sign-bit methods, comparisons, or explicit zero canonicalization.