moonnum.
DesignAlgorithms

Where the digits come from

Three procedures do all the work: turning a rational into rounded decimal digits, turning those digits into a laid-out field, and comparing two numbers of different kinds without rounding either. Everything else is bookkeeping.

One, the exact decimal kernel

A Double is a rational whose denominator is a power of two, and a Fraction is one already. So the kernel never sees a double: it sees num/den, both BigInt, and it produces digits by integer division only. No step of the conversion is itself rounded.

double sign · mantissa · exp num / den m·2^e over 1 or 2^−e floor_log10 exact exponent digits + decpt q, r = divmod(num · 10^k, den) round up when 2r > den, or 2r = den and q is odd 0.1 → 3602879701896397 / 2^55 → 55 exact digits, decpt 0 0.1000000000000000055511151231257827021181583404541015625 → "0.1"
The kernel, from bits to digits.

Two, three ways to choose digits

Everything that prints a float asks for one of three modes. The mode, not the caller, decides how the digits are rounded, and the layout step never re-rounds anything.

ModeDigitsUsed by
shortestthe fewest that read back as the same doublerepr_float, a specifier with no type and no precision
significantexactly n, half to even, trailing zeros droppede, E, g, G, n
fixedexactly n after the point, half to evenf, F, %

The digits then carry a decimal point position, decpt, and the choice between 0.00012 and 1.2e-04 is made on decpt alone. That is the same rule for every mode, which is why a specifier and a repr never disagree about where the exponent starts.

Three, the field

A formatted number is not a string that gets padded. It is six slots, filled in this order, and padding is a decision about which slot grows.

pad sign prefix zeros grouped digits point · frac · exp pad = puts padding in the “zeros” slot, and grouping reaches into it < > ^ put it outside, in the “pad” slots format(255, "#010_x") → 0x000_00ff
Six slots; a zero flag moves the fill inward, and the separators follow it.

Four, comparing across the tower

Comparison is where a numeric library usually lies. Converting the integer to a double to compare it loses the difference between 9007199254740992 and 9007199254740993, and that difference is exactly the sort of thing a big-integer library exists to keep. So compare_numeric never converts: it cross-multiplies.

moonbit
let a = @moonnum.parse("9007199254740993")
let b = @moonnum.parse("9007199254740992.0")
a.compare_numeric(b)
output
1     // greater, though both are the same double

NaN has no place in that order, so ordering against one is refused, and so is ordering a complex number at all. Equality is a different question and has an answer for every pair: nan != nan, and 1 == 1.0 == 3/3 == 1+0j.

Five, the two places complex numbers surprise

Division uses Smith's algorithm — divide both parts by whichever of the divisor's components is larger — because the textbook formula overflows and underflows long before the answer does. When a NaN appears where an infinity belongs, the C11 Annex G recovery step reconstructs it.

Powers with a small integer exponent are computed by repeated squaring rather than through the polar form, which is why (1+1j)² is exactly 2j. A fractional exponent has no such shortcut and goes through hypot, atan2 and exp, with the error that implies.

Six, what is deliberately not here

Three edges are out of scope, and each is a decision rather than an oversight: