Lowent Manual←↑→

40 Proofs about numbers — widening, narrowing, division

What to know first

chapter 4, Numbers · widening is automatic, narrowing is written
chapter 13, Named types · range lo hi is a contract that became the shape of a parameter
chapter 39, The mathematical toolkit · the three conditions of a partial order, and the join

Looking back

In chapter 4, u8 and i16 could be mixed but u32 and i32 could not. What separated them?

A. Whether a value-preserving widening exists. Every value of u8 (0 … 255) fits in i16, but large values of u32 do not fit in i32 of the same width. This chapter defines that “value-preserving widening” as a partial order from chapter 39 and reads the machine proof that it really is safe.

The need for this chapter, and its context

In C, unsigned char small = 300; silently becomes 44, and putting -1 into an unsigned int gives 4294967295. Lowent allows only value-preserving widening automatically, and if losing is acceptable, makes you choose how to lose (narrow_wrap, narrow_sat, narrow_try). Saying that rule is right means a table of a few lines has no mistakes, and people get such tables wrong. So the definition was set up mathematically and the job of missing no case was given to a machine. These are the proofs that touch code most directly, so they come right after the tools.

By the end of this chapter

You will learn the four-line definition of the widening relation ⊑ and the theorem that it is a value-preserving partial order, along with properties of the join and narrowing. You will pick up exactly when division fails, the sign promise of mod, and the theorems about range that ground the removal of checks. You will also see how the three theorems of the type rules (progress, preservation, values_fit) put stopping into the model honestly.

The questions this chapter answers

  1. Is it worth proving separately that widening cannot fail?

40.1 Widening is a partial order#

t ⊑ u reads “any value of type t fits into type u without changing”. The definition is four lines.

Left → rightConditionWhy
uN → uMN ≤ MBetween unsigned types only width must be larger
iN → iMN ≤ MThe same between signed types
uN → iMN < Mu8 (255) does not fit in i8 (127). Width must be strictly larger
iN → uMnoneThere is nowhere to put negatives

Table 40.1 — The widening relation ⊑

The < in the third line is the point. Written as ≤, u8 ⊑ i8 would be allowed and 255 would become −1. This one character decides value safety, and people get such things wrong.

 u8 ──▶ u16 ──▶ u32 ──▶ u64
   ╲       ╲       ╲
    ▼       ▼       ▼
 i8 ──▶ i16 ──▶ i32 ──▶ i64

 → is ⊑ (fits without changing the value); following arrows is ⊑ too (transitivity).
 There is no same-width arrow down such as u8 → i8 or u16 → i16 (N < M),
 and no arrow up from the i row to the u row (nowhere to hold a negative).

examples/ch40/order_strict.low

module order_strict .
rem expect: E-TYPE-SIGN

fn same_width input a u8 . output i8 .
do
  return a .
end

Output

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

examples/ch40/order_ok.low

module order_ok .
rem run: wider 200

fn wider input a u8 . output i16 .
do
  return a .
end

Output

$ lowentc --run wider order_ok.low 200
wider(200) = 200

What NumericLattice.v proves about this relation:

The mathematics. The skeleton of the proof that widening preserves values

Split into four cases. If uN ⊑ uM (N ≤ M), both lower ends are 0 and the upper ends satisfy 2N − 1 ≤ 2M − 1 — using only the fact that powers of 2 are monotonic. If uN ⊑ iM (N < M), then N ≤ M − 1, so 2N − 1 ≤ 2M−1 − 1. Signed pairs are the same, and iN ⊑ uM is false by definition, so there is nothing to do. The key lemma is just “if a ≤ b then 2a ≤ 2b”. The whole proof stands on high-school exponent laws. What is hard is not the argument but missing not a single case, which is why it is left to a machine.

40.2 Division and remainder#

Division is a place this language treats with unusual care.

examples/ch40/division.low

module division .
rem run: quot -7 2
rem trap: quot -128 -1
rem run: rest -7 3
rem run: rest 7 -3

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

rem the sign of mod follows the divisor
fn rest input a i8 . input b i8 . output i8 . do
  return mod a b .
end

Output

$ lowentc --run quot division.low -7 2
quot(-7, 2) = -3
$ lowentc --run rest division.low -7 3
rest(-7, 3) = 2
$ lowentc --run rest division.low 7 -3
rest(7, -3) = -2
$ lowentc --run quot division.low -128 -1
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: division overflow (MIN / -1) at the declared width

Signed division -7 / 2 truncates towards zero to −3. -128 / -1 stops because i8 has no 128. In C this one case is undefined behaviour. The sign of mod follows the divisor — mod -7 3 is 2 and mod 7 -3 is −2.

TheoremMeaning
div_unsigned_totalUnsigned division always succeeds unless dividing by zero
div_signed_failure_is_only_min_neg1The only failing signed division is MIN / −1
mod_sign_follows_divisorThe sign of mod follows the divisor
mod_is_a_safe_indexmod h (len s) is always at least 0 and less than len s

Table 40.2 — What is proven about division and remainder

 unsigned div a b     b = 0                 → stops
                      otherwise             → always a value
 signed div a b       b = 0                 → stops
                      a = MIN and b = −1    → stops (undefined behavior in C)
                      otherwise             → always a value (truncated toward 0)
 mod h n              when n > 0            → 0 ≤ result < n  (a safe index)

The last theorem has great practical value.

examples/ch40/modslot.low

module modslot .
rem run: pick [5,6,7,8] 1000003
rem ir

fn pick input table slice u8 . input h u64 . output u8 .
  requires gt (len table) 0 .
do
  return index table (mod h (len table)) .
end

Output

$ lowentc --run pick modslot.low [5,6,7,8] 1000003
pick([5,6,7,8], 1000003) = 8
  arg0 (written) = [5,6,7,8]
$ lowentc --ir modslot.low
-- runtime checks (interval analysis: overflow · division · narrowing) --
   2 / 2 removed  (100%)

Hash tables always pick slots with mod, and because the result is proven in range, the index check disappears. What if len s is 0? It divides by zero and stops first. So on any path that yields a value, len s > 0 is guaranteed. The argument has no gap.

40.3 Theorems that range provides#

range lo hi written in a parameter’s type position (chapter 13) makes the widening story one layer more precise.

TheoremMeaning
rsub_preservesWidening between ranges also preserves values
radd_soundThe sum of two ranges lies within the computed range
radd_no_checkIf the result range fits in the type, no overflow check is needed
idx_no_checkIf the index range lies within the length, no bounds check is needed
rdisj_no_valueTwo disjoint ranges share no value (so one branch is dead)
derive_minimalThe derived range is the smallest (no wider than needed)

Table 40.3 — What is proven about ranges

radd_no_check and idx_no_check are what justify removing checks. The analysis that applies these two to real code is in chapter 41.

40.4 Putting stopping into the model honestly#

The type rules are proven one layer deeper (LowentType.v). Over numeric expressions with width and sign, it adds binding, branches, comparison, narrowing, checked arithmetic and wrapping arithmetic, and proves three theorems.

The third is special in this language. If a value over 8 bits lands in a u8 place, the emitted C silently does something else. So ranges were put inside the type rules (attaching a type to a value requires range evidence) and proven preserved throughout execution.

And that the conclusion has three parts is the point. Checked arithmetic stops instead of producing a value on overflow. Put stopping into values and the theorem lies; leave stopping out and the theorem is false. So it is “value · stop · step”. Preventing stops at translation time is the normal path’s job, done by contracts and certificates (chapter 41).

 a closed, typed expression e
    ├─▶ a value v     values_fit: v always fits the width of its type
    ├─▶ it stops      checked arithmetic overflowed · division by zero
    └─▶ one step → e' preservation: e' has the same type (and again one of the three)

Q. Is it worth proving separately that widening cannot fail?

A. Yes (widen_never_fails). “Cannot fail” must be proven for widening sites to carry no run-time check. That is why widening is free. Conversely, narrow_ok_iff pins narrowing’s success condition to “exactly within range”, so narrow_try u8 300 does not quietly give 44 but none. Failure is a value.

A common misconception. Since values_fit is proved, an overflowing value can never reach a u8 position in this edition’s compiler

The theorem is about the type rules. Whether the compiler follows those rules in every place is a separate matter. One example found while writing this manual: when a pipe’s map produces u64 and collect into stores it in a u8 buffer, this edition’s tool silently wrapped the value for a long time (it is now rejected with E-TYPE-COLLECT, chapter 24). The gap between a proof that the rules are right and the fact that the implementation applies them everywhere shows up in places like this, and tests and the comparison of the two back ends fill it. Proofs decide what must be stopped; whether it was stopped is measured.

40.5 What is not proven#

Recap

Widening ⊑ is a partial order defined in four lines, and the condition for uN ⊑ iM is the strict N < M. Widening preserves values and cannot fail, the join is one of its two operands, and narrowing succeeds exactly within range. The only failing signed division is MIN / −1, mod follows the divisor’s sign and is always a safe index. The range theorems ground check removal, and the type rules put stopping into the model honestly as “value · stop · step”. Floating point is outside the proofs.