hanki

decimal

stdlib/core/decimal.hk: text into the decimal tier, and the bridges back out of it.

decimal is the arbitrary-precision, never-rounding tier (HANKI.md §3); its / widens to rational. parse is the way in from text, the tier an unsuffixed float literal already lands in, and it is therefore also the tier that reads a price or a quantity out of a file without rounding it. The rest are the explicit crossings out of the tier the numeric-tower section names, the counterpart to int's to_decimal. Integer crossings truncate toward zero (drop the fractional part, like a hardware cast); to_f64 rounds to nearest. The in-band sentinels carry across: to_int preserves them as int's sentinels, to_i64 saturates them, to_f64 maps them to the IEEE floats.

RoundingMode

type RoundingMode
  Up
  Down
  Ceiling
  Floor
  HalfUp
  HalfDown
  HalfEven
end

How to_fixed breaks a tie when the digits it must drop are not zero.

All seven bigdecimal modes are spelled, and not the two a caller usually wants: the extra five cost one arm each and the backing already exists, and a rounding surface that offers a subset is one people work around. Variant order matters: the runtime selects by tag.

Down is toward zero and Floor toward negative infinity, identical for positive values and different for negative ones, which is the distinction printf's %.2f truncation does not let you spell.

impl decimal

_parse

def _parse(s: string) -> Option<decimal>

The runtime seam under [FromString]: the raw yes-or-no parse, with no room to say why. parse below is the surface, and turns the None into a reason.

to_int

def to_int(self) -> int

Truncate toward zero into the unbounded int tier: the fractional part is dropped. Total (int has no range limit); the in-band sentinels pass through unchanged.

3.9.to_int()  => 3
(-3.9).to_int() => -3
2.0.to_int()  => 2

to_i64

def to_i64(self) -> i64

