Lowent Manual←↑→

4 Numbers — fixed-width integers and floating point

What to know first

chapter 2, A first program · the VM and native code must agree
chapter 3, The surface · a literal that does not fit its place’s type is rejected

Looking back

What happened to let x u8 300 . in chapter 3? How does C handle the same thing?

A. It was rejected with E-TYPE-WIDTH, because 300 is outside the u8 range 0 … 255. C silently truncates it to 44, so the value in the source and the actual value differ. This chapter looks at how the same principle applies when a computed result, not a literal, exceeds its width.

The need for this chapter, and its context

Integer defects are among the oldest and most common in systems programs. Values overflow into small numbers, negative numbers turn into huge positive ones, and division by zero becomes undefined behaviour. Most of it happens silently and surfaces much later in an unrelated place. Numbers open Part II because all computation happens on them, and because they show in the simplest form how Lowent removes “silently” — by making you write the chosen treatment as a name.

By the end of this chapter

You will learn that integer types are named by sign and width and have the same size everywhere. You will pick up the rule that widening is automatic and narrowing must be written, that overflow and division by zero stop execution, and how to choose by name to wrap, saturate or get the failure back as a value instead. You will also see that booleans are not numbers, and the behaviour of bitwise operations and floating point.

The questions this chapter answers

  1. If narrow_wrap does what C does, why isn’t wrapping the default?
  2. Why can’t bitwise operations be written infix inside an expr island?
  3. Isn’t writing cast every time a chore?

4.1 Integer types name their width#

An integer type’s name is its sign and bit width. u means unsigned, i means signed.

TypeWidthRange
u8 · u16 · u32 · u648 · 16 · 32 · 640 … 2N−1
i8 · i16 · i32 · i648 · 16 · 32 · 64−2N−1 … 2N−1−1
usize · idiff16 or more — set by the implementationby width (see “The size types” below)
u128 · i128128 — on 64-bit targets onlysame pattern (see “128-bit integers” below)

Table 4.1 — Integer types and their ranges

C’s int can have different sizes on different machines, but Lowent’s u32 is 32 bits everywhere. Negative numbers use two’s complement. usize is a different type from u64 even on a machine where they have the same width, and the two do not convert automatically — so that places mixing lengths with plain numbers show in the source. The one word that crosses is cast (see “The size types” below).

4.2 128-bit integers#

u128 · i128 exist on 64-bit targets only (x86_64 · arm64 · riscv64 · win64). On a 32-bit target the words are refused (E-INT128-TARGET). Use them to hold, as one value, a computation wider than 64 bits — such as the whole product of two 64-bit numbers.

fn top_half input a u64 . input b u64 . output u64 . do
  let x u128 widen u128 a. . .
  return cast u64 shr mul x. widen u128 b. . . 64 . . .
end .

4.3 Widening is automatic, narrowing is written#

A conversion that loses no value is called widening. Passing a u8 where a u32 is expected keeps the value, so the compiler does it for you. Even with mixed signs, arithmetic is allowed if a value-preserving widening exists — every u8 fits in an i16.

examples/ch04/widths.low

module widths .
rem run: mix 200 -100
rem run: shrink 200
rem trap: shrink 300
rem run: shrink_sat 300
rem run: shrink_wrap 300

fn mix input a u8 . input b i16 . output i16 .
do
  return add a. b. . .
end .

fn shrink input x u64 . output u8 .
do
  return narrow u8 x. . .
end .

fn shrink_sat input x u64 . output u8 .
do
  return narrow_sat u8 x. . .
end .

fn shrink_wrap input x u64 . output u8 .
do
  return narrow_wrap u8 x. . .
end .

Output

$ lowentc --run mix widths.low 200 -100
mix(200, -100) = 100
$ lowentc --run shrink widths.low 200
shrink(200) = 200
$ lowentc --run shrink_sat widths.low 300
shrink_sat(300) = 255
$ lowentc --run shrink_wrap widths.low 300
shrink_wrap(300) = 44
$ lowentc --run shrink widths.low 300
== ir diagnostics (1) ==
0:0 E-VM-CAST: value does not fit the target width (use narrow_wrap / narrow_sat / narrow_try)

mix adds a u8 and an i16, and the result has the wider type, i16. To make the widening visible, write widen u64 x. .. An i32 and a u32 of the same width cannot be mixed, because neither fits entirely in the other.

examples/ch04/sign_bad.low

module sign_bad .
rem expect: E-TYPE-SIGN

fn total input a i32 . input b u32 . output i32 .
do
  return add a. b. . .
end .

Output

$ lowentc --check sign_bad.low
sign_bad.low:6:0 E-TYPE-SIGN: sign mismatch: no value-preserving widening exists (widen both to a strictly wider signed type, or bitcast_sign)

A conversion that can lose a value is narrowing, and it must be written. shrink in the same file narrows a u64 to a u8 with narrow u8 x. .. 200 fits and comes out unchanged, but 300 does not, so execution stops (E-VM-CAST). If you do not want it to stop, choose the treatment you want by name. narrow_sat stops at the end value 255, and narrow_wrap wraps like C and gives 44. narrow_try gives none when the value does not fit.

Q. If narrow_wrap does what C does, why isn’t wrapping the default?

A. To leave in the source whether wrapping was intended or a mistake. If wrapping were the default, someone reading a place where narrow u8 x. . gave 44 could not tell whether that was the wanted value. With stopping as the default, an author who wanted wrapping wrote narrow_wrap. The name written is the record of the author’s intent.

4.4 Overflow stops#

Arithmetic happens at the declared width. If the result does not fit the width, execution stops (a trap).

examples/ch04/overflow.low

module overflow .
rem run: bump 254
rem trap: bump 255
rem run: bump_wrap 255
rem run: bump_sat 255

fn bump input a u8 . output u8 .
do
  return add a. 1 . .
end .

fn bump_wrap input a u8 . output u8 .
do
  return wrap_add a. 1 . .
end .

fn bump_sat input a u8 . output u8 .
do
  return sat_add a. 1 . .
end .

Output

$ lowentc --run bump overflow.low 254
bump(254) = 255
$ lowentc --run bump_wrap overflow.low 255
bump_wrap(255) = 0
$ lowentc --run bump_sat overflow.low 255
bump_sat(255) = 255
$ lowentc --run bump overflow.low 255
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)

bump 254 fits as 255, but bump 255 would have to be 256, so the VM stops with E-VM-OVERFLOW. Native code stops at the same place with a non-zero exit code (the verification script checks that both stop). As with widening, you change the treatment by choosing a name.

TreatmentOpsOn overflow
Stop (default)add · sub · mulExecution stops
Wrapwrap_add · wrap_sub · wrap_mulWraps around within the width
Saturatesat_add · sat_sub · sat_mulStops at the end value of the width
As a valuechk_add · chk_sub · chk_mulReports it as an option

Table 4.2 — Choosing the treatment of overflow by name

The “as a value” family turns overflow into an event the program can handle.

examples/ch04/chk.low

module chk .
rem run: safe_bump 255
rem run: safe_bump 7

fn safe_bump input a u8 . output u64 .
do
  let r option u8 chk_add a. 1 . .
  guard is_some r. . else return 999 . .
  return widen u64 some_value r. . . .
end .

Output

$ lowentc --run safe_bump chk.low 255
safe_bump(255) = 999
$ lowentc --run safe_bump chk.low 7
safe_bump(7) = 8

chk_add a. 1 . gives an option u8. On overflow it is none, and guard is_some r else … handles that case first. You will meet this shape again in chapter 11.

A common misconception. Checking for overflow makes code slow

It would if every addition kept its check. But where a contract or a range type proves the range of a value, the check is removed at translation time (chapter 14). Places with an obvious range, such as loop counters, are cleaned up by the compiler’s interval analysis. The checks that remain are at places that really can overflow, and there the check is not a cost but the value that stops a defect.

4.5 Division and remainder#

Dividing an integer by zero stops. Signed division truncates towards zero, and the sign of the remainder mod follows the divisor.

examples/ch04/divide.low

module divide .
rem run: quot -7 2
rem run: modulo -7 2
rem trap: quot 1 0
rem run: safe_div 7 0
rem run: safe_div 7 2

fn quot input a i32 . input b i32 . output i32 .
do
  return div a. b. . .
end .

fn modulo input a i32 . input b i32 . output i32 .
do
  return mod a. b. . .
end .

fn safe_div input a u32 . input b u32 . output u32 .
do
  let nz option nonzero u32 nonzero_of b. . .
  guard is_some nz. . else return 0 . .
  return div_nz a. some_value nz. . . .
end .

Output

$ lowentc --run quot divide.low -7 2
quot(-7, 2) = -3
$ lowentc --run modulo divide.low -7 2
modulo(-7, 2) = 1
$ lowentc --run safe_div divide.low 7 0
safe_div(7, 0) = 0
$ lowentc --run safe_div divide.low 7 2
safe_div(7, 2) = 3
$ lowentc --run quot divide.low 1 0
== ir diagnostics (1) ==
0:0 E-VM-DIV0: divide by zero

quot -7 2 is −3 and modulo -7 2 is 1. Because the remainder follows the divisor’s sign, mod h. n. . for a positive n is always at least 0 and less than n. That property is why the bounds check in idx slots. mod h. n. . . disappears when a hash table picks a slot, and it is proven in Coq (chapter 40).

The only case where signed division overflows is MIN / −1, and that stops too. In C it is undefined behaviour.

There is also a way to check once that a divisor is not zero and carry that fact around. nonzero_of b. . gives an option nonzero u32, and the value inside can be passed to div_nz. No zero check remains at the div_nz site. The check was not removed but moved to one place, with its result carried in the type.

4.6 Booleans are not numbers#

bool has two values, true and false. An integer in a condition is rejected.

examples/ch04/cond_bad.low

module cond_bad .
rem expect: E-TYPE-COND

fn nonzero_flag input n u32 . output u8 .
do
  if n. do return 1 . end .
  return 0 .
end .

Output

$ lowentc --check cond_bad.low
cond_bad.low:6:0 E-TYPE-COND: a condition must be bool, not a number — this language has no truthiness (D12: no implicit int↔bool). Say what the test is: `ne x 0`

C’s if (n) reads as “n is not zero”, but the reader has to guess from context whether it means “n exists” or “n is true”. Lowent makes you write what you are asking — if gt n 0. Conversely, a bool cannot be added like a number. and, or and not take only booleans, and and and or skip the right-hand side when the left-hand side already decides the answer.

4.7 Bitwise operations#

Bitwise operations treat a value as a sequence of bits. They use words instead of symbols.

examples/ch04/bits.low

module bits .
rem run: mask 12 10
rem run: flip 12
rem run: spin 129 1
rem run: s_shr -8 1
rem run: u_shr 4294967288 1
rem run: ones 7

rem 12 = 0000_1100 · 10 = 0000_1010
fn mask input a u8 . input b u8 . output u8 . do return bit_and a. b. . . end .
fn flip input a u8 . output u8 . do return bit_not a. . . end .
fn spin input a u8 . input n u8 . output u8 . do return rotl a. n. . . end .
fn s_shr input a i32 . input n u32 . output i32 . do return shr a. n. . . end .
fn u_shr input a u32 . input n u32 . output u32 . do return shr a. n. . . end .
fn ones input a u8 . output u8 . do return count_ones a. . . end .

Output

$ lowentc --run mask bits.low 12 10
mask(12, 10) = 8
$ lowentc --run flip bits.low 12
flip(12) = 243
$ lowentc --run spin bits.low 129 1
spin(129, 1) = 3
$ lowentc --run s_shr bits.low -8 1
s_shr(-8, 1) = -4
$ lowentc --run u_shr bits.low 4294967288 1
u_shr(4294967288, 1) = 2147483644
$ lowentc --run ones bits.low 7
ones(7) = 3

Shifting by at least the width of the type stops. In C that is undefined behaviour. If you want the shift amount wrapped into the width, use wrap_shl and wrap_shr.

A shift count has an unsigned integer type. That is why n in s_shr is a u32 — written as i32 the program is refused (E-SHIFT-SIGNED), because a negative count has no single meaning. A rotation turns by the count modulo the width, so it takes any integer.

Q. Why can’t bitwise operations be written infix inside an expr island?

A. In C, a & b == c groups as a & (b == c). It is a trap even for long-time users, and languages disagree on bitwise precedence. Putting them in the island would add something to look up instead of making them easier to read. Written prefix, the parentheses state the answer and nothing can be misread.

4.8 Floating point#

f32 and f64 are the IEEE 754 binary 32- and 64-bit formats. Operations happen at the width of their operands, and integers and floating-point values do not convert to each other automatically. One operation is one rounding — the processor does not fuse a multiply and an add into one instruction (FMA). Fused, one rounding goes missing and 0.1 × 10 − 1 is no longer 0. A conversion is one rounding too — cast f32 rounds an f64 or an integer to the nearest f32, and a number too large for f32 becomes infinity (cast f32 <f64 1e300> is inf).

examples/ch04/floats.low

module floats .
rem run: third 1.0
rem run: hyp 3.0 4.0

use math .

fn third input x f64 . output f64 .
do
  return div x. 3.0 . .
end .

fn hyp input a f64 . input b f64 . output f64 .
do
  return math.sqrt add mul a. a. . mul b. b. . . . .
end .

Output

$ lowentc --run third floats.low 1.0
third(1.0) = 0.333333
$ lowentc --run hyp floats.low 3.0 4.0
hyp(3.0, 4.0) = 5.0

The VM shows floating-point results briefly (0.333333). Dividing a floating-point value by zero does not stop; it becomes infinity as IEEE 754 prescribes. Transcendental functions such as sqrt, sin and exp live in the standard library math (use math ., then math.sqrt x. .); they are floating-point only and available only on machines with an operating system. The exact last bit of their results is up to the machine and its maths library.

A common misconception. Floating-point values can be compared with eq too

0.1 plus 0.2 does not have the same bits as 0.3. Floating-point equality usually has to be asked as “close enough”, and the standard library’s math module provides that question (close) (chapter 32). Rounding, NaN and −0 are also properties the proofs of this language do not cover (chapter 50).

f32 and the size types follow the same rules.

examples/ch04/sizes.low

module sizes .
rem run: advance 5 -8
rem run: third32 1.0
rem run: same_third 1.0 1.0

rem usize and idiff are for lengths, indexes and their differences; the implementation sets the width (16 bits or more), and they cross to other integers through cast only
fn advance input n usize . input k idiff . output idiff .
  requires le n. 1000 . .
  requires ge k. -1000 . .
  requires le k. 1000 . .
do
  return add cast idiff n. . k. . .
end .

rem f32 is a 32-bit float: half the memory, about seven significant digits
fn third32 input x f32 . output f32 . do
  return div x. 3.0 . .
end .

rem even 1/3 is a different number in f32 and f64; widened and compared, they are not equal
fn same_third input x f32 . input y f64 . output bool . do
  let a f32 div x. 3.0 . .
  let b f64 div y. 3.0 . .
  return eq widen f64 a. . b. . .
end .

Output

$ lowentc --run advance sizes.low 5 -8
advance(5, -8) = -3
$ lowentc --run third32 sizes.low 1.0
third32(1.0) = 0.333333
$ lowentc --run same_third sizes.low 1.0 1.0
same_third(1.0, 1.0) = 0

4.9 The size types — usize and idiff#

Lengths, indexes and byte counts are written usize. The numbers that len, bitset_count, stack_count and capacity give are usize. idiff is its signed partner, and it has one use: the difference of two positions. Unlike the other integers, these two do not name their width.

4.9.1 The implementation sets the width#

usize and idiff are at least 16 bits wide, and at most as wide as the implementation’s widest integer. This compiler uses the address width of the target machine, which --target selects.

WidthLargest usizeRange of idiffSuch a machine
1665 535−32 768 … 32 767a small microcontroller — word16 (translation only)
324 294 967 295−2 147 483 648 … 2 147 483 647a 32-bit board such as cortex_m
64264−1−263 … 263−1x86_64 · arm64 · riscv64

Table 4.3 — The range of usize and idiff by width

The aim is source that translates on all three. The rules below therefore hold without asking the width.

4.9.2 They do not mix with other integers — the crossing is cast#

usize and idiff never convert automatically to or from any other integer type: not from u8 to usize, and not from usize to u64. They do not mix in arithmetic or comparison either — to add or compare a usize and a u64, move one of them first. The word that moves is cast, and only cast.

cast gives the value unchanged when it fits the target type and stops when it does not (E-VM-CAST). It never cuts silently.

widen and narrow cannot be used with usize or idiff. Those two words state a direction, and going from usize to u32 narrows on a 64-bit machine and widens on a 16-bit one. A conversion whose direction depends on the machine, written with a word of direction, is true on one machine only. cast asks no direction.

Number literals are written as they are — the 1 in add i. 1 . and the 0 in idx xs. 0 . take the type of their place.

examples/ch04/usize_cast.low

module usize_cast .
rem run: last [3,4,5]
rem run: bucket [1,2,3,4] 1000003
rem run: from_outside [9,9,9] 2
rem run: from_outside [9,9,9] 7
rem run: gap 3 10
rem run: packed [5,6,7,8] 3

rem carry lengths and indexes as usize all the way --- no conversion at all
fn last input xs slice u8 . output u8 .
  requires gt len xs. . 0 . .
do
  let n usize len xs. . .
  return idx xs. sub n. 1 . . .
end .

rem where a length meets a number that is not one (a hash) --- compute on the wide side (u64), cast when it becomes an index
fn bucket input xs slice u8 . input h u64 . output u8 .
  requires gt len xs. . 0 . .
do
  let slot u64 mod h. cast u64 len xs. . . . .
  return idx xs. cast usize slot. . . .
end .

rem take a number from outside with narrow_try, which does not stop --- none when it does not fit, and the program decides
fn from_outside input xs slice u8 . input want u64 . output u8 . do
  let p option usize narrow_try usize want. . .
  guard is_some p. . else return 255 . .
  let i usize some_value p. . .
  guard lt i. len xs. . . else return 254 . .
  return idx xs. i. . .
end .

rem the difference of two positions is an idiff --- it holds half the range of usize, and cast guards that
fn gap input a usize . input b usize . output idiff . do
  return sub cast idiff a. . cast idiff b. . . .
end .

rem an index kept in a narrow field (u8) --- write cast on the way in and on the way out
fn packed input xs slice u8 . input i usize . output u8 .
  requires lt i. len xs. . . .
do
  let small u8 cast u8 i. . .
  return idx xs. cast usize small. . . .
end .

Output

$ lowentc --run last usize_cast.low [3,4,5]
last([3,4,5]) = 5
  arg0 (written) = [3,4,5]
$ lowentc --run bucket usize_cast.low [1,2,3,4] 1000003
bucket([1,2,3,4], 1000003) = 4
  arg0 (written) = [1,2,3,4]
$ lowentc --run from_outside usize_cast.low [9,9,9] 2
from_outside([9,9,9], 2) = 9
  arg0 (written) = [9,9,9]
$ lowentc --run from_outside usize_cast.low [9,9,9] 7
from_outside([9,9,9], 7) = 254
  arg0 (written) = [9,9,9]
$ lowentc --run gap usize_cast.low 3 10
gap(3, 10) = -7
$ lowentc --run packed usize_cast.low [5,6,7,8] 3
packed([5,6,7,8], 3) = 8
  arg0 (written) = [5,6,7,8]

4.9.3 Moving without stopping#

When cast stops, the program ends. If the value came from outside the program — a header field in a file, a length received from the network, a number the user typed — its not fitting is no defect but input to refuse. For those places use one of the three words whose name picks what happens.

WordWhat it does
cast usize x. .stops — when not fitting is a defect
narrow_try usize x. .gives an option usize — none when it does not fit
narrow_sat usize x. .sticks to the largest value
narrow_wrap usize x. .keeps the low bits — only where wrapping is the meaning, as in a hash

Table 4.4 — Moving to usize when the value does not fit

4.9.4 What to watch for at each width#

To keep one source standing at 16, 32 and 64 bits:

Counter-example. Mixing a usize with another integer as they are

examples/ch04/mistake_usizemix.low

module mistake_usizemix .
rem expect: E-TYPE-NOMINAL

rem ✘ adding a usize to a u64 as they are --- the width of the sum was never written
fn end_of input start usize . input more u64 . output u64 . do
  return add start. cast usize more. . . .
end .

Output

$ lowentc --check mistake_usizemix.low
mistake_usizemix.low:6:0 E-TYPE-NOMINAL: these are NOMINALLY DISTINCT types with the same representation — a `newtype` (or usize/idiff) is not interchangeable with its base or with another newtype. Convert explicitly with `cast <type> <value>` (RFC-0002 §6.3.1)

start is a position in memory and more is a u64. The source does not say at which width to add. Write add cast u64 start. . more. . to add on the wide side. When the result is used as a position again, bring it down with cast usize.

Counter-example. Moving a usize with widen

examples/ch04/mistake_usizewiden.low

module mistake_usizewiden .
rem expect: E-TYPE-NOMINAL

rem ✘ moving a usize with widen --- whether it widens depends on the machine. Write cast
fn as_u64 input n usize . output usize . do
  return widen u64 n. . .
end .

Output

$ lowentc --check mistake_usizewiden.low
mistake_usizewiden.low:6:0 E-TYPE-NOMINAL: `usize` / `idiff` convert with `cast` only — their width is the implementation's (16 bits up to the widest integer), so whether this WIDENS or NARROWS depends on the target, and `widen` promises a direction. Write `cast <type> <value>`: it asks no direction and stops when the value does not fit
mistake_usizewiden.low:6:0 E-TYPE-NOMINAL: these are NOMINALLY DISTINCT types with the same representation — a `newtype` (or usize/idiff) is not interchangeable with its base or with another newtype. Convert explicitly with `cast <type> <value>` (RFC-0002 §6.3.1)

Looking at 64-bit machines only, usize to u64 seems always to widen, but the implementation sets the width. Write cast u64 n. ..

4.10 Explicit conversion is cast’s job#

Reading a value as another type happens only where the source says so. The one conversion the compiler makes by itself is widening; every other conversion is written by the author as a word. At the centre is cast <type> <value>. In this language, explicit conversion is what cast is for.

WordWhat it coversWhen the value does not fit
widenwidening that keeps the value — the compiler also does it by itselfcannot happen
narrowa smaller width within the same kindstops
castevery other conversion — changing the sign · between integers and floating point · between usize/idiff and other integers · a bool to a number · between an enum and a numberstops

Table 4.5 — The three words that write a conversion

examples/ch04/cast_kinds.low

module cast_kinds .
rem run: sign 200
rem run: to_float 7
rem run: to_int 7.9
rem run: bool_num 9
rem run: level_num 2
rem trap: sign 40000

rem change the sign --- read a u32 value as i16. It stops when the value does not fit
fn sign input n u32 . output i16 . do
  return cast i16 n. . .
end .

rem cross kinds --- an integer to a floating-point number
fn to_float input n u32 . output f64 . do
  return cast f64 n. . .
end .

rem a floating-point number to an integer --- the fraction is dropped toward 0
fn to_int input x f64 . output i64 . do
  return cast i64 x. . .
end .

rem a bool to a number --- true is 1, false is 0
fn bool_num input n u64 . output u8 . do
  return cast u8 gt n. 5 . . .
end .

enum level do
  low .
  mid .
  high .
end .

fn pick input k u8 . output level . do
  if eq k. 0 . do return low. . end .
  if eq k. 1 . do return mid. . end .
  return high. .
end .

rem an enum to a number --- the variant's number (the first is 1)
fn level_num input k u8 . output u8 . do
  return cast u8 pick k. . . .
end .

Output

$ lowentc --run sign cast_kinds.low 200
sign(200) = 200
$ lowentc --run to_float cast_kinds.low 7
to_float(7) = 7.0
$ lowentc --run to_int cast_kinds.low 7.9
to_int(7.9) = 7
$ lowentc --run bool_num cast_kinds.low 9
bool_num(9) = 1
$ lowentc --run level_num cast_kinds.low 2
level_num(2) = 3
$ lowentc --run sign cast_kinds.low 40000
== ir diagnostics (1) ==
0:0 E-VM-CAST: value does not fit the target width (use narrow_wrap / narrow_sat / narrow_try)

A conversion that could be read more than one way never happens by itself: the same bits could be read as two different numbers, the conversion widens on one machine and narrows on another, or the value changes kind. Types and conversions added to the language later follow the same rule — if it is ambiguous, it is written cast.

Q. Isn’t writing cast every time a chore?

A. It costs some typing. In return a reader can find every place a value may change by eye. Search the program for cast and you have seen every place a type changes. One place where the compiler changes a type silently — like C’s integer promotions or mixed signs — and that promise is broken.

4.11 Common mistakes#

Many number mistakes pass compilation and only show up while running. Where other languages would quietly produce a wrong value, Lowent stops — and the place it stops is the place to fix.

Counter-example. Finding the middle by adding first, then halving

examples/ch04/mistake_midpoint.low

module mistake_midpoint .
rem trap: mid 200 100

fn mid input a u8 . input b u8 . output u8 .
do
  rem ✘ adding first gives 300, which does not fit in u8
  return div add a. b. . 2 . .
end .

Output

$ lowentc --run mid mistake_midpoint.low 200 100
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)

200 + 100 is 300, and u8 only goes up to 255, so it overflows before the division. This is the very bug that lurked for decades in (lo + hi) / 2 inside binary searches. C would quietly give 22, half of the wrapped value 44. The fix is to compute at a wider width.

examples/ch04/midpoint_fixed.low

module midpoint_fixed .
rem run: mid 200 100
rem run: mid 255 255

fn mid input a u8 . input b u8 . output u8 .
do
  rem add one width up --- u16 holds up to 510
  let both u16 add widen u16 a. . widen u16 b. . . .
  rem half of it fits in u8 again; we know that, and narrow checks it
  return narrow u8 div both. 2 . . .
end .

Output

$ lowentc --run mid midpoint_fixed.low 200 100
mid(200, 100) = 150
$ lowentc --run mid midpoint_fixed.low 255 255
mid(255, 255) = 255

Widen to u16, add, divide, then narrow u8 back down. The middle always fits in u8, so the narrowing never stops — and if the computation is ever changed by mistake, it stops right there and tells you. (When lo <= hi is guaranteed, add lo. div sub hi. lo. . 2 . . does not overflow either.)

Counter-example. Subtracting a larger number from an unsigned one

examples/ch04/mistake_usub.low

module mistake_usub .
rem trap: gap 3 5

fn gap input a u64 . input b u64 . output u64 .
do
  rem ✘ unsigned numbers cannot go below 0 --- 3 − 5 is not negative, it stops
  return sub a. b. . .
end .

Output

$ lowentc --run gap mistake_usub.low 3 5
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)

