Fraction.js: Unexpected behavior for string input with high precision

A user of math.js noticed some unexpected behavior when creating a Fraction:

// works as expected:
new Fraction(4.166666666666667) // {s: 1, n: 25, d: 6}

// doesn't work as expected (the second value was coming from a BigNumber)
new Fraction('4.166666666666667') 
    // {s: 1, n: 4166666666666667, d: 1000000000000000}
new Fraction('4.166666666666666666666666666666666666666666666666666666666666667') 
    // {s: 1, n: 7602530730928912, d: 1824607375422939}

I guess this has something to do with round-off errors and the stop condition of the loop that tries to split the value into a fraction, but I don’t really understand it. Do you have any ideas why this happens?

About this issue

Most upvoted comments

Hey @a-zhendi, My thinking was that the ideal solution would be a polyfill. Yes, it is impossible to polyfill BigInt syntax, but it’s still possible to use the native BigInt implementation whenever it’s available. JSBI doesn’t do this, instead it uses its own implementation no matter what. The library I posted a link to, BigInteger.js, does exactly what I described, but the implementation it uses when native BigInt isn’t available might be slower than JSBI.

To really polyfill BigInts we could either a) use BigInteger.js, or b) detect whether the browser supports BigInt, if not use JSBI, else use native bigint.

I might do a performance test later today, so that we see how the variants compare. We might learn that there’s no real performance benefit in using native BigInts, then it would be reasonable to use just JSBI, without any polyfills.

Yes that’s true, all major browsers currently support BigInt already! That’s moving faster than I expected.

@josdejong I’m not in favor of introducing such an abstraction layer to fraction.js. However, I implemented a BigInt variation of fraction.js quite a while ago in file bigfraction.js, which runs all unit tests perfectly, but sets all operations on ES6 native BigInt implementation. I would go this route rather than implementing a switch, like I did in Polynomial.js to keep it as simple as possible.

And to come back to the initial call of issue #28:

new Fraction('4.166666666666667')
// { s: 1n, n: 4166666666666667n, d: 1000000000000000n }
new Fraction('4.166666666666666666666666666666666666666666666666666666666666667')
// { s: 1n,
//   n:
//    4166666666666666666666666666666666666666666666666666666666666667n,
//   d:
//    1000000000000000000000000000000000000000000000000000000000000000n }

Ah and what I changed is this silly NaN behavior. Since NaN ∉ BigInt, I reverted that decision that NaN ∈ Fraction. Based on a pull request somewhere in time we got Fraction(NaN)='NaN'. Now it is Fraction(NaN)=throw invalid param. This cleans the code a lot and a real NaN was rare anyways since parsers were throwing on invalid params already. Only NaN was a special case. And we somehow had a wrong test case with a 1500 digits repeating part in the test suite. BigInt now has the precision to cover that case correctly.

Boooooom, I invite all of you to test the file as much as possible: https://github.com/infusion/Fraction.js/blob/master/bigfraction.js

Only one test is failing, namely JSON.stringify does not know how to serialize BigInt, I expect that to be fixed in future versions of v8.

The old song ;D I started porting fraction.js to BigInt, but still a way to go to make it robust and pass all test cases. Since it is a string, Fraction.js interprets the string as a fixed length float and as such tries to pack the big number into a numerator and denominator AS IS. If it weren’t a string, it would be approximated using Farey Sequences (If you want an idea of it, I used them here as well: https://raw.org/puzzle/project-euler/problem-71/ ). Basically, the algorithm is super simple: Take the string as numerator without the dot and set the denominator to the 10th exponential according to the dot-position and cancel the fraction if possible.

So yeah, it is still the old problem that the internal representation of fractions is based on Number primitives, forced to work on integer scale - which finally screws up the numbers.