Truncate toward zero, then take the low 64 bits (modular, two's complement), the fixed-width narrowing contract (HANKI.md §3), matching int.to_i64. The spec's x.to_i64() // 10i64 recipe reaches integer arithmetic through this. The in-band sentinels saturate to the i64 range ends.

3.9.to_i64()    => 3i64
(-3.9).to_i64() => -3i64

to_f64

def to_f64(self) -> f64

The nearest f64, rounded to nearest (lossy beyond f64's precision). A magnitude beyond f64's range saturates to ±inf; the in-band sentinels map to the IEEE floats (inf/-inf/NaN).

3.5.to_f64()  => 3.5f64
(-2.5).to_f64() => -2.5f64

to_rational

def to_rational(self) -> rational

Widen to the rational tier. It is exact, a decimal being by definition unscaled / 10^scale and that ratio is a rational. The missing rung of int ⊂ decimal ⊂ rational (HANKI.md §3): int already widens both ways, and without this a decimal reached a rational parameter by neither conversion nor widening, which bites: dividing two decimals produces a rational.

The same conversion mixed arithmetic already uses to promote a decimal operand, and d.to_rational() and the d / 1 that promotes it therefore agree by construction. Sentinels pass through.

1.25.to_rational() => 5 / 4
0.1.to_rational() + 0.2.to_rational() => 3 / 10
(1.0 / 3.0) => 1.0.to_rational() / 3.0.to_rational()

abs

def abs(self) -> decimal

The magnitude, dropping the sign. Ord gives this tier min/max/ clamp as default methods but not abs, which is inherent on the signed int and fixed widths; this is the decimal counterpart, and like int.abs it has no wrap case.

-inf answers inf; undefined is absorbing and answers itself.

(0 - 5.25).abs() => 5.25
5.25.abs()       => 5.25
0.0.abs()        => 0.0

to_fixed

def to_fixed(self, places: int, mode: RoundingMode) -> string

Render with places fractional digits, rounding by mode.

It is a rendering and no value-level rounding: the decimal itself is untouched, and the tier's never-rounds promise (HANKI.md §3) is intact and only the string is rounded. There is no default mode: one program needing both HalfUp for stored prices and Down for displayed ones is the case this was built for, and a default would pick one of them for it.

Zero-padding matches printf, and a negative places clamps to zero; the other end clamps at a million, which bounds the rendering alone; the decimal itself is unaffected, and the bound is set where no real caller meets it, and a computed places that went wrong therefore cannot ask for a gigabyte-sized string. The in-band sentinels render as their usual spelling, a fixed number of digits meaning nothing for them.

places is an int and d.scale therefore feeds it with no conversion: one module, one quantity, one spelling. A places that is itself non-finite clamps like any other out-of-range count: toward the bound it points at.

A value that rounds to zero pads like any other, and takes the sign of the input as printf does, and this is therefore byte-identical to f64.to_fixed, which is what a program comparing output against another implementation needs. The sign is the input's and not the rounded value's, which is what makes every mode agree there: a decimal has no negative zero to carry one.

1.5.to_fixed(3, HalfUp)      => "1.500"
2.345.to_fixed(2, HalfUp)    => "2.35"
2.345.to_fixed(2, Down)      => "2.34"
1.999.to_fixed(0, HalfUp)    => "2"
(0 - 2.5).to_fixed(0, Down)  => "-2"
(0 - 2.5).to_fixed(0, Floor) => "-3"
(0 - 2.5).to_fixed(0, HalfUp) => "-3"
2.5.to_fixed(0, HalfEven)    => "2"
3.5.to_fixed(0, HalfEven)    => "4"
1.5.to_fixed(0 - 1, HalfUp) => "2"
0.0.to_fixed(2, HalfEven)    => "0.00"
0.001.to_fixed(5, Down)      => "0.00100"
0.001.to_fixed(2, Down)      => "0.00"
(0 - 0.001).to_fixed(2, Down)    => "-0.00"
(0 - 0.001).to_fixed(2, Ceiling) => "-0.00"

unscaled

prop unscaled(self) -> int

The unscaled digits: this decimal is unscaled * 10^(-scale). That pair is how the value is stored, and reading it is exact where every other crossing off this tier rounds or truncates. It is what a binary codec encodes in place of the display string.

An in-band sentinel has no such pair. It rides out here, in the unscaled digits (int has the same three sentinels), and scale is then 0.

1.23.unscaled => 123
1.5.unscaled  => 15

scale

prop scale(self) -> int

The power-of-ten divisor: this decimal is unscaled * 10^(-scale). A negative scale scales up.

int because multiplication sums scales, and a scale past any fixed width takes one squaring of a value like 1.to_decimal_scaled(2000000000), and an accessor that could not say so would misreport the value it decomposes. to_decimal_scaled and from_parts speak the same int, so the quantity has one spelling across the module.

1.23.unscaled => 123
1.23.scale    => 2
12.0.scale    => 1

from_parts

def from_parts(unscaled: int, scale: int) -> decimal

The inverse of unscaled / scale: the decimal equal to unscaled * 10^(-scale). It round-trips every decimal without loss, sentinels included: a sentinel unscaled is the whole value and passes through.

undefined when scale is not one any decimal can hold (non-finite, or past i64). No decomposition yields such a scale, and this therefore only reports a pair that was never a decimal to begin with.

decimal.from_parts(123, 2) => 1.23
d = 1.23
decimal.from_parts(d.unscaled, d.scale) => d

impl FromString<decimal>

parse

def parse(s: string) -> Result<decimal, ParseError>

Parses an exact decimal. Accepts what a Hanki decimal literal spells: digits, an optional sign, an optional fractional part, and exponent notation (1.5e-3). Unlike f64.parse the result is not rounded - every accepted string maps to the decimal it denotes, at the scale it was written with.

Surrounding whitespace is trimmed. The in-band sentinels have no spelling here: +inf / -inf / undefined arise from division by zero and never from text, and those words are therefore a failure, as on int.parse.

The success doctests compare the unwrapped decimal and not the Result: the lowercase tier compares with a bare == through the tier's own opcode and has no Eq impl, and the Result therefore cannot be ==-compared. The failure line maps the payload to a bool for the same reason.

decimal.parse("1.5").unwrap_or(0.0)    => 1.5
decimal.parse("-0.25").unwrap_or(0.0)  => -0.25
decimal.parse("  42  ").unwrap_or(0.0) => 42.to_decimal()
decimal.parse("1.5e-3").unwrap_or(0.0) => 0.0015
decimal.parse("1_000").map(|v| v > 0.0) => Err(ParseError(input="1_000", reason="not a decimal number"))
decimal.parse("nope").map(|v| v > 0.0)  => Err(ParseError(input="nope", reason="not a decimal number"))