RFA-335 · Case file with fixtures · Case 307 of 694 · Runtime evidence
Float-to-Integer as Casts Saturate in Rust
Rust float-to-integer as casts truncate fractional parts, saturate out-of-range finite and infinite values, and map NaN to zero. Validate before casting when those distinct states matter.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- all Rust targets
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Rust float-to-integer as casts truncate fractional parts, saturate out-of-range values, and map NaN to zero under a distinct numeric-cast rule.
- First discriminating check
- Exercise fractional, upper-range, lower-range, infinite, and NaN values and validate before casting when the collapsed states have different meaning.
I knew that narrowing an integer with as can wrap through retained low bits, so I carried that model into floating-point casts. In modern Rust, a float-to-integer cast has a different rule.
The failing program expects 300.0_f64 as u8 to produce 44. Rust 1.98.1 produces 255.
Float casts truncate and saturate
The Rust Reference numeric-cast rules define float-to-integer conversion in stages. A finite fractional value rounds toward zero. A value above the integer maximum saturates to that maximum. A value below the minimum saturates to the minimum. NaN becomes zero.
For u8, this means 42.9 becomes 42, 300.0 becomes 255, a negative value becomes 0, and NaN also becomes 0.
The repaired fixture verifies all four branches rather than learning only one example.
Saturation can collapse distinct invalid inputs
Zero after casting may represent an exact zero, a small negative value, negative infinity, or NaN. Maximum may represent an exact maximum or any larger finite or infinite input.
If these states have different domain meaning, casting first destroys the evidence. A sensor error represented by NaN can become a valid-looking zero measurement. An unbounded request can become the largest accepted allocation or retry count.
I validate the float before converting. is_finite rejects NaN and infinities. Explicit range checks preserve whether the value was below or above policy bounds.
Truncation is not rounding to nearest
Rounding toward zero means 42.9 becomes 42 and -42.9 becomes -42 for a signed target. It is not floor for negative values and not ordinary “round half up.”
When a product requires nearest, floor, or ceiling behaviour, I apply that operation explicitly, check the resulting range, and then convert. The order matters because rounding can move a value onto or beyond a boundary.
For money and exact decimal quantities, binary floating point may be the wrong input representation before conversion even begins.
Integer-to-integer as uses another model
An integer narrowing cast discards high bits. That is why 300_u16 as u8 equals 44. The visually similar 300.0_f64 as u8 saturates to 255.
I avoid saying “as wraps” or “as saturates” without naming source and destination types. as covers several cast categories with separate rules.
A checked integer conversion through TryFrom is usually clearer when out-of-range values are errors.
Avoid unsafe conversion as a performance reflex
Floating-point primitives expose unsafe unchecked conversions on supported APIs. Their safety preconditions include validity and range requirements. Violating them is not a request for wrapping semantics; it can make the program unsound.
I first measure whether the safe cast matters. If a validated hot loop needs a lower-level operation, the proof that every input is finite and in range must be local, documented, and tested around boundaries.
Most request parsing and business logic benefits more from an explicit error than from removing a small conversion cost.
Configuration parsing should not use NaN as a secret default
Text parsers may accept strings representing NaN and infinity. If a configuration later casts the result, malformed operational intent can become a normal integer.
I reject non-finite values at the parsing boundary and state whether fractional values are accepted. If the setting is conceptually an integer, parsing directly into the integer type often gives better errors and avoids float semantics entirely.
Defaults are applied because a field is absent, not because conversion collapsed an invalid value to zero.
My boundary test table
For each target integer type I test its minimum and maximum as floats, values just inside and outside the accepted range, positive and negative fractions, both infinities, NaN, and negative zero if the domain distinguishes it before conversion.
Large integer boundaries may not be represented exactly as f64, so I derive tests carefully and compare them with the documented conversion rule rather than decimal intuition.
Property tests can assert that validated in-range integral floats round-trip. Out-of-domain cases should exercise errors before the cast.
What RFA-335 establishes
The fixture pins current stable semantics to Rust 1.98.1, though the Reference is the language-level authority. It does not depend on debug overflow checks or target instruction choice.
The core principle is that a conversion policy is part of data validation. Rust's float cast gives a total result by truncating, saturating, and mapping NaN to zero. This is convenient for low-level conversion, but I keep invalid states separate whenever zero and maximum are meaningful application values.