u64 has nothing below 0. 3 − 5 is not −2 but an overflow, so it stops (C would give 18446744073709551614). If you want the size of the difference, subtract the smaller from the larger — if ge a. b. . do return sub a. b. . . end . return sub b. a. . . — and if a negative result is meaningful, compute in i64 from the start.

Counter-example. Giving a loop counter too narrow a type

examples/ch04/mistake_narrowloop.low

module mistake_narrowloop .
rem expect: E-TYPE-WIDTH

fn tally output u64 .
do
  rem ✘ a u8 loop counter has nothing after 255 --- the 256th `add i 1` stops
  var i u8 0 .
  var n u64 0 .
  while lt i. 256 . do
    set n. add n. 1 . .
    set i. add i. 1 . .
  end .
  return n. .
end .

Output

$ lowentc --check mistake_narrowloop.low
mistake_narrowloop.low:9:0 E-TYPE-WIDTH: this comparison holds a value that the other side's type cannot hold, so the answer is the same for every input — the comparison decides nothing. A name declared `u8` is at most 255, `u16` at most 65535, `i8` at most 127; a literal beyond that is out of range exactly as it would be in an assignment, where the tool has always refused it. Widen the name's type, or compare against a value the type can reach. (This is how a loop written `while lt i 256 .` over a `u8` counter never ends)
mistake_narrowloop.low:9:0 E-TYPE-WIDTH: value does not fit the declared type (a literal out of range, or a narrowing — use narrow / narrow_wrap / narrow_sat / round_to)

A u8 can never reach 256, so lt i. 256 . is always true. In C, i would wrap from 255 back to 0 and the loop would never end. Lowent refuses the comparison at translation time with E-TYPE-WIDTH: comparing against a value the type cannot hold gives the same answer for every input, so it decides nothing. Give a loop counter a type wider than the largest value it counts to (u64).

Counter-example. Doing float arithmetic with an integer literal

examples/ch04/mistake_floatint.low

module mistake_floatint .
rem expect: E-TYPE-MIX

fn half input x f64 . output f64 .
do
  rem ✘ divides an f64 by the integer literal 2 --- write 2.0 where a float is expected
  return div x. 2 . .
end .

Output

$ lowentc --check mistake_floatint.low
mistake_floatint.low:7:0 E-TYPE-MIX: a floating-point value and an INTEGER literal are mixed in one operation. Floating point and integers do not convert implicitly (§6.2.5), so write the literal as a float (`2.0`) — or convert the other side. It used to pass `--check` and stop at run time (`E-VM-TYPE: arithmetic needs ints`)

Integers and floats never convert into each other automatically. To divide an f64, write the literal as a float too: 2.0. Mixing them is rejected at translation time with E-TYPE-MIX; it used to stop while running with E-VM-TYPE.

The largest u64 goes in as it is when passed as a --run argument.

examples/ch04/runmax.low

module runmax .
rem run: echo 18446744073709551615
rem test

rem the upper half of u64 goes in as a `--run` argument as it is (since 1.20.0)
fn echo input n u64 . output u64 .
do
  return n. .
end .

rem boundary values can also be written as literals inside a test
test largest_u64
do
  let m u64 18446744073709551615 .
  expect gt m. 9223372036854775807 . .
  expect eq div m. 2 . 9223372036854775807 . .
end .

Output

$ lowentc --run echo runmax.low 18446744073709551615
echo(18446744073709551615) = 18446744073709551615
$ lowentc --test runmax.low
  [PASS] largest_u64
== tests: 1 run, 1 passed, 0 FAILED ==

Until 2026-09-16 this number was quietly cut down to 9223372036854775807 — a different number, and both back ends did it, so comparing them did not reveal it — and after that, up to 1.19.0, it was refused at the boundary (E-VM-CONTRACT). Since 1.20.0 the boundary takes 0 to 2^64 − 1 without a sign; -1 and 2^64 are still refused. Boundary values can also be written as literals inside the program, as largest_u64 does.

A common misconception. f32 and f64 hold the same number

examples/ch04/mistake_f32literal.low

module mistake_f32literal .
rem run: stored
rem run: computed

rem ✘ an f32 holding a literal as is; in this edition it is not rounded to 32 bits, so it compares equal to the f64 0.1
fn stored output bool . do
  let a f32 0.1 .
  let b f64 0.1 .
  return eq widen f64 a. . b. . .
end .

rem an f32 that went through one operation (0.1 + 0) is rounded to 32 bits and compares unequal
fn computed output bool . do
  let a f32 0.1 .
  let c f32 add a. 0.0 . .
  let b f64 0.1 .
  return eq widen f64 c. . b. . .
end .

Output

$ lowentc --run stored mistake_f32literal.low
stored() = 0
$ lowentc --run computed mistake_f32literal.low
computed() = 0

