This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 13886b5c45 fix(arrow-buffer): round i256 to f64 correctly at f64
midpoints (#11315)
13886b5c45 is described below
commit 13886b5c4536654f4ac7211ef2d42bb1ac44d115
Author: Miguel Cordero Pamphile <[email protected]>
AuthorDate: Tue Oct 6 03:32:10 2026 +0000
fix(arrow-buffer): round i256 to f64 correctly at f64 midpoints (#11315)
# Which issue does this PR close?
- Closes #11314.
# Rationale for this change
`i256_to_f64` normalizes the input, keeps the top 64 bits and discards
the lower 192, so the final `i64 as f64` rounds a value truncated at 64
bits. When the discarded bits put the exact value just past a binary64
midpoint, the result can round to the wrong neighbor: `(1_i128 << 63) +
1024 + 1` returns `0x43e0000000000000` instead of `0x43e0000000000001`.
# What changes are included in this PR?
Set a sticky bit on the retained value when any discarded bit is
non-zero, so midpoint ties round to nearest (ties to even only apply
when the exact value is the midpoint). No API changes. A naive `top + 1`
variant was also checked and is incorrect, since it can move a value
that sits just below a midpoint up to the midpoint; setting the low bit
preserves the side of the midpoint.
# Are these changes tested?
- The reported value plus midpoint neighborhoods for every binary64
exponent in `i256` range, both signs, checked against
`to_string().parse::<f64>()`.
- `MIN`, `MAX`, `-1`, `0`, `1`, and 1000 deterministic pseudo-random
full-range values against the same oracle.
- `cargo test -p arrow-buffer` (358 passed) and `cargo test -p
arrow-cast --lib test_cast_decimal256` (12 passed); `cargo fmt --check`
and `cargo clippy -p arrow-buffer --all-targets -- -D warnings` clean.
# Are there any user-facing changes?
No API changes. `i256::to_f64` now returns the correctly rounded `f64`,
which also fixes `Decimal256 -> Float64` casts that use it.
# AI usage disclosure
Per CONTRIBUTING: the sticky-bit approach was suggested in the issue. I
reproduced the failure, validated the approach at scale against
correctly rounded conversions (midpoint sweeps and randomized
comparisons, including ruling out the `+1` variant), and wrote the
tests. AI assistance was used for analysis and drafting; I have reviewed
and verified every change and own the final implementation.
---------
Signed-off-by: Miguel Cordero Pamphile <[email protected]>
---
arrow-buffer/src/bigint/mod.rs | 57 ++++++++++++++++++++++++++++++++++++++++--
1 file changed, 55 insertions(+), 2 deletions(-)
diff --git a/arrow-buffer/src/bigint/mod.rs b/arrow-buffer/src/bigint/mod.rs
index eb1fad0abb..db996acfdb 100644
--- a/arrow-buffer/src/bigint/mod.rs
+++ b/arrow-buffer/src/bigint/mod.rs
@@ -677,7 +677,10 @@ impl i256 {
fn i256_to_f64(input: i256) -> f64 {
let k = i256::redundant_leading_sign_bits_i256(input);
let n = input << k; // left-justify (no redundant sign bits)
- let n = (n.high >> 64) as i64; // throw away the lower 192 bits
+ // If the retained bits land exactly on a midpoint, set the low bit so
+ // non-zero discarded bits push it just past and it rounds to nearest.
+ let sticky = n.low != 0 || (n.high as u64) != 0;
+ let n = ((n.high >> 64) as i64) | i64::from(sticky);
(n as f64) * f64::powi(2.0, 192 - (k as i32)) // convert to f64 and
scale it, as we left-shift k bit previous, so we need to scale it by 2^(192-k)
}
@@ -1342,7 +1345,7 @@ impl Not for i256 {
mod tests {
use super::*;
use num_traits::Signed;
- use rand::{RngExt, rng};
+ use rand::{RngExt, SeedableRng, rng, rngs::StdRng};
#[test]
fn test_signed_cmp() {
@@ -1877,6 +1880,56 @@ mod tests {
}
}
+ #[test]
+ fn test_i256_to_f64_midpoint_rounding() {
+ // Regression test for #11314: the value is one above the binary64
midpoint.
+ let integer = (1_i128 << 63) + 1024 + 1;
+ assert_eq!(
+ i256::from_i128(integer).to_f64().unwrap().to_bits(),
+ (integer as f64).to_bits()
+ );
+
+ let two = i256::from(2);
+ // Binary64 has a 53-bit significand: inside [2^e, 2^(e+1)) adjacent
values
+ // are 2^(e-52) apart, so the exact tie is 2^(e-53) above 2^e. Ties
are where
+ // #11314 rounded the wrong way. Start at 53 (below that the midpoint
is not
+ // an integer) and stop at 254 (2^255 overflows i256).
+ for exponent in 53..=254 {
+ let midpoint = two.wrapping_pow(exponent) +
two.wrapping_pow(exponent - 53);
+ for delta in -2_i64..=2 {
+ for value in [
+ midpoint + i256::from(delta),
+ -(midpoint + i256::from(delta)),
+ ] {
+ let expected: f64 = value.to_string().parse().unwrap();
+ assert_eq!(
+ value.to_f64().unwrap().to_bits(),
+ expected.to_bits(),
+ "i256 {value} should round to nearest"
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn test_i256_to_f64_fuzz() {
+ // `to_string().parse::<f64>()` is a correctly rounded
decimal-to-binary oracle.
+ for value in [i256::MIN, i256::MAX, i256::MINUS_ONE, i256::ZERO,
i256::ONE] {
+ let expected: f64 = value.to_string().parse().unwrap();
+ assert_eq!(value.to_f64().unwrap().to_bits(), expected.to_bits());
+ }
+
+ let mut rng = StdRng::seed_from_u64(42);
+ for _ in 0..1_000 {
+ let low = u128::from(rng.random::<u64>()) |
(u128::from(rng.random::<u64>()) << 64);
+ let high = u128::from(rng.random::<u64>()) |
(u128::from(rng.random::<u64>()) << 64);
+ let value = i256::from_parts(low, high as i128);
+ let expected: f64 = value.to_string().parse().unwrap();
+ assert_eq!(value.to_f64().unwrap().to_bits(), expected.to_bits());
+ }
+ }
+
#[test]
fn test_decimal256_to_f64_typical_values() {
let v = i256::from_i128(42_i128);