sysl

The rational module

sysl.math.rational — exact fractions over BigInt: lowest terms with a positive denominator, the four operations, floor and ceiling, an exact comparison against a real, and a correctly rounded conversion to one.

Every declaration in sysl.math.rational, with its signature: the generated API page. This page is the argument — what the module is for, and how its pieces fit; that one is the list.

A Rational is a fraction with no error: a numerator and a denominator, both BigInts. BigInt makes +, - and * exact over the integers; the rationals are the smallest set that keeps / exact as well, and every numeric tower that has one — Scheme’s, Haskell’s Rational, Python’s fractions — builds it out of exactly these two integers.

import sysl.math.rational.{ratio, to_string}

val third = ratio(1, 3)
val sixth = ratio(1, 6)

print(to_string(third + sixth))
print(to_string(ratio(6, -4)), to_string(ratio(10, 5)))
print(third < sixth, third * ratio(3, 1) == ratio(1, 1))
1/2
-3/2 2/1
false true

Lowest terms, and the denominator is positive

Every value is kept reduced by its gcd with the sign on the numerator, so every number has exactly one representation: 6/-4 is built as -3/2, and zero is 0/1 whatever it was computed from. That is what lets == compare the two parts field by field, Hash hash them, and the text print without anybody normalizing first. The text is always n/d — a whole number is 2/1, so it says which type it came from.

of(num, den) builds one from two BigInts and ratio(n, d) from two machine integers; from_int and from_big take a whole number, and From[long] and From[BigInt] are implemented. A zero denominator traps, and so does division by zero, as both do for a BigInt and for a machine integer: the mistake is the same one and the language’s answer for it is the same answer. A caller that cannot rule out a zero denominator builds with checked_of, which answers None for one and otherwise exactly what of does:

import sysl.math.bigint.from_int
import sysl.math.rational.{checked_of, to_string}

print(checked_of(from_int(1), from_int(0)).is_none())
print(to_string(checked_of(from_int(6), from_int(-4)).unwrap()))
true
-3/2

Every operation reduces its result, which is Euclid’s algorithm over BigInts and most of the cost of a sum. Without it a running sum of fractions grows its denominator without bound even when the value does not.

Back to an integer

floor, ceil and trunc round down, up and toward zero, and answer a BigInt. numerator() and denominator() hand back the two parts:

import sysl.math.bigint.to_string
import sysl.math.rational.{ceil, floor, ratio, trunc}

val x = ratio(-7, 2)

print(to_string(floor(x)), to_string(ceil(x)), to_string(trunc(x)))
print(to_string(x.numerator()), to_string(x.denominator()))
-4 -3 -3
-7 2

A real is read at its exact value

Every finite real is a rational — an integer times a power of two — so from_real and the comparisons take a double at its exact binary value, never at the shortest decimal that reads back as it. 0.1 is not a tenth, and this module says so:

import sysl.math.Float
import sysl.math.rational.{cmp_real, eq_real, from_real, lt_real, ratio, to_string}

print(to_string(from_real(0.1).unwrap()))
print(cmp_real(ratio(1, 10), 0.1), lt_real(ratio(1, 10), 0.1))
print(eq_real(ratio(1, 2), 0.5), cmp_real(ratio(1, 2), real.nan()))
3602879701896397/36028797018963968
Some(-1) true
true None

cmp_real answers None for a NaN, which is unordered against everything, and eq_real, lt_real and gt_real answer false for one. An infinity is above or below every rational. None of them converts the rational to a real, which rounds and so can call two different numbers equal.

And a real comes back correctly rounded

to_real gives the nearest real, a tie to the even one — the answer an infinitely precise division followed by one rounding would give. The numerator is scaled so one integer division yields a few bits more than the 53 a real keeps; the bits below those and the division’s remainder decide the rounding, on the integer side where it is exact. Below the normal range fewer bits are kept, so a subnormal is rounded once at its own last place rather than twice, and past the largest finite real the answer is an infinity.

import sysl.math.bigint.{add, from_int, shl}
import sysl.math.rational.{from_big, ratio, to_real}

print(f"${to_real(ratio(1, 3))}%.17g")

val odd = add(shl(from_int(1), 53), from_int(1))

print(f"${to_real(from_big(odd))}%.17g")
0.33333333333333331
9007199254740992

2^53 + 1 sits exactly halfway between two doubles and goes to the even one.

Search

Esc
↑↓ to navigate ↵ to open Esc to close