RFA-287 · Case file with fixtures · Case 259 of 694 · Runtime evidence
f64::fract Keeps the Negative Sign
f64::fract is defined as self minus trunc(self), and trunc moves toward zero. For negative non-integers this produces a negative fraction; rem_euclid or floor-based decomposition serves a different contract.
- 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
- fract subtracts truncation toward zero, preserving the sign of a negative fractional component rather than applying Euclidean wrapping.
- First discriminating check
- Test paired positive and negative inputs and name whether the required result is signed displacement or a phase in a nonnegative interval.
I used fract while converting a signed coordinate into a position inside a repeating unit. Positive inputs worked, then a negative input produced -0.6. My downstream code required a value between zero and one.
The failing program calls f64::fract on -3.6 and expects a positive fraction. The assertion fails because fract follows truncation toward zero.
fract means subtract the truncated part
Rust documents the operation as self - self.trunc(). For positive and negative inputs:
3.6.trunc() = 3.0 so 3.6.fract() = 0.6
-3.6.trunc() = -3.0 so -3.6.fract() = -0.6
The fractional component keeps the input's sign for ordinary finite non-integers. This is a coherent decomposition because the truncated integer part plus the fraction reconstructs the original value.
The surprise usually comes from using “fractional part” to mean two different things: the remainder after truncation, or the positive phase inside a unit interval.
trunc and floor choose different integer parts
trunc rounds toward zero. floor rounds toward negative infinity. For -3.6, floor is -4.0, so subtracting the floor produces approximately 0.4.
Neither is universally more correct. A sign-preserving numeric decomposition often wants truncation. Array wrapping, periodic coordinates, time-of-day normalization, and hue rotation often want a Euclidean result in [0, 1).
I name the range requirement instead of asking vaguely for “the decimals.”
rem_euclid(1.0) expresses the positive phase
rem_euclid is the direct operation when I need a nonnegative remainder relative to a positive modulus:
let phase = value.rem_euclid(1.0);
For -3.6, this is approximately 0.4. For 3.6, it is approximately 0.6. This maps both directions into one repeating unit.
Using fract().abs() is not an equivalent repair. For -3.6 it gives 0.6, while the wrapped phase is 0.4. Absolute value throws away direction without applying Euclidean wrapping.
Floating-point assertions need tolerance
The decimal 3.6 usually has no exact binary floating-point representation. Subtraction can leave a value close to the expected decimal but not bit-for-bit equal to it.
The repaired fixture compares the absolute error with a small tolerance. In production, the acceptable tolerance should come from the scale and error budget of the computation. A copied magic epsilon can be too strict for large accumulated values or too loose for precise small ones.
When exact decimal fractions are a domain requirement, an integer minor unit or a decimal representation can be more suitable than f64.
Special values remain part of the contract
NaN, infinities, and signed zero need deliberate handling. A range check such as value >= 0.0 && value < 1.0 rejects NaN because comparisons with it are false, but an application should make that rejection explicit rather than rely on an incidental branch.
Signed zero can also remain observable in floating-point operations even though positive and negative zero compare equal. If values cross serialization, hashing, or foreign-function boundaries, I test the representation behavior that actually matters there.
For normalization code, I often validate is_finite() first. This gives invalid sensor data or parser results a named error instead of letting a NaN flow through several transformations.
Negative coordinates expose the semantic choice
This bug often hides because examples use only positive values. A texture coordinate of 3.6, a clock phase of 3.6, and a positive rotation all make fract and Euclidean wrapping appear equivalent. The first negative example separates them immediately.
I add paired inputs such as 3.6 and -3.6 when reviewing periodic calculations. The pair asks whether the operation should preserve the sign of displacement or wrap onto a positive cycle. It is a small test with more explanatory power than ten positive examples.
The same decision appears with integer %. Rust remainder follows truncating division, so a negative dividend can produce a negative remainder. rem_euclid is the explicit alternative for nonnegative modular coordinates. Keeping integer and floating-point normalization under the same named policy prevents two layers from disagreeing at zero.
What I test
The repaired program asserts that fract is approximately -0.6 and rem_euclid(1.0) is approximately 0.4. Showing both values makes the semantic choice visible.
My broader table includes positive and negative integers, positive and negative non-integers, values just around zero, large magnitudes, signed zero, non-finite inputs, and values already inside the desired interval. Property tests can assert that every accepted finite result of unit wrapping lies in [0, 1).
I also test reconstruction separately: trunc(x) + fract(x) should approximate x for the finite range the application supports. That property belongs to fract; the positive wrapped phase has another interpretation.
The core principle is that a familiar mathematical word does not specify a rounding rule. Rust's fract is tied to truncation toward zero. When I require a Euclidean phase, I state the output interval and use rem_euclid instead.