14 Contracts — write them, have them checked, lose the checks
What to know first
errors clause is a contract on the way outLooking back
What did the single line requires le n (len a) . do in chapter 9′s sum_first? And why must an index itself not use le?
A. It gathered the check into one check on entry and let the bounds check on index a i inside the loop be removed. With le on an index, i = len a would be allowed, and that is one past the end, so indices use lt. This chapter covers all of it: what that contract is, who keeps it, and when it is checked and when it disappears.
The need for this chapter, and its context
By the end of this chapter
requires, ensures and errors each are, when they are checked, and how diagnostics assign blame when they break. You will pick up the principle by which contracts remove checks, conditions over every element of a slice (elem_le and so on), and naming contracts with contract and satisfies. You will also see violations decided at translation time, declarations of errors that can never happen, contract grades (static, debug, assume) and how build modes treat the checks that remain.The questions this chapter answers
ensuresis the op checking itself — how does that differ from a test?
14.1 A contract is a checked promise#
A contract is the promise an op makes about its own inputs and outputs. Unlike a comment, it is checked.
examples/ch14/pair.low
module pair .
rem run: read_pair [1,2,3]
fn read_pair input data slice u8 . output u32 .
requires ge (len data) 2 .
ensures le ret 65535 .
do
let hi u32 be widen u32 (index data 0) .
let lo u32 be widen u32 (index data 1) .
return add (mul hi 256) lo .
end
Output
$ lowentc --run read_pair pair.low [1,2,3]
read_pair([1,2,3]) = 258
arg0 (written) = [1,2,3]
Read aloud, the head says: “this op must be given a byte sequence of length at least 2 (requires), promises that the value it returns is at most 65535 (ensures), and is pure (fn).” You know that much without opening the body. In ensures, ret stands for the returned value.
A contract is used in three places. The processor uses it as a fact to remove checks. A contract the processor could not prove is checked at run time and stops when broken. And it tells the reader what must be kept to call this op.
14.2 Whose fault is it?#
There are two places a contract breaks, and the diagnostic assigns blame.
| Clause | When checked | Whose fault when broken |
|---|---|---|
requires | on entry | the caller — did not keep the condition |
ensures | on exit | this op — broke its own promise |
errors … <condition> | on exit | this op — the condition held but it did not produce the error |
Table 14.1 — When a contract breaks
Draw a contract as an op’s two doors and the blame is decided by which door caught it.
caller op
┌─────────────────────────────────────┐
percent_of 250 200 ───▶│ way in: requires │ caught here → the caller's fault
│ │
│ … body … │
│ │
result ◀─────│ way out: ensures · errors … cond │ caught here → this op's fault
└─────────────────────────────────────┘examples/ch14/blame.low
module blame .
rem run: percent_of 50 200
rem trap: percent_of 250 200
rem run: clamp_to_100 400
rem trap: clamp_to_100 120
fn percent_of input part u64 . input whole u64 . output u64 .
requires le part whole .
requires gt whole 0 .
requires le whole 1000000 .
ensures le ret 100 .
do
return div (mul part 100) whole .
end
rem clamps only values above 150 and forgets 101 … 150
fn clamp_to_100 input n u64 . output u64 .
requires le n 1000 .
ensures le ret 100 .
do
if gt n 150 . do
return 100 .
end
return n .
end
Output
$ lowentc --run percent_of blame.low 50 200
percent_of(50, 200) = 25
$ lowentc --run clamp_to_100 blame.low 400
clamp_to_100(400) = 100
$ lowentc --run percent_of blame.low 250 200
== ir diagnostics (1) ==
0:0 E-VM-CONTRACT: `requires` violated at entry — the caller broke the contract
$ lowentc --run clamp_to_100 blame.low 120
== ir diagnostics (1) ==
0:0 E-VM-CONTRACT: `ensures` violated at exit — THIS op broke its own promise (the caller was told a lie)
percent_of 250 200 has a part larger than the whole, so it is the caller’s fault, and the VM says “the caller broke the contract”. clamp_to_100 120 is the fault of an op that forgot to clamp values from 101 to 150, and the VM says “THIS op broke its own promise”. When a program stops, the diagnostic answers at once “did I call it wrong, or is that op built wrong?”.
Q. ensures is the op checking itself — how does that differ from a test?
A. A test checks that the answer is right for a few inputs. ensures checks that the promise holds on every call. Even if the promise breaks outside the inputs a test chose, ensures stops on that call. And callers may use ensures as a fact: a caller of clamp_to_100 may trust that the result is at most 100 and have the check in narrow u8 removed. It is also why the development repository’s tooling can test ops from their contracts alone, without expected outputs (chapter 31).
14.3 Contracts remove checks#
The processor uses contracts as facts to narrow the ranges values can take. When those ranges show safety, it removes overflow, division by zero, narrowing and slice bounds checks. So in this language writing honestly makes code faster.
fn bare input a u8 . output u8 .
do
return add a 1 . rem the overflow check remains
end
fn proven input a u8 . output u8 .
requires le a 200 .
do
return add a 1 . rem no check --- it cannot exceed 201
endThe two ops have the same body. The only difference is one line of contract, and that line removes a run-time check. What the processor thinks is this short:
requires le a 200 . → a is 0 … 200
add a 1 → the result is 1 … 201
does it fit u8? → 201 ≤ 255 ⇒ cannot overflow ⇒ drop the overflow checkThe check did not vanish but was moved to one check on entry. If the caller calls with a constant, or proves the range with its own contract, even the entry check disappears.
There is one important rule. A contract that is not enforced is not used as a fact. Trusting without checking and removing checks is not being faster but being wrong. This rule returns below with grades and build modes.
14.4 Conditions over every element#
A contract’s condition is a pure expression. Loops are statements, so “every element is at most 9” cannot be written as a loop. There are words that say it instead.
examples/ch14/elems.low
module elems .
rem run: digit_sum [1,2,3,9]
rem trap: digit_sum [1,20]
fn digit_sum input ds slice u8 . output u64 .
requires elem_le ds 9 .
do
var s u64 be 0 .
for d ds do
set s (add s (widen u64 d)) .
end
return s .
end
Output
$ lowentc --run digit_sum elems.low [1,2,3,9]
digit_sum([1,2,3,9]) = 15
arg0 (written) = [1,2,3,9]
$ lowentc --run digit_sum elems.low [1,20]
== ir diagnostics (1) ==
0:0 E-VM-CONTRACT: `requires` violated at entry — the caller broke the contract
elem_le ds 9 means every element is at most 9. There are also elem_lt, elem_gt and elem_ge. The condition is checked once on entry, and the arithmetic in the body uses the fact that the elements are at most 9.
14.5 Naming a contract#
When several ops require the same condition, give the condition a name.
examples/ch14/named.low
module named .
rem run: half 10
rem run: third 10
contract positive do
requires ge a 1 .
end
fn half satisfies positive . input a u8 . output u8 .
do
return div a 2 .
end
fn third satisfies positive . input a u8 . output u8 .
do
return div a 3 .
end
Output
$ lowentc --run half named.low 10
half(10) = 5
$ lowentc --run third named.low 10
third(10) = 3
contract positive do … end is a named contract, and an op adopts it with satisfies positive . at the very front of its head. Repeating the same condition by hand in several places means fixing one and forgetting another; a name leaves one place to fix. satisfies comes first in the head because it says what the op is first (chapter 3).
14.6 Decide now what can be decided now#
When a call breaks the callee’s requires and both sides are constants, translation rejects it without waiting to run.
examples/ch14/impossible.low
module impossible .
rem expect: E-CONTRACT-IMPOSSIBLE
fn small input a u8 . output u8 .
requires lt a 10 .
do
return a .
end
fn caller output u8 .
do
return small 200 .
end
Output
$ lowentc --check impossible.low
impossible.low:12:0 E-CONTRACT-IMPOSSIBLE: this call breaks the callee's `requires`, and BOTH SIDES ARE CONSTANTS — so it can be decided here, now. It used to compile green and trap at run time (E-VM-CONTRACT): a bit budget that does not fit (`slot + shard + generation > word`) shipped as a runnable program. A contract that can be decided at compile time IS decided at compile time (RFC-0104 §8-3)
As the diagnostic’s story says, this once translated green and only stopped at run time. A program carrying a bit budget that did not fit shipped in runnable form. A contract whose answer is available now is decided now.
The mistake in the other direction is rejected too. Declaring an error for a condition that requires already excludes means that error can never happen.
examples/ch14/dead.low
module dead .
rem expect: E-CONTRACT-DEAD
enum div_error do
by_zero .
end
fn safe_div input a u32 . input b u32 . output result u32 div_error .
requires ne b 0 .
errors by_zero eq b 0 .
do
guard ne b 0 . else return error by_zero .
return ok (div a b) .
end
Output
$ lowentc --check dead.low
dead.low:10:0 E-CONTRACT-DEAD: this declared error can never occur: `requires` already excludes the condition, and the condition's inputs cannot change during the op (remove the `requires`, or remove the error — not both)
With requires ne b 0 ., errors by_zero eq b 0 . can never be true. If the declaration stays, callers write code to handle by_zero, and that code never runs. Code that never runs is never tested, and untested code is wrong someday. The diagnostic says to remove one of the two — either make it the caller’s responsibility (requires) or have the op handle it itself (errors).
A common misconception. The more contracts you write, the safer
requires and an errors fighting over the same condition blur who is responsible, which is why it is rejected. Good contracts are short and put responsibility in one place.14.7 Contract grades#
A grade on a single contract clause decides when and how its condition is treated. The shape is requires <grade> <condition> ..
| Grade | When looked at | Meaning |
|---|---|---|
| (none) | whenever possible | Prove it if possible; otherwise leave it as the build mode says |
static | at translation | The processor must prove it; if it cannot, translation fails |
debug | at run time | Checked only while the build mode keeps checks |
assume | never | Only written down. Not used as a fact |
Table 14.2 — Contract grades
examples/ch14/grades.low
module grades .
rem run: bump_checked 10
rem trap: bump_checked 250
rem run: bump_assumed 10
rem trap: bump_assumed 255
fn bump_checked input a u8 . output u8 .
requires le a 200 .
do
return add a 1 .
end
fn bump_assumed input a u8 . output u8 .
requires assume le a 200 .
do
return add a 1 .
end
Output
$ lowentc --run bump_checked grades.low 10
bump_checked(10) = 11
$ lowentc --run bump_assumed grades.low 10
bump_assumed(10) = 11
$ lowentc --run bump_checked grades.low 250
== ir diagnostics (1) ==
0:0 E-VM-CONTRACT: `requires` violated at entry — the caller broke the contract
$ lowentc --run bump_assumed grades.low 255
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)
The same condition is written two ways. bump_checked rejects 250 on entry and then removes the overflow check in add a 1. bump_assumed does not check on entry. In exchange it does not use the condition as a fact either, so the overflow check remains. That is why giving it 255 stops with overflow (E-VM-OVERFLOW), not with a contract violation.
assume is a place to write down what the processor cannot yet prove but a person knows. Written down, readers know it, and when the processor grows it can be raised to static. Treating an unenforced condition as a fact would mean removing checks that should not have been removed, and believing that was right when the condition turns out false. So assume is not a fact.
14.8 Build modes decide the remaining checks#
Proven contracts leave no check in any mode. What a build mode decides is the treatment of contracts that could not be proven. The mode is written in the source as build <mode> ..
| Mode | Remaining contract checks |
|---|---|
debug | Kept; stop and say what broke |
test | Kept like debug; tests are also built and run |
release_safe | Kept; stop without a message |
release_fast | Removed. Execution may continue with a broken contract |
Table 14.3 — The four build modes
examples/ch14/fast.low
module fast .
rem run: bump 10
rem run: bump 250
build release_fast .
fn bump input a u8 . output u8 .
requires le a 200 .
do
return add a 1 .
end
Output
$ lowentc --run bump fast.low 10
bump(10) = 11
$ lowentc --run bump fast.low 250
bump(250) = 251
Under build release_fast ., bump 250 does not stop and gives 251. The same source stops under debug. release_fast opens one more trust boundary, where a person promises the contracts are true. It is one reason the claim that this language has no undefined behaviour is limited to “the safe subset”. Removing checks for speed is a choice you may make, but the fact of choosing must stay in the source. That is why the mode is a build statement and not a command-line flag.
In practice. Entry checks the processor cannot build
requires of shapes like name comparison constant, len, elem_* and field paths, and reports W-CONTRACT-IGNORED when it meets an expression it cannot build. While writing this book it turned out that an ensures containing an expression (ensures le (mul ret 2) n .) was not checked, without any warning. An unenforced contract is not used as a fact either, so no wrong optimisation results, but the fact that a promise goes unchecked should be reported. That is a place where the processor ought to speak, so it is a defect of this edition.14.9 Common mistakes#
Counter-example. Calling the returned value result in ensures
examples/ch14/mistake_result.low
module mistake_result .
rem expect: E-ENS-UNDEF
fn clamp input a u8 . output u8 .
rem ✘ the returned value is named `ret`, not `result`
ensures le result 100 .
do
guard le a 100 . else return 100 .
return a .
end
Output
$ lowentc --check mistake_result.low
mistake_result.low:6:0 E-ENS-UNDEF: ensures references an undefined name — an ensures that names nothing checks nothing, and the interval analysis DERIVES the result range from it (`ret` is the result)
The returned value is named ret. result is already a type name (result u8 e), so it is not reused as a value name. An ensures that names something undefined checks nothing, yet the analysis might derive a result range from it, so this is an error (E-ENS-UNDEF), not a warning.
Counter-example. Writing an index precondition with le
examples/ch14/mistake_leindex.low
module mistake_leindex .
rem run: at [1,2,3] 2
rem trap: at [1,2,3] 3
fn at input xs slice u8 . input i u64 . output u8 .
rem ✘ `le` allows `i = len xs` --- that slot is one past the end
requires le i (len xs) .
do
return index xs i .
end
Output
$ lowentc --run at mistake_leindex.low [1,2,3] 2
at([1,2,3], 2) = 3
arg0 (written) = [1,2,3]
$ lowentc --run at mistake_leindex.low [1,2,3] 3
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)
The slots of a slice of length 3 are 0, 1 and 2. requires le i (len xs) also allows i = 3, so the contract passes and the following index stops with E-VM-BOUNDS trying to read one slot past the end. When a contract is wrong, the stop moves from the contract into the body, and the diagnostic says “out of bounds” instead of “the caller’s fault”. Use lt for indexes; le is right for counts (“read n items”).
Counter-example. Writing preconditions that cannot hold together
examples/ch14/mistake_contradict.low
module mistake_contradict .
rem expect: E-CONTRACT-UNSAT
rem ✘ no value satisfies both conditions --- every call stops
fn pick input a u8 . output u8 .
requires le a 100 .
requires ge a 200 .
do
return a .
end
Output
$ lowentc --check mistake_contradict.low
mistake_contradict.low:7:0 E-CONTRACT-UNSAT: two preconditions on the same input cannot both hold, so EVERY call stops at the door and the body never runs. A contract that no argument satisfies is not a strong contract, it is a dead op — usually one line left behind when the other was edited. Keep the one you meant
No u8 is both at most 100 and at least 200, so this op stops at entry however it is called. Usually one le or ge was written backwards, or an old line was not removed during an edit. It sits beside an error declaration that can never happen (E-CONTRACT-DEAD) and is rejected with E-CONTRACT-UNSAT: a contract no argument satisfies is not a strong contract but a dead op.
A common misconception. requires is a tool for validating user input
examples/ch14/contract_input.low
module contract_input .
rem run: check_age 30
rem trap: age_strict 200
rem run: age_checked 200
enum age_error do
too_old .
end
rem contract: accept only values the caller has already checked --- breaking it stops the program
fn age_strict input a u8 . output u8 .
requires le a 150 .
do
return a .
end
rem values from outside may be wrong --- report it as a value
fn age_checked input a u8 . output result u8 age_error .
errors too_old gt a 150 .
do
guard le a 150 . else return error too_old .
return ok a .
end
fn check_age input a u8 . output u8 .
do
match age_checked a do
case ok v . return age_strict v .
case error e . return 0 .
end
end
Output
$ lowentc --run check_age contract_input.low 30
check_age(30) = 30
$ lowentc --run age_checked contract_input.low 200
age_checked(200) = err too_old
$ lowentc --run age_strict contract_input.low 200
== ir diagnostics (1) ==
0:0 E-VM-CONTRACT: `requires` violated at entry — the caller broke the contract
When a contract breaks, the program stops. A contract promises “the caller has already checked”, so a broken one means there is a bug in the code. A user typing an age of 200 is not a bug; it happens all the time. Check values from outside as age_checked does and report the problem as a result; pass only checked values to an op with a contract such as age_strict. Because the call comes after the check, the analysis removes the contract check inside check_age.
14.10 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
requires le a 200 . | condition on entry — the caller’s responsibility | gathers checks into one at entry and removes checks in the body |
ensures le ret 100 . | promise on exit — this op’s responsibility | ret is the returned value — callers use it as a fact |
errors too_big gt a 200 . | promise to return this error under this condition | a contract on the way out — errors are promised too |
requires elem_le ds 9 . | a condition on every element | contracts are expressions, so no loops |
contract positive do … end | give a contract a name | one place to change a shared condition |
fn half satisfies positive . … | adopt a named contract (first in the head) | say what the op is first |
requires static … · debug · assume | contract grades | decide per clause when and how it is checked — assume is not a fact |
build release_fast . | remove unproven contract checks | the choice of speed stays in the source |
Table 14.4 — Contract syntax — shape · meaning · why it looks this way
Recap
requires is the caller’s responsibility, ensures and errors the op’s, and the diagnostic says whose fault it was. Enforced contracts become facts and remove overflow, bounds and division-by-zero checks. elem_* expresses conditions over every element, and contract with satisfies names contracts. Violations between constants are rejected at translation, and so are declarations of impossible errors. assume is not a fact; build modes decide the treatment of unproven contracts, and release_fast removes those checks.