akubera / bigdecimal-rs

Arbitrary precision decimal crate for Rust
Other
275 stars 71 forks source link

parse_from_f64 is not precise #103

Closed sanpii closed 1 year ago

sanpii commented 1 year ago

With the last release (0.4.0), this code no longer works:

fn main() {
    let from_f64 = bigdecimal::BigDecimal::try_from(20_000.000001_f64).unwrap();
    let from_text = "20_000.000001".parse().unwrap();

    assert_eq!(from_f64, from_text);
}
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `BigDecimal("20000.0000010000003385357558727264404296875")`,
 right: `BigDecimal("20000.000001")`', src/main.rs:5:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
akubera commented 1 year ago

Yes, this is expected behavior now. The floating point value 20_000.000001_f64 cannot be "trusted" as a decimal value, as binary representations can only exactly represent numbers as sums of 2^-n (just how humans prefer decimal numbers, which only represent sums of 10^-n).

Putting 20000.000001 into https://baseconvert.com/ieee-754-floating-point confirms this is the correct result: 20000.0000010000003385357558727264404296875

Same with Python's Decimal library:

>>> from decimal import Decimal
>>> Decimal(20_000.000001)
Decimal('20000.0000010000003385357558727264404296875')

It's also true for 0.1:

>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

And I'm sure this is standard behavior for decimal libraries.

This is actually the primary reason you use decimal libraries, as the floating point values we are shown are often lies

>>> Decimal(0.3)
Decimal('0.299999999999999988897769753748434595763683319091796875')
>>> Decimal(0.1 + 0.2)
Decimal('0.3000000000000000444089209850062616169452667236328125')
>>> 0.3 < 0.1+0.2
True

vs dealing with sums of negative-two powers:

>>> 2**-1 + 2**-5
0.53125
>>> Decimal(2**-1 + 2**-5)
Decimal('0.53125')
akubera commented 1 year ago

This was the previous implementation, which wrote the given number to an allocated string in exponential form with fixed precision:

BigDecimal::from_str(
    &format!("{:.PRECISION$e}", n, PRECISION = ::std::f64::DIGITS as usize)
)

(std::f64::DIGITS == 15)

Which for your case would parse 2.000000000100000e4 (playground)

That implementation was replaced with a couple bit shifts and masks.

What specifically broke? Or was this just unexpected behavior?

akubera commented 1 year ago

Apparently BigDecimal Ruby gem disallows building BigDecimal from float without specifying precision: https://github.com/ruby/bigdecimal/blob/master/test/bigdecimal/test_bigdecimal.rb#L167

That doesn't really work for us if we want to keep the trait.

sanpii commented 1 year ago

Ok, thank you for your explanation.

That breaks unit tests in my case, nothing critical.