RFA-259 · Case file with fixtures · Case 231 of 694 · Runtime evidence
Why f64::round Sends Halfway Values Away from Zero
Rust f64::round uses halfway-away-from-zero, while round_ties_even implements banker's rounding. Choose and name the policy before aggregating, formatting, or converting values.
- 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
- f64::round uses a halfway-away-from-zero tie policy rather than the ties-to-even policy expected by the caller.
- First discriminating check
- Test positive and negative exact halves with round and round_ties_even before checking decimal representation issues.
Rounding is not one universal operation. The difficult cases are values exactly halfway between two integers.
The failing program expects 2.5_f64.round() to become two. f64::round returns three. For negative -2.5, it returns -3.0.
round uses halfway away from zero
Values whose fractional magnitude is exactly one half choose the integer with greater magnitude. Positive halves move upward; negative halves move downward on the number line, but both move away from zero.
This differs from “round half up” if that phrase means toward positive infinity for negative values. Naming only “normal rounding” is not precise enough.
For non-halfway inputs, familiar nearest-integer behavior can make several policies appear identical. Tests must include ties on both signs.
Ties-to-even is a different method
round_ties_even chooses the even neighboring integer at a tie. Then 2.5 becomes 2, while 3.5 becomes 4. This is often called banker's rounding and can reduce systematic bias across repeated balanced values.
The repaired program compares both methods for positive and negative ties. It does not claim one is universally superior; it makes the selected policy executable.
Rounding each item differs from rounding the total
Even with one tie rule, operation order changes results. Rounding every line item and then summing can differ from summing exact representations and rounding once.
For invoices, quotas, storage units, and percentages, I document where rounding occurs. A code review cannot validate the method name without knowing that boundary.
Distributed services must share the same rule and stage. Otherwise two correct implementations can disagree on reconciliation.
Binary floats rarely represent decimal expectations exactly
Values written with .5 are useful because binary floating point represents halves exactly in these ranges. Decimal values such as 2.675 may be stored slightly above or below the human decimal, so the apparent tie may not be a binary tie.
Rounding to two decimal places by multiplying, rounding, and dividing inherits representation and overflow issues. Financial domains often use scaled integers or decimal types with an explicit rounding mode.
I inspect the actual representation before calling an unexpected result a tie-rule problem.
Casting is truncation, not rounding
Converting a float to an integer with as does not call round; it truncates the fractional part toward zero and applies Rust's float-to-integer cast rules for out-of-range and NaN values.
trunc explicitly returns the integral part toward zero as a float. It is useful when that is truly the policy, not as a substitute for nearest rounding.
I round first only after checking range and non-finite inputs, then convert with an API whose failure behavior matches the boundary.
NaN, infinity, and signed zero remain floats
Rounding NaN returns NaN, and infinities remain infinite. Negative values near zero can produce negative zero, which compares equal to positive zero but retains a sign bit.
Formatting may hide these distinctions. If serialized output, hashing, or later reciprocal operations care, I test them directly rather than relying on displayed decimal strings.
For ordinary UI counts, I often reject non-finite inputs earlier and normalize zero only if the product contract permits it.
Version-independent policy needs a named helper
I wrap business rounding in a function such as round_invoice_ties_even or round_display_away_from_zero. The name records why a particular primitive was chosen and gives tests one stable place.
This also avoids scattered combinations of floor, ceil, added constants, and sign branches that often fail for negative values or large magnitudes.
What I test
My table includes positive and negative halves around even and odd integers, values just below and above each tie, zero, negative zero, large exactly representable values, NaN, and infinities where accepted.
For decimal-facing logic I use exact expected source values or a decimal representation rather than assuming every typed decimal literal is an exact tie.
Cross-language defaults need contract tests
Languages, databases, spreadsheets, and analytics engines do not all choose the same default tie rule. A Rust service can therefore disagree with a query used to verify it even when both call a function named round.
At integration boundaries I publish a small table of canonical inputs and outputs, including negative ties. Running that table in every implementation gives stronger evidence than comparing method names. It also catches a database upgrade or library replacement that changes a default rounding mode.
If the rounded number enters a signature or idempotency key, the rule becomes part of the protocol and must be versioned like any other serialization detail.
The core principle is that rounding requires a tie policy and an application boundary. Rust's f64::round chooses halfway away from zero; round_ties_even chooses the even neighbor. Making that choice explicit prevents a plausible number from changing between services, languages, or accounting stages.