hanki

rational

stdlib/core/rational.hk: conversion bridges off the rational tier.

rational is the exact p/q top of the lowercase tier (HANKI.md §3), produced by int / int and any mixed lowercase division. These are the explicit crossings out of the tier, the counterpart to int's to_rational. Integer crossings truncate toward zero; to_f64 rounds to nearest. The in-band sentinels carry across as on decimal. parse is the way in from text, and it accepts both spellings the tier has: the ratio form and every decimal one.

impl rational

_parse

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

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.

_reason

def _reason(s: string) -> string

The ratio form is the one failure worth naming apart: p/0 is a well-formed ratio the type cannot hold, where nope is not a ratio at all. A denominator that is itself unparseable falls through to the general reason.

to_int

def to_int(self) -> int

Truncate toward zero into the unbounded int tier: the fractional part is dropped and never floored, (-7/2).to_int() being -3 where flooring gives -4. Total; the in-band sentinels pass through unchanged.

r: rational = 7 / 2
r.to_int() => 3
neg: rational = -7 / 2
neg.to_int() => -3

to_i64

def to_i64(self) -> i64

Truncate toward zero, then take the low 64 bits (modular), matching int.to_i64 and decimal.to_i64. The in-band sentinels saturate.

half: rational = 7 / 2
half.to_i64() => 3i64

to_f64

def to_f64(self) -> f64

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

q: rational = 1 / 4
q.to_f64() => 0.25f64

numerator

prop numerator(self) -> int

The numerator of the reduced p/q. A rational is reduced on construction and retains a positive denominator, and this pair is therefore the value's canonical one, the sign sits here, 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 numerator (int has the same three sentinels), and denominator is then 1.

r: rational = 2 / 4
r.numerator   => 1
r.denominator => 2

denominator

prop denominator(self) -> int

The denominator of the reduced p/q, always positive (see numerator).

whole: rational = 6 / 3
whole.denominator => 1

from_pair

def from_pair(numerator: int, denominator: int) -> rational

The inverse of numerator / denominator. Round-trips every rational without loss, sentinels included.

This is plain division and it therefore reduces (from_pair(2, 4) is the value 1/2 and never that spelling), and a zero denominator lands on the language's own p/0 sentinels (HANKI.md §3) in place of a separate error path.

rational.from_pair(2, 4) => 1 / 2
r: rational = 3 / 7
rational.from_pair(r.numerator, r.denominator) => r

nearerhigh?

def _nearer_high?(rest: int, denominator: int, on_tie: bool) -> bool

Whether the rounding takes the upper of the two neighbours, given the remainder's position between them. on_tie is the mode's answer for an exact half, which is the only thing the seven modes disagree about once the nearest one is known.

takeshigh?

def _takes_high?(positive: bool, rest: int, denominator: int, low: int, mode: RoundingMode) -> bool

Whether mode rounds up from low (the floor) to low + 1. Reached only when the value sits strictly between them.

positive and no sign test on low: Up and Down are stated relative to zero, and the floor of a value in (-1, 0) is -1, whose sign is not the value's.

roundedunscaled

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

self * 10^places, rounded to an integer by mode - the digits a decimal of that scale is built from.

A sentinel needs no arm: it rides in the numerator, every operation below takes it, and decimal.from_parts hands it back as the whole value.

clampplaces

def _clamp_places(places: int) -> int

places bounded the way decimal.to_fixed bounds it, and the two agree for every argument: a negative count means zero digits, and the far end stops a computed count that went wrong from asking for a number with a billion digits.

to_decimal

def to_decimal(self, places: int, mode: RoundingMode) -> decimal

The decimal nearest this rational at places fractional digits, with ties broken by mode - the rung back down the lowercase chain, and the counterpart to decimal.to_rational (HANKI.md §3).

rational is where an exact division lands, and it is what a program is most likely to be holding at the end of a computation, and until this existed there was no way to store one in a fixed-scale column. Unlike the widening up, the narrowing back down cannot be exact for every value - 1 / 3 has no finite decimal form at all, and the scale and the tie rule are therefore the caller's to state and no operator's to invent. That is the same reason decimal / decimal widens to rational in place of rounding.

