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:
- Lowercase tier:
int,decimal,rational. These form an inclusion chainint ⊂ decimal ⊂ rational. Every integer is a finite decimal; every finite decimal is a rationalp/q. Arithmetic in this tier never rounds. Operands promote before the operator binds, and a sub-expression evaluates to the smallest type in the chain that represents the exact mathematical result. - Fixed-width tier:
i8..i64,u8..u64,f32,f64. Picking one of these is choosing hardware semantics: truncation on integer division, IEEE rounding on floats, overflow as a panic.
The two tiers do not mix implicitly. Crossing the boundary requires an explicit conversion method on the source type, such as x.to_f64() or n.to_int().
An unconstrained literal takes its type from context where context pins it, 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. Where nothing pins it, an unsuffixed integer literal falls back to int and an unsuffixed float literal to decimal, the exact tier. These defaults also apply through generic calls: for identity<T>(value: T) -> T, n = identity(4) can infer int without an annotation; a later pinned: i32 = n still selects i32 before defaulting.
A literal pins anywhere at or above its own kind on the exact chain. 2 is 2, 2.0 and 2/1, and 1.25 is 5/4. An unsuffixed integer literal therefore pins to any integer type and to decimal and rational, and an unsuffixed fractional one to decimal, f32/f64 and rational, whether as a parameter, a return type, an annotation or a generic slot, with nothing written at the call and nothing lost. It is the same pinning that makes a bare 0 an i64 in i64 position: an unsuffixed literal has no type of its own until context gives it one, and int/decimal is the fallback where nothing does. f64 is not on the chain, and an integer literal does not pin to it; picking a fixed width is opting into hardware semantics. None of this is a conversion, and a decimal value in a rational position is still an error. See Off the exact tier below.
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
The literal suffixes are 42i32 42u8 for the fixed-width integer tier and 3.14f32 3.14f64 for f32 and f64. There is no int suffix, since unsuffixed integer literals already default to int and 99 == 99n would be a second spelling of one value. Unsuffixed floats default to decimal for the same reason. 1/3r (rational) and 1+2i (Complex) are planned.
Every f32 value is representable without loss. A literal, an f32.parse and an arithmetic result all lie on the single-precision grid. The same number reached two ways compares equal: 0.1f32 == f32.parse("0.1").unwrap_or(0.0f32), and x.abs() == x for a computed x. Arithmetic evaluates at double precision and rounds back, which for + - * / yields the single-precision result.
Widening within a tier
The lowercase tier widens implicitly along int ⊂ decimal ⊂ rational. The fixed-width tier does not: picking a fixed width is opting into hardware semantics, and implicit widening would partly undo that choice. Mixing widths, signedness, or integer with float requires an explicit conversion method on the source value:
i32 + i64→ error, usex.to_i64() + yf32 + f64→ error, usex.to_f64() + y(f32has.to_f64(), a lossless widening)i32 + u32→ error, a signedness change requires an explicit conversioni32 + f64→ error, a tier crossing requires an explicit conversion
Widening methods (i8.to_i16 and its siblings) are always safe, the value range being unable to overflow the wider type. The cross-tier bridge works the same way:
i32,i64,u32andu64have.to_int(), an exact widening into the lowercase tier.inthas.to_i64(), a modular truncation like the narrowing family, where the in-band sentinels saturate to thei64range ends. It also has the whole checked familytry_to_i8throughtry_to_u64. Each tests the full-precision value; composing throughto_i64would report a fit the value never had, and a sentinel answersNone, no width holding an infinity.- The same four fixed-width types have
.to_f64(), a widening into the float tier, exact for the 32-bit widths and rounded to nearest fori64andu64, where it is lossy beyond 2^53. intalso has.to_f64(), rounded to nearest, itsinf/-inf/undefinedsentinels mapping to the IEEE float infinities andNaN.f32has.to_f64(), a lossless widening into the double tier, everyf32value being anf64. It is the bridge that legalises a mixed-widthf32 + f64above.f64.to_int()is the reverse crossing. It truncates toward zero, dropping the fractional part like a hardware cast, into the unboundedinttier, total becauseinthas no range limit. The IEEE non-finite floats map back the other way:inf→inf,-inf→-inf,NaN→undefined.
Narrowing methods (i32.to_i8 and its siblings) truncate: the low bits reinterpreted at the target width, total. 300i32.to_i8() is 44i8 and 200i32.to_i8() is -56i8. Pair them with try_to_TYPE, which returns Option<T> and answers None where the value does not fit, to detect an out-of-range value. This mirrors the fixed-width arithmetic story below: the bare conversion is total and the try_ twin is the checked one.
Off the exact tier, decimal and rational are the widening targets of the lowercase chain, and they also have the explicit crossings back out, the counterpart to int's to_decimal and to_rational. Both have .to_int() (truncate toward zero into the unbounded int, dropping the fractional part like f64.to_int(); total, and the in-band sentinels pass through), .to_i64() (that truncation reduced to the low 64 bits, modular like int.to_i64; sentinels saturate) and .to_f64() (rounded to nearest, sentinels to IEEE). They make the division section's x.to_i64() // 10i64 recipe reachable from a value that widened into decimal or rational.
Promotion is operator-level. Widening along int ⊂ decimal ⊂ rational is a property of the numeric operators. + - * / // % and the comparisons == != < <= > >= promote their operands to the top of the chain they span: n + d on an int and a decimal is a decimal, d == r compares as rationals, and dividing two decimals produces a rational. Every other position takes the type it declares, whether a parameter, a return, an annotated binding, a struct field, a list element, a branch or a trait-method argument (d.max(r) is a mismatch). An int value does not reach a decimal parameter and a decimal value does not reach a rational one; a value crosses rungs through n.to_decimal() or d.to_rational(). There is no value subtyping behind this. The checker is unification-based, and coercion at the positions with a known expected type would leave the inferring ones behind: [d, r], a T slot, a method argument. An unsuffixed literal is the one thing that passes without a crossing, and it is no exception: it had no type to widen, and context pinned it. See Defaults for unconstrained literals above.
The chain also widens explicitly all the way up. decimal.to_rational() is exact, a decimal being unscaled / 10^scale and that ratio being a rational, and it completes int ⊂ decimal ⊂ rational as a set of conversions as well as an arithmetic promotion. Promotion is operator-level: a decimal does not reach a rational parameter on its own, and dividing two decimals produces a rational. Without the crossing, computing a mean leaves you holding a type no signature accepts. It is the same conversion mixed arithmetic promotes with, and d.to_rational() and the promotion agree.
And rational narrows back down. rational.to_decimal(places, mode) is the counterpart to decimal.to_rational. A division lands on rational, which is what a computation most often ends holding, and storing one in a fixed-scale column needs a narrowing. It cannot be exact for every value the way the widening is, 1 / 3 having no finite decimal form at all, and the scale and the tie rule are the caller's to state, the same reason decimal / decimal widens where it cannot be exact. rational.to_fixed(places, mode) is the rendering beside it. It takes decimal.to_fixed's mode set, zero-padding and bounds, and a value rendered through to_rational produces the same bytes as one rendered directly. Both bound places as decimal.to_fixed does, and both pass an in-band sentinel through unchanged.
decimal also has abs. Ord supplies min, max and clamp to this tier as default methods and does not supply abs, which is inherent on the signed int and the fixed widths. decimal.abs has no wrap case, as int.abs has none.
Rendering with a fixed number of digits is decimal.to_fixed(places, mode), the printf %.Nf this tier otherwise has no spelling for. It is a rendering and not a value-level rounding: the decimal is untouched, the never-rounds promise above is intact, and only the string is rounded. mode is a RoundingMode: Up / Down / Ceiling / Floor / HalfUp / HalfDown / HalfEven. There is no default. One program rendering stored prices HalfUp and displayed prices Down is the case this exists for, and a default would pick one of them for it. Down rounds toward zero and Floor toward negative infinity, the same for positive values and different below zero, a distinction plain truncation cannot express. Zero-padding follows printf (1.5 at 3 places is 1.500), a negative places clamps to zero and an absurd one to a million. That bound is on the rendering, with the value itself unaffected, and a places arriving from arithmetic gone wrong cannot ask for a gigabyte of digits. The in-band sentinels render as their usual spelling. A value that rounds to zero pads like any other (0 at 2 places is 0.00) and takes the sign of the input, again as printf does: (0 - 0.001).to_fixed(2, Down) is -0.00, and so is the Ceiling of it. Every mode agrees there, a decimal having no negative zero for the rounded value to take. That makes it byte-identical to f64.to_fixed, which matters to a program comparing against another implementation; Crystal's BigDecimal#round(2, :to_zero).to_s prints the same case unsigned. to_int truncates and is no rounding operation.
Every one of those crossings rounds or truncates, and each type also has the exact decomposition into the pair it is stored as, the lossless way out. decimal decomposes into unscaled and scale, the value being unscaled * 10^(-scale), and rebuilds with decimal.from_parts. rational decomposes into the numerator and denominator of the reduced p/q, kept with a positive denominator so that the sign is on the numerator, and rebuilds with rational.from_pair. Both round-trip every value without loss, which is what a binary codec needs from this tier. scale is an int, as are int.to_decimal_scaled's parameter and to_fixed's places count across decimal, rational and f64: multiplication sums scales, and a scale past any fixed width takes a single squaring of 1.to_decimal_scaled(2000000000). The in-band sentinels have no pair form. One rides out in the first component, int having the same three, and the second takes its role's identity, scale 0 or denominator 1; the constructors invert that, and inf, -inf and undefined round-trip like any other value. rational.from_pair is plain division: it 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 with no separate error path. decimal.from_parts answers undefined for a scale no decimal can hold, which no decomposition produces.
f32 has its own min, max and abs, IEEE like f64's, alongside to_f64 and to_bits/from_bits, a u32 pattern and the twins of f64's pair, and it renders through Display. It is a full fixed-width float. f32.from_bits matters beyond codecs: an f32 is stored in an f64 slot and no other name in core converts into it. A pattern is the only way to build one from a computed value. to_bits narrows before taking the pattern and answers the 32-bit float's bits, never the low half of the 64-bit one. The pair is exact over all 2^32 patterns, signaling NaNs included. The widening and narrowing move a NaN's payload between the two mantissa widths, where the hardware conversion would quiet it, and a codec re-emitting a float field it never interpreted does not alter it. Arithmetic is unaffected and remains IEEE: an operation on a signaling NaN yields a quiet one, which is the standard's rule and no artifact of the slot's width.
A same-width signed and unsigned reinterpret is 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 and 4294967295u32.to_i32() is -1i32. Together with the fixed-width bitwise methods below and f64 and f32's to_bits/from_bits, this pair is the seam a binary codec uses to reach a number's wire bytes.
All thirteen numeric types read text through FromString (core from_string, §17). The spelling is .parse(s: string) -> Result<T, ParseError>, a trait method and not an inherent one: i64.parse("42") is Ok(42i64). Two consequences at the call site: core trait impls are ambient, and the concrete i64.parse("42") needs no import; and generic code can read a number without naming its type, through the bound. Surrounding whitespace is trimmed.
A failure names which failure it was, the reason this is a Result and not an Option. ParseError reports the input alongside a reason: u32.parse("4294967296") reports out of range for u32 (0 ..= 4294967295) and u32.parse("nope") reports not a base-10 integer. An unsigned width tests the sign first and refuses a leading - outright (negative, and u32 is unsigned), separately from the range arm: "-0" denotes a value that is in range and is still refused.
Base-10 for the integers. Correctly-rounded for the floats, f64.parse and f32.parse onto the single-precision grid, where a magnitude past the type's range rounds to the IEEE infinity and does not fail. Exact for the lowercase tier, where decimal.parse takes what a decimal literal spells, sign, digits, optional fraction and exponent notation, and preserves the scale it was written with, with no rounding. rational.parse accepts both spellings the tier has, the ratio form p/q (reduced: "2/4" is 1/2) and every decimal one, and the accepted sets nest as the types do along int ⊂ decimal ⊂ rational; p/0 is the one failure it names in its own words (a zero denominator). A fixed-width parse rejects a value outside its range and does not wrap: u32.parse("4294967296") is an Err, and so is i8.parse("128"). An underscore separator is never accepted, on any of the thirteen. 1_000 is literal syntax, an affordance for reading source, while the text a parse reads is data, where that spelling is likelier a typo or a foreign format than a separator someone meant. int.parse("1_000") fails, as i64.parse("1_000") does. The in-band sentinels have no spelling: +inf, -inf and undefined arise from division by zero and not from text, and those words fail on the exact tiers. The IEEE floats keep their own inf and NaN spellings, those being ordinary f64 and f32 values.
Within the lowercase tier, +, - and * widen to the top of the chain that the operands span:
int + int → int,int * decimal → decimal,decimal + rational → rational- All multiplication and addition are exact. No rounding ever happens.
Division
/ is true division and never rounds in the lowercase tier:
int / int → rational.1 / 3is the rational1/3.decimal / decimal → rational. In1.0 / 3.0both operands aredecimalby default, the result is not a finite decimal, and the type widens.int / decimal → rational,decimal / rational → rationali32 / i32 → i32, hardware sdiv, truncating toward zerof64 / f64 → f64, IEEE 754
For truncating integer division, use //:
//is floor division, rounding toward negative infinity, defined onintand on the fixed-width integer types.(-7) // 2is-4, where truncation toward zero would give-3.%is modulo with the sign of the divisor, over the same domain. The identity(a // b) * b + (a % b) == ais true for every sign combination.
// and % are not defined on decimal, rational or the floats. Those need a conversion method, such as x.to_i64() // 10i64.
Fixed-width division needs a non-zero literal divisor. The fixed-width integer tier has no representation for an infinity or an undefined value, and a bare / (and //, %) is accepted only where 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. Every bare fixed-width division is therefore total, with no silent divide-by-zero value and no crash. The one wrapping edge is signed MIN / -1, which wraps to MIN; MIN % -1 is 0.
For a runtime divisor, use checked_div / checked_floor_div / checked_mod, which return Option and answer None on a zero or MIN / -1 divisor. The other route is to guard it: inside an if y != 0 … end block a bare / (and //, %) by y is accepted, the guard having proved y non-zero there. This is TypeScript-style flow narrowing, or occurrence typing. The narrowing covers a y != 0 test on its own or as a conjunct of an and-guard, where y != 0 and … narrows every such conjunct, over a binding: a let, a parameter or a 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: a / y is a bare divide in if y != 0 … a / y; y = compute!(); b / y … end while the later b / y is not, and a rebind inside a nested branch ends it too, conservatively. It applies only in the then-block, never the else and never after end, and it reaches into a closure written there. A closure captures by value (§13), a binding the guard proved non-zero is captured with that value and retains it for the closure's whole life, and a bare divide by it inside the body is sound even where the closure escapes. That ends at a rebind as always: the closure's own same-named parameter, or its own same-named var. A reassignment of the captured binding itself is rejected outright (§13). The narrowing runs in the other direction too. 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, the fall-through being reached only where the test was false; an or-guard there, if y == 0 or z == 0 … end, narrows every such disjunct, ¬(a or b) giving both a != 0 and b != 0. It relaxes where a bare divide is allowed and never what one computes: the MIN / -1 wrap and the no-zero-divisor guarantee are unchanged. The lowercase int, decimal and rational tiers are exempt from this rule. Their division by zero is total a different way, next.
Lowercase division is total through in-band infinities. The int, decimal and rational tiers have three in-band sentinels alongside their finite values: +inf, -inf and undefined. Division by zero yields one of them and does not trap: 1 / 0 is +inf, -1 / 0 is -inf, 0 / 0 is undefined. 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 being undefined. // and % on int behave uniformly with / here. Equality is reflexive, and undefined == undefined is true, unlike IEEE floats' NaN. The order is total: -inf < every finite < +inf < undefined. The lowercase tiers therefore have decidable equality and a total order, remain sortable, set-able and usable as map keys, and the test is a plain x == undefined. They render as inf, -inf and undefined. The trade is stated: an in-band sentinel can flow downstream until it surfaces far from the / 0, and the choice was to keep arithmetic plain and total with no propagation operator. The floats remain IEEE, and f64's NaN != NaN differs from int's reflexive undefined, an accepted split between the exact tier and the hardware tier.
Cross-tier mixing requires a conversion method
i32 + int→ error. Choosingi32is opting into fixed-width semantics, and promoting tointwould undo that.f64 + decimal→ error. Floats and decimals are in different tiers.- The crossing is always explicit, written as a method on the source value:
x.to_int() + yorx.to_f64() + y. The methods are inherent associated items on the source type, animpl i32block withdef to_f64(self) -> f64. - Non-numeric operands are different.
+and-on them dispatch through the coreAddandSubtraits (§10), the way<routes throughOrd. The trait fallback fires only where an operand is outside the numeric tower. Numeric-but-incompatible pairs get the conversion-hint error above, and no impl can legalize a cross-width+. An operator impl whoseSelfis a builtin primitive is rejected outright (H0573),string + stringis an error, and joining text is explicit: interpolation,join, or aStringBuilder(§4). - An
f64's raw IEEE 754 bit pattern is reachable both ways withf64.to_bits() -> u64andf64.from_bits(u64) -> f64, exact inverses and the seam a binary codec uses to read or write a float's eight wire bytes. There is no other route to the bits from Hanki.
Overflow and wrapping
The fixed-width tier is modular. +, -, * and unary - wrap around at the type's bit width, two's complement over ℤ/2ⁿ: total, never trapping, the same in debug and release. 200u8 + 100u8 is 44u8, and negating i8::MIN is i8::MIN. Wrapping suits fixed-width's real uses, hashing, checksums and wire formats, and it leaves pure functions total (§6): a pure def computing a + b can never crash. Choosing i32 or u8 is choosing hardware and modular semantics; the safe arbitrary-precision default is int.
The Option-returning twins detect an overflow where the bare operators wrap through it: checked_add, checked_sub, checked_mul, checked_neg, and checked_div / checked_floor_div / checked_mod for division, above. None means the result did not 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 through Op::*Wrap(IntWidth), and the AOT tier emits plain LLVM add/sub/mul, which wrap natively at width iN.
min, max and clamp are default methods on Ord, defined once over cmp (§10), and every Ord type has them: int and every fixed width (3i32.min(7i32)), the lowercase decimal and rational, bool, string, bytes, and any user type with an Ord impl ("apple".min("banana") is "apple"). A type may override them with an inherent min, max or clamp, which outranks the default. clamp(low, high) limits to the closed range: low where self sorts below it, high where above it, self otherwise, the same as x.max(low).min(high). Crossed bounds, low above high, answer high, a total answer stated in place of a crash; a crashing default would bar every pure caller of the trait. abs is an inherent method on the signed int, i8, i16, i32 and i64, an unsigned abs being the identity. abs follows each tier's semantics: on int every magnitude is representable, and on a fixed width it is modular like the bare operators, where the most negative value's abs wraps to itself. Pair it with checked_sub where that matters. f64 has its own min, max and abs as intrinsics, having no Eq or Ord. min and max follow IEEE semantics: where one argument is NaN and the other is not, the other is returned. abs clears the sign bit: (-0.0).abs() is 0.0 and NaN.abs() is NaN.
Beyond abs, min and max, f64 has the standard libm methods as inherent intrinsics: rounding (floor, ceil, round, which is half away from zero, and 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 and libm semantics and is total in the fixed-width sense: a domain error is a NaN, as in (-1.0).sqrt() and (-1.0).ln(), and never a trap.
How those methods lower on the AOT tier splits the list in two, permanently. The IEEE-754-exact operations, abs, ceil, floor, round, sqrt and trunc, lower to llvm.fabs.f64 and its siblings, which LLVM can fold into one instruction. They are exact, a correctly-rounded result or a bit manipulation, where the intrinsic and the bytecode tier's Rust method agree on every input, NaN payloads, signed zeros and subnormals included. Everything else, sin, cos, tan, exp, ln, log10, log2, pow, atan2, hypot and cbrt, remains on an opaque C-ABI shim and always will: llvm.sin.f64 is not required to match Rust's f64::sin bit for bit, and the cross-tier guarantee below is worth more than the saved call. min and max look exact and also remain on the shim. Rust documents that where the inputs compare equal, +0.0 against -0.0, f64::min may return either operand non-deterministically, and llvm.minnum has the same licence; two implementations each free to choose cannot be relied on to choose alike. All of them compute identically on both tiers, the exact ones because they are exact and the rest because the AOT shims call the same underlying std and libm routines, and a given f64 input yields the same bits under hanki run and in an AOT binary.
int.pow is the exact tier's power, and it answers in rational so that every exponent has an exact answer: 10.pow(3) is 1000 and 2.pow(-1) is 1/2. An int return would have to answer 0 for the second, and a lossy answer is the thing this tier exists to prevent; the common case adds a crossing back, 10.pow(3).to_int(). 0.pow(-1) divides by zero and lands on the tier's own p/0 sentinel. It squares, and a large exponent costs a number of multiplications logarithmic in the exponent. f64.pow above is the inexact counterpart. decimal and rational have no pow of their own yet.
Rendering an f64 with a fixed number of digits is to_fixed(places), printf's %.Nf. Display cannot spell it: it prints the shortest text that round-trips (1.0f64 renders as 1 and never as 1.00), and interpolation has no format-spec channel. A method is the only available shape. Ties round half to even, as %.2f does in C, Python, Go and Crystal, and unlike round above, which is half away from zero. There is no rounding-mode parameter, the one place this differs from decimal.to_fixed: on the binary tier the tie is usually not a real one, 2.675f64 being 2.67499…, and a mode would offer a choice about a digit the value does not have. A caller that needs modes crosses to decimal, which is exact and mode-complete. Zero-padding follows printf (1.5f64 at 3 places is 1.500), a negative places clamps to zero and an absurd one to 1100, past which an f64 has no more information, and the non-finite values render as NaN, inf and -inf, a fixed number of digits meaning nothing for them.
Generic numeric code goes through the core numeric module's trait Numeric: the static identities zero() and one() plus add and mul, whose per-type impl bodies are the native + and *. It is implemented for all 13 numeric types, and generic code can bound <T: Numeric> and fold numerically. List.sum and List.product (§17) reach the identities as T.zero() and T.one() through the bound, per §10's static-through-bound dispatch. It is a plain trait because the operator traits (§10) exclude builtin-primitive operands (H0573), and bounds on traits declaring type parameters are staged (H0569), which Add does. Semantics inherit the tower: fixed-width add and mul wrap like the operators, floats remain IEEE, and the lowercase tier's in-band sentinels propagate unchanged.
Counting iteration is stdlib and not syntax. Hanki has no for, no while and no ... Range.new(lo, hi) is the half-open [lo, hi) integer range, with a subset of List's combinators: each!(|i| …), which is effectful and runs as a loop, adding no stack depth for a large range; map(|i| …) -> List<U>; fold(init, |acc, i| …); plus length and to_list. The effect-polymorphic ! twins beyond each! are List-only for now, and an effectful range traversal that must return a value goes through to_list first. The inclusive counterpart is int.upto: 1.upto(3) covers 1, 2 and 3, a Range over [self, hi+1). n.times!(|i| …) runs an action n times with index 0 … n-1, and a zero or negative count runs it zero times. Bounds are the scripting-default int, the index surface below, and loop counters need no conversions. All of it is Hanki over int arithmetic and closures, with no new syntax and no intrinsics.
Bitwise operations are inherent methods on the fixed-width tier: bit_and, bit_or and bit_xor, per-bit over two same-width operands; bit_not, the per-bit complement; and the shifts bit_shl and bit_shr, whose shift amount is a u32. They are methods and not operators, to keep the grammar small (§2). All are total. A shift takes its amount mod the bit width (1u8.bit_shl(8u32) is 1u8, never a trap), and bit_shr is type-directed, arithmetic and sign-extending on the signed widths, logical and zero-filling on the unsigned. They exist only on the fixed-width tier; the lowercase int, decimal and rational tier has no fixed bit width to complement or shift within. Both tiers narrow to the width, apply the native bit operation, then re-widen, and they agree bit for bit: the bytecode tier routes each op through a per-width intrinsic, wrapping_shl and wrapping_shr for the shifts, and the AOT tier emits the LLVM instruction inline, and/or/xor and shl/lshr/ashr with the shift amount masked mod the width. u32 also exposes count_ones, the population count, which is the number of set bits.
The lowercase int tier is arbitrary precision with a small-int fast path. A magnitude within ±2^62 is held inline as a machine word with no allocation, and only a larger value routes through num-bigint::BigInt: bytecode Value::BigInt as an inline Small or a boxed Big, and AOT a low-bit-tagged i64, either 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 and overflow cases routing through the hanki_int_* C-ABI. The small and big split is never observable. decimal is similarly arbitrary precision through bigdecimal::BigDecimal (bytecode Value::BigDecimal, AOT hanki_dec_* C-ABI). rational is exact p/q through num-rational::BigRational (bytecode Value::Rational, AOT hanki_rat_* C-ABI). The fixed-width tier stops at 64 bits: i128 and u128 are not native on x86_64 or ARM64 and are compiler-synthesised, the everyday width-agnostic type is int, and the rare crypto and UUID scratch uses 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 does not fit its declared suffix is a compile-time error and no runtime trap: 128i8 is rejected, the i8 range being -128..127, as are 256u8 and -1u8. A leading unary - directly preceding a numeric literal folds into the literal, and -128i8 parses as the single literal -128 and is accepted as the signed i8 minimum. Unsuffixed literals have no fixed range and are bound by inference. Lowercase int literals are unbounded: 9999999999999999999999999999999999999999999 parses and lowers as a Const::BigInt directly.