--optimization=remove-bounds-checks and remove-overflow-checksStatus: Accepted
Date: October 3, 2026 (D12-D16: October 4, 2026, #383, #390; D17: October 19, 2026, #384; D18: October 20, 2026; D20: October 22, 2026, #781)
History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached
Answers: ADR-269 §6’s open question for two of the checks
nobody writes - whether a proved check is not emitted - for an index out of
bounds and for an arithmetic overflow; and the owner’s question whether the
compiler should know bounds on the values a list holds, who finds them,
and what they cost
Related:
ADR-269 D4 (a claim the compiler proves has no check emitted, and means the same either way), D9-D10 (what the prover reads),
ADR-270 D1 (Fourier-Motzkin in the logic layer) and D5 (a check is dropped only on a certificate its checker accepts),
ADR-285 D1 (an index is an i64) and D5 (an overflow stops the program at every build),
ADR-293 D2 (an assignment’s value is computed before the place it writes),
ADR-218 D1 (every unsafe topic is a small crate of its own),
ADR-258 (the four weights),
Part III A.2 (an index out of bounds, an overflow and a conversion that does not fit stop the program at every build) and 13.3 ([build])
xs[i] stops the program where i is outside xs, and a + b stops it
where the sum leaves its type (Part III A.2). Each check is a compare and a
branch, nothing alone; in a loop it is what keeps the code generator from
unrolling and vectorising. In the big-integer kernel of
benches/solver-kernels.nika the index checks in out[i + j] are 34 % of
the instructions.
The checks are not optional: reading past a list’s end or overflowing is a
defect, and the stop is how it is seen. But a check that cannot fail is a
cost, not a check. ADR-269 D4 drops a written assert the prover proves; the
same question stands for the checks nobody wrote.
Many of the costly checks are on a value read out of a list:
let start = list[k] // list is one of watched
... value[arena[start + at]] ... // start + at: u32, checked; the index too
The walk knows nothing of start, yet the program says what is in those
lists: watched only ever receives c * size with c < clauses, arena
only … % (2 * variables). Without a bound on the values a list holds, an
overflow option buys nothing - the overflow checks a walk can prove from
ordinary facts (2 * v + 1 with v < 2000) are the ones LLVM already drops.
May the compiler drop an index or an overflow check, when, who decides that it tries, and may it know what a list holds?
| Language / tool | Its answer | Who writes the bound | Source |
|---|---|---|---|
| Rust | always checked; LLVM removes what it proves from the code it sees; get_unchecked in unsafe for the rest |
- | the language, from memory |
| Swift | checked; -Ounchecked removes the checks without proof, so a wrong index is undefined behaviour |
- | from memory, not checked |
| HotSpot C1, .NET RyuJIT | range-check elimination from induction variables, conditions, lengths and a type’s own range; never from what an array holds | - | c1_RangeCheckElimination.cpp, RyuJIT rangecheck.cpp |
| Ada / SPARK | range subtypes, also as component types; checked at each store, or GNATprove proves the absence of run-time errors | the programmer | GNATprove output |
| Wuffs | refinement types, also as element types (array[4096] base.u16[..= 4095]); every index and overflow proved at compile time, by interval arithmetic and earlier ifs; what is not proved is refused |
the programmer, in the type | bounds-checking note |
| Pascal | subrange types; with {$R+} stores are checked at run time |
the programmer | range and overflow checks |
| Liquid Haskell, Dafny | refinements in type arguments ([{v:Int \| v < n}]), quantified invariants or subset types; an index is a proof obligation |
the programmer, partly inferred | ICFP’14 paper, Dafny types |
| Astrée | array smashing: an array is one abstract cell, an interval every write joins | inferred | snippet only, the paper was not reachable |
| Clousot (FunArray) | an array split into segments with symbolic bounds, one interval each | inferred | Cousot, Cousot, Logozzo, POPL 2011 |
LLVM !range |
metadata on a load; rustc sets it for bool, char, enum tags, NonZero |
the frontend, as an assumption | rustc’s builder.rs |
| Rust pattern types, Zig ranged integers | exist only inside the compiler (Rust) or as an open proposal (Zig) | would be the programmer | rust#123646, zig#3806 |
What transfers. SPARK’s and Dafny’s order - prove, then drop - and the JIT’s range-check elimination for the loop shape that is most of the cost. For what a list holds, two families: the programmer writes a bound in the element type (Wuffs, Ada, Liquid Haskell, Dafny), or the tool infers one interval per array from every write (Astrée, Clousot). Both put the work at the write, which runs less often than the read.
What does not. Swift’s -Ounchecked and LLVM’s !range used as a guess:
a build option that turns a stop into undefined behaviour changes what a
program means, and Part I 1.2 lets no build option do that. Wuffs refuses
what it cannot prove; Nikaia checks it, so a proof not found costs speed,
never a refusal. Astrée’s and Clousot’s precision comes from a fixpoint over
loops; the walk here is a single forward pass that forgets at a loop’s head.
| What it is | D1 the special case pays | D2 the compiler works | D3 no cost for the common case | D4 consistent | If it is wrong | |
|---|---|---|---|---|---|---|
| A leave it to LLVM | every check emitted | yes | no: the author rewrites the loop until LLVM sees it | yes | yes | slow, never wrong |
| B an unchecked mode | -Ounchecked, or !range assumed |
yes | yes | yes | no: a flag changes meaning | memory read past the end, silently |
| C drop what is proved, always | ADR-269 D4’s rule as it stands for an assert |
no: every build proves | yes | no | yes | a wrong proof, everywhere, unasked |
| D drop what is proved, at a level the build asks for | --optimization=…:basic\|aggressive; values’ bounds inferred from every write |
yes: only a build that asks proves | yes: nothing is written | yes: off by default | yes: meaning never changes | a wrong proof, caught by the certificate’s checker before a check is dropped; a bound not found costs speed only |
| E the bound written in the element type | Vec[Vec[u32 in 0..<32000]] (illustration only), checked or proved at each write |
yes | no: the author writes and keeps ranges | yes | no: a range type for list elements alone, which the language has nowhere else | a new place for the program to stop |
D is chosen. B and C lose on the promise to the program: B changes what it means, C makes every build pay and lets a growing prover change every program’s emitted code unasked. A leaves the work to the developer. E reaches what D cannot - a bound across a call, a list behind a parameter - but is a new kind of type, and ADR-258 D2 says the compiler works first.
The number that decided it: on the solver’s watch kernel the index proof alone gives 10.44 M instructions against 10.69 M for Rust by hand, and 9.41 M with the bound on a list’s values.
--optimization=NAME:LEVEL, and its first name, remove-bounds-checks.--optimization=remove-bounds-checks:LEVEL on the command line, for every
command that lowers (lower, build, run, test), or
optimization = "remove-bounds-checks:LEVEL" in [build]. The levels are
off (the default), basic and aggressive. Words are comma-separated, and
the command line may give the flag more than once; a later word for a name
wins. A name or a level the compiler does not know stops the build before
anything is read, naming the words there are.
Unlike Part I 1.2’s options it changes nothing a program means (D2), so a
single build may override it, as it may --target. It is part of the build’s
cache key, and reaches the wrapper Cargo runs as the other options do.
std is lowered at off: the toolchain’s own lowering does not follow an
option of the program it serves (Part III 13.2).
An index check is dropped only where 0 <= i < xs.len() holds every time
the index is reached. The program means the same at every level: where the
proof holds, the check could not have stopped it; where it does not, the
check stays. A level says how hard the compiler tries, not what the program
may do, and a program that stops at an index at off stops at it at
aggressive. The same holds for every name of D1’s option.
basic: the loop over a list’s own length.xs[k] where k is the binding of an enclosing for k in lo..<xs.len(),
lo is a literal that is not negative or the binding of such a loop, and
nothing in the loop’s body can change xs’s length or k: xs is not
assigned or bound again, not handed to a call, not captured by a lambda, and
every method called on it is one a fixed list knows to keep the length
(len, contains, iter, sort, swap, …). An index inside a lambda in
the loop is not proved: the lambda may run after the loop. No solver is
asked; the pass is linear in the body.
aggressive: the solver, from the facts a forward walk keeps.Each function body is walked forward, keeping the linear facts that hold at
each point, over whole numbers and the lengths of std’s lists, text and
maps:
let and an assignment (n = e; n += d and n = n + d rewrite every
fact about n to the new value), a range’s bounds for its binding, a
parameter’s unsigned type, a counter that starts at a literal and only
grows (never negative);if in its branches, its denial after a branch that
leaves; a while’s condition in its body and its denial after it, where
the body has no break; the left side of && and || where the right
side runs;resize(n, …),
push, insert, remove, clear. A method not known to keep the length
forgets it; a name handed to a call that may change it - a mut
parameter, or a callee this program does not declare - forgets it.Polarity decides what a condition says. A condition is read as a formula
it implies: of a && b, what is known of either half; of a || b, something
only where both halves are known. Its denial is read the other way round:
a || b false says what is known of either half false, and a && b false
says something only where both halves can be denied. Reading a && b as a
under a negation would prove what does not hold.
A conversion keeps its value or stops the program (Part III A.2), and so does
arithmetic: a fact about x as i64, or about 2 * v + 1, is a fact about the
number, wherever the program goes on.
The walk follows the order the lowering runs things in. An assignment’s
value is walked before the index it writes at, because the value is computed
first (ADR-293 D2): in xs[2] = xs.pop() ?? 0 the list is shorter by the
time the write happens. A call’s receiver comes before its arguments, and its
effect after both.
A match arm starts with nothing known. Its pattern may bind a name that
hides an outer one.
At a loop’s head, every fact about a name the body changes is forgotten, and
what holds of it everywhere - never negative - is said again. A name a
lambda, a task, an overlap branch or a select arm changes has no facts at
all. A point keeps its 48 newest facts, so a long body costs the solver what
a short one does.
The goal 0 <= i && i < xs.len() is asked of Fourier-Motzkin (ADR-270 D1); a
check is dropped only where the answer is proved and its certificate passes
the checker (ADR-270 D5). x.len() is a number of the proof only where the
checker typed x as a std list, text, map or set: a len() a program
writes for its own type may say anything.
proven::read and proven::write.The checker records which indexes are a position into a Vec or an Array
(not a range, not a map’s key); the emitter writes a proved one of those as
*unsafe { nikaia_std::proven::read(&xs, (i) as usize) }
unsafe { nikaia_std::proven::write(&mut xs, (i) as usize, value) }
proven is crates/unsafe/proven-index (ADR-218 D1): three functions over
get_unchecked, each with a debug assertion, its README listing every
unsafe and its argument, checked by scripts/check-unsafe-crates.sh like
the others. nikaia-std re-exports it and stays #![forbid(unsafe_code)].
The unsafe the emitted code writes stands on the proof, which is the
argument; the crate holds the one operation the proof licenses.
remove-overflow-checks:aggressive, the second name of D1’s option.--optimization=remove-overflow-checks:aggressive, or the same word in
[build] optimization, with the levels off (the default) and aggressive.
There is no basic: the one shape a walk without a solver would prove is the
one LLVM already proves. The name is independent of remove-bounds-checks
and part of the cache key like it. D2 holds: an overflow check that is not
proved stays.
For a list bound in the body being walked (a let, not a parameter), the walk
keeps a lower and an upper bound that hold of every value it holds,
linear terms over names the body never changes. The bound is the join of
what each write puts in: a literal’s items, push, insert, resize’s fill
value, xs[i] = v, and, for a list of lists, the same writes into xs[i].
Each write’s value is proved within the bound at that write, from the
facts that hold there.
The list has no bound if it is handed to a call that may change it (a
mut parameter, or a callee this program does not declare), captured by a
lambda, a task, an overlap branch or a select arm, or written by any
method not on a closed list. A value read from a list with a bound is a
number of the proof with that bound, wherever D4 reads a number: in an index,
a comparison, an arithmetic check. A binding of such a read (let start =
list[k]) keeps the bound, and so does a binding of an inner list (let list
= watched[lit]) for its values.
The bound is found flow-insensitively, before the walk, from the writes of the whole body - Astrée’s smashing, with no fixpoint. The constants a write’s value lies between come from interval propagation over the linear facts that hold there; each end is then confirmed by the solver on a certificate the checker accepts, and an end it does not confirm is open. Two passes find the bounds, each reading the last one’s, and a third keeps only those every write is proved to stay within while assuming all of them - an inductive invariant. A bound that holds only by assuming itself larger is dropped.
x % n is a bound.Where x is not negative and n is a positive term, 0 <= x % n < n; for
an unsigned x, always.
for _ in lo..<hi { … xs.push(v) … }, with exactly one push on every path
through the body and no other change to xs, leaves xs.len() larger by
hi - lo where lo <= hi.
xs[i] += d on a list of numbers (or of bools) at a position D4 or D7
proves is lowered as the value, then the position, then one proven::read,
the operation and one proven::write - the element read once, as += reads
it. A slot that is not a copy, such as text, keeps its checked index, because
reading it out would move it. x += d on a name of one whole-number type is
recorded by the statement’s first byte and proved as x + d with what is
known of x before the statement; a proved one is x = <T>::wrapping_add(x,
d) (D10) (#387).
<T>::wrapping_*.Its lowering is replaced by D19.
The checker records each +, - and * whose two sides are of one
whole-number type, by the operator’s span, with that type. A proved one
lowers to <T>::wrapping_add(a, b) (_sub, _mul). With no overflow, that
is the same number with no check, and no unsafe is needed. Naming T ties
the proof’s type to the emitted one: if they ever disagreed, rustc would
refuse the program instead of computing something else.
Every program the tests run prints the same at every level of every name, and
a program that stops at an index or an overflow at off stops at the same
one at aggressive, with the language’s message - never with
proven-index’s debug assertion. The tests that hold a level to this are
compiled without -O, so that a wrong proof stops there instead of reading
past the end. A level is kept only where, on the solver’s kernels, it is at
or below off with LTO.
Added October 4, 2026 (#383).
let mut row = Vec[i64]()
for i in 0..<line.len() + 1 { row.push(i) } // line.len() + 1 is proved
len() of a list, text, Bytes, map or set whose elements take at least one
byte is a fact of the walk: below 2^60, the same on every target. It is
read where the walk reads len(), as a callee’s promise is read (ADR-314
D3), and a tighter bound the program gives replaces it.
Why memory and not the language below. Rust promises an allocation of at
most isize::MAX bytes, 2^63 - 1, the edge of an i64: that proves nothing
about len() + 1. The machine promises more: no target this compiler builds
for addresses more than 2^57 bytes (x86-64 with five-level paging; aarch64
2^52; wasm32 2^32). 2^60 leaves a factor of eight, and holds x.len() + 1,
a.len() + b.len() and c.len() - w.len() - 1 inside an i64.
Why one number for every target. What a program means does not depend on
the target (ADR-037 D1), and which checks it carries should not either: a
bound per target would make wasm32 prove products that x86_64 checks. A
target that addresses more than 2^60 bytes reopens this.
What it buys. With the bound, a product of
two lengths still reaches 2^120 and stays checked; without it, nothing past a
literal offset is proved, and those are about a quarter of the overflow
checks the corpus keeps (docs/solver-workload.md §8.4).
enum One { Only } takes no space, and neither does a tuple or a struct of
such parts or an Array[T, 0]. A Vec[One] holds as many as resize(n, …)
asks for in one step, without a byte of memory, so D12 is not true of it and
is not read. Whether a type takes space is a structural walk, as whether it
compares is (ADR-204 D1). Its length is what the walk already reads off how it
was built (D4, D9): [] is 0, resize(n, …) is n, a push per counted turn
adds the count, extend adds the other’s length; anything else leaves it
unknown, and the check stays. A run-time limit on such a list was the other
way: it would cost a check at every resize and extend of every list, to
cover a list that takes no memory.
As built (#383, #444): the walk reads D12 as 0 <= len(x) <= 2^60 for
every len() whose container’s elements take space, and D13’s structural walk
answers *no for a type variable, a function, or a type nothing here declares.
A proof states its goal inside ±2^62 where the type is wider: the solver
counts in i64 and refuses on overflow, so term <= i64::MAX (negated, 2^63)
was never decided, and no i64 or u64 operation had been proved. Over the
repository’s .nika files, the checks left standing at aggressive went from
551 to 442 (docs/solver-workload.md §8.5).*
Added October 4, 2026 (#390). It replaces D1’s levels, D3’s and D6’s names for them, and §6’s question of the default.
proving says how hard the compiler proves: a level that maps to a step
budget for the solver (ADR-270), counting only its expensive steps - a case,
an elimination - which costs less than reading a clock and answers the same
everywhere. It reaches both clients: the claims a program writes (ADR-269)
and the checks it does not write, index and overflow. remove-bounds-checks
and remove-overflow-checks say only what happens to a check that was
proved: on writes it without its check, off (the default) keeps it.
Why two. One switch meant both search harder and drop what was found. A program that wanted more of its defects found at build time had to take the dropped checks with it, and a program that wanted neither had no budget to set. The two buy different things: more proving buys safety, because more defects are found before the program runs (D15); dropping checks buys speed, at a risk proving alone does not carry.
Why dropping stays off by default. A proof is checked by its certificate
(ADR-270 D5), but the certificate covers the solver’s arithmetic, not whether
the query the walk built says what the program does. A wrong walk is a defect
of the compiler, as a wrong lowering is - and where it drops a check, it turns
a defect of the program that would have stopped cleanly into a read past a
list’s end (get_unchecked, D5, with its debug assertion off in the profile a
program is built with) or a wrong number (wrapping_*, D10). A wrong
refutation is a visible error with a bypass; a wrong drop is invisible.
Why changing one does not redo the other. The proofs depend on proving
and the sources; remove-*-checks only chooses what is written. A build that
switches dropping on or off proves nothing again and caches nothing twice.
As built: remove-bounds-checks and remove-overflow-checks take on and
off, and the walk runs at every build, D3’s shape and D4’s solver together,
whatever they say. basic is no longer a word of the option: its one shape is
proved at every build, and whether a level without the solver is worth
offering is proving’s question (§6). The old words are refused with the word
they now are.
let third = xs[5] over a list the walk knows holds three is refused, with
the value that fails, as a claim a program writes is (ADR-269 D8, NK1207);
refuted-claims = "warn" builds it with the check. Why an error. The
check would stop the program where the line runs; it would not rescue it. A
program the compiler has shown wrong does not build, whichever kind of check
showed it. Only on a refutation, never on unknown, and on ADR-269 D8’s
terms: a model checked by evaluation, for values the program reaches. The walk
runs at every build for this, whatever remove-*-checks says.
nikaia.proofs.Every answer a build uses is recorded there with its evidence (ADR-270 D19-D23), the walk’s as much as a claim’s: a build is a function of the sources and the committed file, whatever the solver’s search does.
A path of fields from a name, t.slots, p.end, self.at, is read as a name
of its own: a number at the path is one, and t.slots.len() is a length (D12)
as a list binding’s is. A fact about a path holds from where it is learned
until something may change the path:
let, a let mut, a parameter the body keeps,
self in a method that changes it - until the next write to the path or to a
path it is part of, a call handed the path’s root or a part of it to change, a
method on it that changes its receiver, or a lambda, a task, an overlap
branch or a select arm that sees the root. Where the walk cannot tell, the
fact is dropped, as D7 drops a list’s bound.A precondition (ADR-269 D5) may read a path from a parameter, and a caller proves it as it proves any other.
Scanners keep a cursor and a buffer in one value (self.at + 1 <
self.c.len()), tables keep their slots in a field, and work is cut into
pieces that carry their bounds: the field is where such a program keeps the
numbers a check is about, and a walk that forgets them at the dot proves none
of it. Copying each field into a let first is the same proof with a line per
field, asked of every program.
if dot > 28 { throw BadRow {} }
let digits = (w & keep) << (28 - dot) // 0 <= 28 - dot < 64: no check
A shift whose count is not a literal, x << k and x >> k, asks
0 <= k < w, w the width of x’s type, as an index asks 0 <= i < len.
Where the walk proves it, remove-overflow-checks:on writes the shift without
its check; where it does not, the shift is checked as Part I says, and a
count it shows outside is an error as D15 says.
Why. A shift by more places than its type is wide stops the program
(Part I), so the count is checked at every such shift, and the checks a
program writes before it - a guard on a value it read - prove it as they
prove an index. Without the goal, the only way to lose the check is a
wrapping_shl, which turns a wrong count into a wrong value without a word,
against what the check is for; an assert does not help, because nothing
reads it as the shift’s goal. Rejected: masking the count as Rust’s release
build and Java do, which gives a value where the program means a stop; a
switch of its own, for a goal of the same kind as +, - and *’s.
proven::add, sub and mul.Replaces D10’s lowering, October 22, 2026.
A proved +, - or * lowers to unsafe { nikaia_std::proven::add::<T>(a,
b) } (sub, mul), from proven-index, as a proved index lowers to
proven::read: <T>::unchecked_add behind a debug_assert of
checked_add. T stays named, so D10’s tie between the proof’s type and the
emitted one holds. The operation tells the optimiser that it does not
overflow, which wrapping_add does not: in 1BRC’s fast.nika that saves one
instruction a row at x86-64-v3, where wrapping_add lost what the check it
replaced had told the optimiser. A wrong proof stops at the assertion in every
build that keeps debug assertions, D11’s tests among them, and is undefined
behaviour in one that does not, as a wrong proved index already is and a
proved shift already is (D18).
Rejected: keeping wrapping_* (D10). It turns a wrong proof into a wrong
number rather than undefined behaviour, but the index and the shift beside it
do not, so the operation was the one place that paid for a guarantee the
program as a whole does not have. <T>::unchecked_add written into the
program directly: the same code, but an unsafe outside the listed crates
(ADR-218) and no assertion in a debug build.
October 22, 2026, #781.
t.slots[at].sum, a path of fields through one element of a list that is
itself a path (D17), indexed by a name, is a name of the walk as t.used is:
unsafe { assume(t.slots[at].sum <= B) } bounds it, and t.slots[at].sum += v
is proved from that. What is known of it holds until
at may change;t.slots: a write
through t.slots[j] to the same field, to a part of it or to a whole it is
part of, j any index, the same name or another, since the walk does not
tell indexes apart; a write to the whole element; a method that may change
an element;t.slots itself.A write to another field of the same element keeps it: t.slots[at].min = v
cannot change t.slots[at].sum. An index that is not a name makes no path;
a write through it drops what is known of every element.
An aggregation loop keeps its sums in the elements of a table, and the bound that keeps them from overflowing is about the element the row lands in: a walk that knows fields only from a name proves none of the four writes a row makes.
unsafe at a second place besides a call into
foreign code, and only where a proof stands behind it.assert (ADR-269), with
queries of its own shape: more facts, mutable counters, lengths that move,
and bounds on a list’s values beside its length.--asserts
does not exist for these checks.rv[i] beside rc[i]): a
relation, not a bound.xi * y[j]): interval arithmetic beside
Fourier-Motzkin, bounding it from the operands’ types.proving’s levels: their names, their budgets and the default, from
the compile time over the whole corpus with std and the sites proved and
refuted at each (D14). Whether D3’s walk without a solver remains a level.