The result has places digits, and it is therefore a decimal you can compare and store, and no rendering; to_fixed is the rendering. places is bounded as decimal.to_fixed bounds it. A sentinel passes through unchanged: a fixed number of digits means nothing for one.

r: rational = 10 / 4
r.to_decimal(2, HalfUp) => 2.50
third: rational = 1 / 3
third.to_decimal(4, HalfUp) => 0.3333
third.to_decimal(0, HalfUp) => 0.to_decimal()
(0 - 1 / 3).to_decimal(2, Floor) => -0.34
(0 - 1 / 3).to_decimal(2, Down)  => -0.33

todecimalexact

def to_decimal_exact(self) -> Option<decimal>

The exact narrowing: Some of the decimal this rational IS, at the scale of its terminating expansion, or None where no finite decimal form exists. 1000 / 4 comes back as 250 (scale 0) and 10 / 4 as 2.5, with nothing invented, where to_decimal makes the caller pick a scale both times, this is for the caller whose scale was the thing they were computing. 1 / 3 has no finite form, and it is therefore None.

A real computation and no accessor: the expansion terminates where the reduced denominator factors into 2s and 5s, and there alone, and the scale is however many digits that factorisation needs. A caller who already knows the scale they want goes on calling to_decimal.

A sentinel rides through as Some of the same sentinel, as to_decimal passes one unchanged: it has no expansion to terminate, and the decimal tier has the same three.

q: rational = 1000 / 4
q.to_decimal_exact().unwrap_or(0.to_decimal()) => 250.to_decimal()
q.to_decimal_exact().map(|d| d.scale) => Some(0)
h: rational = 10 / 4
h.to_decimal_exact().unwrap_or(0.to_decimal()) => 2.5
third: rational = 1 / 3
third.to_decimal_exact().map(|d| d.scale) => None

to_fixed

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

Render with places fractional digits, rounding by mode: the decimal.to_fixed this tier was missing, with the same mode set, the same zero-padding, and the same bounds on places. Rendering a value through to_rational therefore produces the same bytes as rendering it directly, which is what a program comparing output against another implementation needs.

to_decimal then that render, plus the sign: a negative value small enough to round to zero retains its -, as printf does and as decimal.to_fixed documents, and the narrowed decimal cannot carry it because the tier has no negative zero.

r: rational = 10 / 4
r.to_fixed(2, HalfUp) => "2.50"
third: rational = 1 / 3
third.to_fixed(4, HalfUp) => "0.3333"
third.to_fixed(2, HalfUp) => "0.33"
twothirds: rational = 2 / 3
twothirds.to_fixed(2, HalfUp) => "0.67"
twothirds.to_fixed(2, Down)   => "0.66"
(0 - 1 / 1000).to_fixed(2, Down) => "-0.00"

impl FromString<rational>

parse

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

Parses an exact rational. Two spellings are accepted: the ratio form p/q (reduced on the way in, and "2/4" is therefore 1/2), and everything decimal.parse accepts, converted without loss. The accepted set therefore nests the same way the types do (int ⊂ decimal ⊂ rational, HANKI.md §3).

Surrounding whitespace is trimmed. A zero denominator is a failure, not an in-band infinity: the sentinels come from dividing and never from text, and it is the one failure the reason names in its own words.

The success doctests compare the unwrapped rational and not the Result, for the reason decimal.parse's do: the tier compares with a bare == and has no Eq impl. The failure lines map the payload to a bool for the same reason.

rational.parse("3/4").unwrap_or(0 / 1)   => 3 / 4
rational.parse("2/4").unwrap_or(0 / 1)   => 1 / 2
rational.parse("0.5").unwrap_or(0 / 1)   => 1 / 2
rational.parse("1/0").map(|v| v > 0 / 1) => Err(ParseError(input="1/0", reason="a zero denominator"))
rational.parse("3_0/4").map(|v| v > 0 / 1) => Err(ParseError(input="3_0/4", reason="not a rational number"))
rational.parse("nope").map(|v| v > 0 / 1) => Err(ParseError(input="nope", reason="not a rational number"))