Renderingen-US

Math in the ether

Faber treats a number's width the way measurement treats a value: it has no width until it is observed. A value is observed when it is stored into a cell or converted into one. Until then, no boundary applies to it.

So arithmetic runs unbounded — or as bare IEEE for floats — and a width limit is applied once, at the store or the conversion. Nothing clamps, traps, saturates or wraps per operation. In (100 + 200) / 2 the sum, 300, does not fit a u8, but nothing is checked there: the expression evaluates to 150, and only the store into a u8 cell is checked, where 150 fits.

The policy belongs to the cell, not to the arithmetic. An integer cell is one of three:

CellOn store or conversion
saturating<u8>clamps to the cell's range
wrapping<u8>reduces modulo the cell's width
trapping<u8>errors when the value does not fit

inf is the integer that stays unbounded after it is stored. A value that does not fit a fixed-width cell does not become inf by itself: the cell still applies its policy, and a bounded intermediate that leaves the 64-bit range traps. Write inf on a slot, or put an inf operand in the expression, and the unbounded width takes over — any integer width widens into it, and a store into an inf cell is never a size check.

Float cells follow the same rule: a bare f64 and a saturating<f64> both give ±infinity on overflow, because there is no maximum finite value to clamp to; only a trapping<f64> cell errors, and only when a non-finite value is stored or converted into it.

What it looks like#

The same unbounded sum is stored into cells with different policies:

main {
    const int a ← 200
    const int b ← 100
    const int exact ← (a + b) / 2
    print exact
    const saturating<u8> sat ← (a + b) ↦ saturating<u8>
    print sat
    const wrapping<u8> wrap ← (a + b) ↦ wrapping<u8>
    print wrap
}
$ faber run
150
255
44

(a + b) is 300 in the ether, and (a + b) / 2 is exactly 150 — the unbounded int keeps it. The limits appear only where the value becomes a u8: the sum saturates to 255 in a saturating cell, and reduces to 44 in a wrapping cell.

A trapping cell turns the same overflow into an error at the store:

main {
    const int big ← 300
    const trapping<u8> cell ← big ↦ u8
    print cell
}
$ faber run
error: int to int conversion out of range

Why it works this way#

The rule buys predictability. A reader can look at an expression and reason about it as arithmetic, without carrying the widths of every operand in their head or wondering where an implicit reduction happens. The one place a boundary can bite is the one place the source names it: the store, or the ↦.

It also composes with the rest of the language. Because numeric families do not mix silently, an operation between two different numeric types is an explicit conversion — which is exactly the point where a policy would be applied. A saturating or wrapping cell cannot fail on conversion, so the conversion needs no default and no error channel; a trapping cell is where an out-of-range store is reported. See Types and values for the cell types themselves.

Every numeric width, the overflow policies it accepts, and the policy a bare marker uses:

WidthPoliciesBare default
i8trapping, wrapping, saturatingtrapping
i16trapping, wrapping, saturatingtrapping
i32trapping, wrapping, saturatingtrapping
i64trapping, wrapping, saturatingtrapping
u8trapping, wrapping, saturatingtrapping
u16trapping, wrapping, saturatingtrapping
u32trapping, wrapping, saturatingtrapping
u64trapping, wrapping, saturatingtrapping
d64trappingtrapping
inftrapping, wrapping, saturating — one typeunbounded
f16trapping, saturatingsaturating
bf16trapping, saturatingsaturating
f32trapping, saturatingsaturating
f64trapping, saturatingsaturating