Both stored and computed are false. The 0.1 held in an f32 place is rounded to 32 bits, so widening it still does not equal the f64 0.1. Until 2026-09-16 a literal (or an argument passed with --run) was not rounded, and stored was true — the same type held values of different precision depending on where they came from. Choose the width deliberately, and write widen or narrow when comparing two widths.

A common misconception. div 7 2 . is 3.5

Integer division keeps only the quotient and drops the fraction.

examples/ch04/intdiv.low

module intdiv .
rem run: avg2 3 4
rem run: avg_f 3.0 4.0

fn avg2 input a u64 . input b u64 . output u64 .
do
  rem integer division drops the fraction --- 7 / 2 is 3
  return div add a. b. . 2 . .
end .

fn avg_f input a f64 . input b f64 . output f64 .
do
  rem if you need the fraction, compute in floating point from the start
  return div add a. b. . 2.0 . .
end .

Output

$ lowentc --run avg2 intdiv.low 3 4
avg2(3, 4) = 3
$ lowentc --run avg_f intdiv.low 3.0 4.0
avg_f(3.0, 4.0) = 3.5

avg2 3 4 is 3. If you need the fraction, take the inputs as floats and divide by 2.0. If you need rounding, write it — add div d. 2 . before dividing, for example — because the language never rounds for you.

The floating-point remainder gets its own name, math.fmod, separate from the integer mod.

examples/ch04/floatmod.low

module floatmod .
rem run: rest
rem run: whole_rest 7 2

use math .

rem the floating-point remainder is `fmod` --- a different name from the integer `mod`
fn rest output f64 .
do
  return math.fmod 7.5 2.0 . .
end .

fn whole_rest input a u64 . input b u64 . output u64 .
  requires gt b. 0 . .
do
  return mod a. b. . .
end .

Output

$ lowentc --run rest floatmod.low
rest() = 1.5
$ lowentc --run whole_rest floatmod.low 7 2
whole_rest(7, 2) = 1

math.fmod 7.5 2.0 . is 1.5 and mod 7 2 . is 1. Not loading two kinds onto one name is the same reason as for div — what is computed shows in the name.

4.12 This chapter’s syntax at a glance#

ShapeMeaningWhy
u8 … u64 · i8 … i64integers with the width in the namesizes never change from machine to machine
f32 · f64IEEE 754 floating pointnever mixed with integers — the literal is 2.0 too
widen u64 x. .widening (loses nothing)happens automatically, but can be written to make it visible
narrow u8 x. .narrowing — stops if it does not fitso values never change silently
narrow_sat · narrow_wrap · narrow_tryclamp to the end value · wrap · as an optionpick the outcome by name
cast i16 n. .every other conversion — stops when the value does not fitso every place a value changes shows in the source
add · sub · mularithmetic that stops on overflowoverflow is a bug by default
wrap_add · sat_add · chk_addwrap · saturate · optionthe intended outcome stays in the source
div · modquotient (towards zero) · remainder (sign of the divisor)division by zero and MIN / −1 stop
nonzero_of b. . · div_nzcheck “not zero” once and carry it in the typemoves the check to one place
bit_and · bit_or · bit_xor · bit_notbitwise logicno symbol-precedence traps
shl · shr · rotl · rotrshift · rotateshifting by the width or more stops
call_builtin clmul_lo · call_builtin clmul_hicarry-less multiply (builtin ops) — two words outthe machine instruction when there is one, the computation when there is not
call_builtin mul_hi (bigint64.mul_hi)the upper 64 bits of a u64 product (builtin op) — the lower half is wrap_mulone multiply where the C compiler has a 128-bit integer, four 32-bit products where it does not
count_ones · leading_zeros · trailing_zeros · byte_swapcount bits · reverse bytescommon jobs get one name
true · false · and · or · notbooleans and their logicnumbers are never used as conditions
usize · idiff · f32integers for lengths and indexes (width set by the implementation) · 32-bit floatwriting lengths and positions · when quantity matters more than precision
cast usize x · cast u64 nmoves between usize · idiff and another integerthe same meaning whatever the machine’s width

Table 4.6 — Number syntax — shape · meaning · why it looks this way

Recap

Integer types carry sign and width in their names and have the same size everywhere. Widening is automatic and narrowing is written with narrow. Overflow, division by zero, out-of-range narrowing and shifting by the width or more all stop, and other treatments (wrap, saturate, as a value) are chosen by names like wrap_, sat_, chk_ and narrow_try. The sign of mod follows the divisor. Booleans are not numbers, bitwise operations are written as words, and width and sign decide their meaning.