hanki

3. Numeric tower

i8 i16 i32 i64              signed fixed-width
u8 u16 u32 u64              unsigned fixed-width
f32 f64                     IEEE 754 floats
int                         arbitrary-precision integer
decimal                     arbitrary-precision decimal
rational                    exact rational p/q
Complex<T>                  complex number over a Real type T

There are two tiers, governed by different rules:

The two tiers don't mix implicitly - crossing the boundary requires an explicit conversion method on the source type (e.g. x.to_f64(), n.to_int()).

Defaults for unconstrained literals. Context wins when it pins the type - including through a generic slot (o: Option<int> = Some(0) and r.unwrap_or(0) on a Result<int, _> both pin the bare 0 to int); otherwise an unsuffixed integer literal falls back to int and an unsuffixed float literal falls back to decimal (the lowercase tier, exact arithmetic).

x = 5            # OK — defaults to `int`
y = 1.5          # OK — defaults to `decimal`
x: i32 = 5       # OK — annotation wins over the default
x = 5i32         # OK — suffix wins over the default
z = x + 1        # OK — `1` inferred as i32 from x

Literal suffixes: 42i32 42u8 (fixed-width integer tier), 3.14f32 3.14f64 (f32/f64). There is no explicit int suffix - unsuffixed integer literals already default to int, and a second spelling would be a redundancy (99 == 99n) with no payoff. Unsuffixed floats default to decimal for the same reason; 1/3r (rational planned), 1+2i (Complex planned).

Widening within a tier

The lowercase tier widens implicitly along the int ⊂ decimal ⊂ rational chain. The fixed-width tier does not - picking a fixed width is opting into hardware semantics, and implicit widening would partially undo that choice. Mixing widths (or signedness, or int↔float) requires an explicit conversion method on the source value:

Widening methods (i8.to_i16, …) are always safe - the value range can't overflow the wider type. The cross-tier bridge works the same way: i32/i64/u32/u64 carry .to_int() (exact widening into the lowercase tier), and int carries .to_i64() (modular truncation like the narrowing family; the in-band sentinels saturate to the i64 range ends). The same four fixed-width types carry .to_f64() - widening into the float tier, exact for the 32-bit widths and rounded to nearest (lossy beyond 2^53) for i64/u64; int also carries .to_f64() (rounded to nearest, with its inf/-inf/undefined sentinels mapping to the IEEE float infinities and NaN). f32 carries .to_f64() too - a lossless widening into the double tier (every f32 value is exactly an f64), the bridge that legalises a mixed-width f32 + f64 above. The reverse crossing is f64.to_int() - it truncates toward zero (drops the fractional part, like a hardware cast) into the unbounded int tier, total because int has no range limit, with the IEEE non-finite floats mapping back the other way (infinf, -inf-inf, NaNundefined). Narrowing methods (i32.to_i8, …) truncate (modular - the low bits reinterpreted at the target width), total: 300i32.to_i8() is 44i8, 200i32.to_i8() is -56i8. Pair them with try_to_TYPE (returns Option<T>, None when the value doesn't fit) when you need to detect out-of-range instead of wrapping through it. This mirrors the fixed-width arithmetic story (above): the bare conversion is total, the try_ twin is the checked one.

