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 be 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?

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 · isizeaddress widthset by the execution environment

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 address counts with plain numbers show in the source.

4.2 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.3 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 be 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.4 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 be 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 index 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.5 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.6 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 i32 . 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.

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.7 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.

examples/ch04/floats.low

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

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 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 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: step 5 -8
rem run: third32 1.0
rem run: same_third 1.0 1.0

rem usize and isize have the machine's address width (64 bits on a 64-bit machine); use them to exchange lengths and indexes with C
fn step input n usize . input k isize . output isize .
  requires le n 1000 .
  requires ge k -1000 .
  requires le k 1000 .
do
  return add (cast isize 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 be div x 3.0 .
  let b f64 be div y 3.0 .
  return eq (widen f64 a) b .
end

Output

$ lowentc --run step sizes.low 5 -8
step(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.8 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 be 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 be 0 .
  var n u64 be 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)

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.

Counter-example. Testing with the largest u64 passed as a --run argument

examples/ch04/mistake_runmax.low

module mistake_runmax .
rem trap: echo 18446744073709551615
rem test

rem ✘ giving the upper half of u64 as a `--run` argument is refused at the boundary: the interval analysis believes u64 tops out at the i64 maximum
fn echo input n u64 . output u64 .
do
  return n .
end

rem write boundary values as literals inside a test
test largest_u64
do
  let m u64 be 18446744073709551615 .
  expect gt m 9223372036854775807 .
  expect eq (div m 2) 9223372036854775807 .
end

Output

$ lowentc --run echo mistake_runmax.low 18446744073709551615
== ir diagnostics (1) ==
0:0 E-VM-CONTRACT: a parameter's TYPE range was violated at the program boundary — the value does not fit the declared type (the interval analysis TRUSTS the type and deletes checks on that basis; the boundary is what makes that trust true)
$ lowentc --test mistake_runmax.low
  [PASS] largest_u64
== tests: 1 run, 1 passed, 0 FAILED ==

echo 18446744073709551615 is refused at the boundary (E-VM-CONTRACT). Until 2026-09-16 it was quietly cut down to 9223372036854775807 — a different number, and both back ends did it, so comparing them did not reveal it. It is now refused instead of cut. The reason is that the interval analysis records the upper bound of u64 as the largest i64; a value above that makes the analysis unsound. Literals inside the program are whole, so write boundary-value tests there, as largest_u64 does, and results now print unsigned.

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 be 0.1 .
  let b f64 be 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 be 0.1 .
  let c f32 be add a 0.0 .
  let b f64 be 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, fmod, separate from the integer mod.

examples/ch04/floatmod.low

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

rem the floating-point remainder is `fmod` --- a different name from the integer `mod`
fn rest output f64 .
do
  return 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

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.9 This chapter’s syntax at a glance#

ShapeMeaningWhy
u8 … u64 · i8 … i64 · usize · isizeintegers 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 xwidening (loses nothing)happens automatically, but can be written to make it visible
narrow u8 xnarrowing — 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
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
clmul_lo · clmul_hicarry-less multiply — two words outthe machine instruction when there is one, the computation when there is 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 · isize · f32address-width integers · 32-bit floatlengths exchanged with C · when quantity matters more than precision

Table 4.3 — 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.