Off the exact tier. decimal and rational are the widening targets of the lowercase chain, but they also carry the explicit crossings back out (the counterpart to int's to_decimal/to_rational): both carry .to_int() (truncate toward zero into the unbounded int - drop the fractional part, like f64.to_int(); total, and the in-band sentinels pass through), .to_i64() (that truncation then reduced to the low 64 bits, modular like int.to_i64; sentinels saturate), and .to_f64() (rounded to nearest, sentinels → IEEE). These are what make the division section's x.to_i64() // 10i64 recipe reachable: a value that widened into decimal/rational is no longer a dead end. f32 likewise carries its own min/max/abs (IEEE, like f64's) alongside to_f64, and renders through Display - it is a first-class fixed-width float, not a write-only one.

Same-width signed↔unsigned reinterpret. i32.to_u32 / u32.to_i32 and i64.to_u64 / u64.to_i64 reinterpret the two's-complement bit pattern at the same width - same bits, total, lossless, exact inverse pairs: (-1i32).to_u32() is 4294967295u32, 4294967295u32.to_i32() is -1i32. Together with the fixed-width bitwise methods (below) and f64.to_bits/from_bits, this pair is the seam a binary codec uses to reach a number's wire bytes.

Text → value. int, f64, and the fixed-width integer types i32 / u32 / i64 / u64 carry .parse(s: string) -> Option<T> as an inherent associated function (i64.parse("42") is Some(42i64)): base-10 for the integers, correctly-rounded decimal for f64, surrounding whitespace trimmed. None on non-numeric or out-of-range input - a fixed-width parse rejects a value outside its range rather than wrapping (u32.parse("4294967296") is None).

Within the lowercase tier, +, -, * widen to the top of the chain that the operands span:

Division

/ is true division - it never rounds in the lowercase tier:

For truncating integer division, use //:

// and % are not defined on decimal, rational, or the floats - those need a conversion method (e.g. x.to_i64() // 10i64).

Fixed-width division needs a non-zero literal divisor. The fixed-width integer tier has no way to represent infinity or "undefined", so a bare / (and //, %) is accepted only when the divisor is a statically non-zero literal: x / 2i32 compiles, x / 0i32 is a compile-time error, and x / y for a runtime y is rejected with a pointer to the checked twin. This keeps every bare fixed-width division total - the one wrapping edge is signed MIN / -1, which wraps to MIN (and MIN % -1 is 0) - with no silent divide-by-zero value and no crash. For a runtime divisor, use checked_div / checked_floor_div / checked_mod, which return Option (None on a zero or MIN / -1 divisor) - or guard it: inside an if y != 0 … end block a bare / (and //, %) by y is accepted, since the guard proves y non-zero there (TypeScript-style flow-narrowing / occurrence typing). The narrowing covers a y != 0 test - on its own or as a conjunct of an and-guard (y != 0 and …, which narrows every such conjunct) - over a binding (let, parameter, or var). It is flow-sensitive within the block: the fact narrows the divides before the binding is reassigned or shadowed and ends at that rebind - so a / y is a bare divide in if y != 0 … a / y; y = compute!(); b / y … end while the later b / y is not (a rebind inside a nested branch ends it too, conservatively). It holds only in the then-block (never the else, nor after end), and reaches into a closure written there - because a closure captures by value (§13), a binding the guard proved non-zero is captured with that value and keeps it for the closure's whole life, so a bare divide by it inside the body is sound even if the closure escapes (ending, as always, at a rebind: the closure's own same-named parameter, or a reassignment in its body). It also runs in the other direction: after an early-exit guard if y == 0 … end whose then-branch always diverges (return / crash! / throw / break / continue), y is non-zero in the code that follows the guard, since the fall-through is reached only when the test was false; an or-guard there (if y == 0 or z == 0 … end) narrows every such disjunct, because ¬(a or b) gives both a != 0 and b != 0. It relaxes only where a bare divide is allowed, never what one computes - the MIN / -1 wrap and the no-zero-divisor guarantee are unchanged. The lowercase int / decimal / rational tiers are exempt from this rule - their division by zero is total a different way (next).

Lowercase division is total via in-band infinities. The int / decimal / rational tiers gain three in-band sentinels alongside their finite values: +inf, -inf, and undefined. Division by zero yields one of them instead of trapping - 1 / 0 is +inf, -1 / 0 is -inf, 0 / 0 is undefined - and they propagate through arithmetic: +inf + 1 is +inf, +inf + -inf is undefined, +inf * 0 is undefined, and undefined is absorbing (any operation with an undefined operand is undefined). // and % on int behave uniformly with / here. Unlike IEEE floats' NaN, equality is reflexive (undefined == undefined is true) and the order is total: -inf < every finite < +inf < undefined. So the lowercase tiers keep decidable equality and a total order - they stay sortable, set-able, and usable as map keys; you test with a plain x == undefined. They render as inf / -inf / undefined. (DELIBERATE TRADEOFF: an in-band sentinel can flow downstream silently until it surfaces far from the / 0; chosen to keep arithmetic plain and total, with no propagation operator. The floats stay IEEE - f64's NaN != NaN and int's reflexive undefined differ, an accepted exact-tier/hardware-tier split.)

Cross-tier mixing requires a conversion method

Overflow and wrapping

The fixed-width tier is modular: +, -, *, and unary - wrap around at the type's bit width (two's-complement, ℤ/2ⁿ) - total, never trapping, same in debug and release. 200u8 + 100u8 is 44u8; negating i8::MIN is i8::MIN. Wrapping is the right default for fixed-width's real uses (hashing, checksums, wire formats), and it keeps pure functions total (§6): a pure def computing a + b can never crash. Choosing i32/u8 is choosing hardware/modular semantics; the safe arbitrary-precision default is int.

To detect an overflow instead of wrapping through it, use the Option-returning twins - checked_add, checked_sub, checked_mul, checked_neg (and checked_div / checked_floor_div / checked_mod for division, see above). None means the result didn't fit (or, for division, a zero or MIN / -1 divisor). There are no wrapping_* methods - the bare operators already wrap. The bytecode tier evaluates the fixed-width ops at the operand width via Op::*Wrap(IntWidth); the AOT tier emits plain LLVM add/sub/mul, which wrap natively at width iN.

abs / min / maxmin / max are default methods on Ord (defined once over cmp, §10), so every Ord type carries them: int and every fixed width (3i32.min(7i32)), the lowercase decimal / rational, bool, string, bytes, and any user type with an Ord impl ("apple".min("banana") is "apple"); a type may still override them with an inherent min/max, which outranks the default. abs is an inherent method on the signed int/i8/i16/i32/i64 (unsigned abs would be the identity). abs follows each tier's semantics: on int every magnitude is representable; on a fixed width it is modular like the bare operators, so the most negative value's abs wraps to itself - pair with checked_sub when that matters. f64 carries its own min/max/abs as intrinsics (it has no Eq/Ord): min/max follow IEEE semantics - if exactly one argument is NaN the other is returned - and abs clears the sign bit ((-0.0).abs() is 0.0, NaN.abs() is NaN).

f64 math surface. Beyond abs/min/max, f64 carries the standard libm methods as inherent intrinsics: rounding (floor, ceil, round - half away from zero, trunc), roots and powers (sqrt, cbrt, pow(exp)), exponentials and logarithms (exp, ln, log10, log2), and trigonometry in radians (sin, cos, tan, atan2(x), hypot(other)). Each follows IEEE/libm semantics and is total in the fixed-width sense - a domain error is a NaN ((-1.0).sqrt(), (-1.0).ln()), never a trap. They compute identically on both tiers (the AOT shims call the same underlying std/libm routines), so a given f64 input yields the same bits under hanki run and an AOT binary.

Generic numeric code — the Numeric trait. The core numeric module carries trait Numeric — the static identities zero()/one() plus add/mul, whose per-type impl bodies are the native +/* — implemented for all 13 numeric types, so generic code can bound <T: Numeric> and fold numerically: List.sum/List.product (§17) reach the identities as T.zero()/T.one() through the bound (§10's static-through-bound dispatch). It exists as a plain trait because the operator traits (§10) deliberately exclude builtin-primitive operands (H0573) and bounds on parameterized traits are staged (H0569). Semantics inherit the tower exactly: fixed-width add/mul wrap like the operators, floats stay IEEE, and the lowercase tier's in-band sentinels propagate unchanged.

Iteration primitives. Counting iteration is stdlib, not syntax - Hanki has no for/while/... Range.new(lo, hi) is the half-open [lo, hi) integer range, carrying the same combinators as List: each!(|i| …) (effectful, a loop under the hood so a large range adds no stack depth), map(|i| …) -> List<U>, fold(init, |acc, i| …), plus length and to_list. The inclusive counterpart is int.upto: 1.upto(3) covers 1, 2, 3 (a Range over [self, hi+1)). n.times!(|i| …) runs an action n times with index 0 … n-1 (a zero or negative count runs it zero times). Bounds are the scripting-default int (the index surface, below), so loop counters need no conversions. All pure Hanki over int arithmetic and closures - no new syntax or intrinsics.

Bitwise operations are inherent methods on the fixed-width tier - bit_and / bit_or / bit_xor (per-bit, two same-width operands), bit_not (per-bit complement), and the shifts bit_shl / bit_shr (shift amount as u32). Methods rather than operators, to keep the grammar small (§2). All total: a shift takes its amount mod the bit width (so 1u8.bit_shl(8u32) is 1u8, never a trap), and bit_shr is type-directed - arithmetic (sign-extending) on the signed widths, logical (zero-filling) on the unsigned. They exist only on the fixed-width tier; the lowercase int / decimal / rational tier has no fixed bit width to complement or shift within, so it has none. Both tiers narrow to the width, apply the native bit operation, then re-widen, so they agree bit-for-bit: the bytecode tier routes each op through a per-width intrinsic (wrapping_shl/wrapping_shr for the shifts), while the AOT tier emits the LLVM instruction inline (and/or/xor, shl/lshr/ashr with the shift amount masked mod the width). u32 also exposes count_ones - the population count (the number of set bits).

The int (lowercase) tier is arbitrary precision with a small-int fast path - a magnitude within ±2^62 is held inline as a machine word (no allocation), and only a larger value routes through num-bigint::BigInt (bytecode Value::BigInt as an inline Small or a boxed Big; AOT a low-bit-tagged i64, a small immediate or an aligned pointer to a bignum cell, with the small-int arithmetic and comparison fast paths emitted inline and the boxed/overflow cases routing through the hanki_int_* C-ABI). The small/big split is never observable. decimal is similarly arbitrary precision via bigdecimal::BigDecimal (bytecode Value::BigDecimal, AOT hanki_dec_* C-ABI). rational is exact p/q via num-rational::BigRational (bytecode Value::Rational, AOT hanki_rat_* C-ABI). Hanki deliberately stops the fixed-width tier at 64 bits: i128 / u128 aren't native on x86_64 or ARM64 (compiler-synthesised), the everyday "I don't care about width" type is int, and the rare crypto/UUID scratch use cases are served by a bytes value or a two-field struct.

Literal ranges

Fixed-width integer literals are range-checked at compile time. A literal whose value doesn't fit its declared suffix is a compile-time error, not a runtime trap: 128i8 is rejected (i8 range is -128..127), as are 256u8 and -1u8. A leading unary - directly preceding a numeric literal folds into the literal - -128i8 parses as the single literal -128 and is accepted as the signed i8 MIN value. Unsuffixed literals carry no fixed range and are bound by inference. Lowercase int literals are unbounded: 9999999999999999999999999999999999999999999 parses and lowers as a Const::BigInt directly.