Wado

WEP: Contract Checks

Context

assert is never removed. That is deliberate: an invariant an assert states must hold in the build that ships, where the code runs for real. C++26's contracts are criticized for the opposite choice, an "ignore" semantic that removes checks in production (Research: Assertions and Contracts).

The _unchecked functions are the other side of that choice. They exist for speed, and tuning benchmark/ showed they pay for themselves. Each states a contract in its doc comment, and a call outside it is a contract violation (Behavior Classes). Nothing checks the contract in any build, so such a call is easy to write and invisible in tests.

The specification already permits any build to detect a contract violation and trap. No build did.

Decision

A build may check the contracts of _unchecked functions. Shipping code keeps the speed it has today, and a build meant to find bugs traps on a violation. This only adds checks: no build loses one it had, so C++'s objection to the ignore semantic does not apply.

The flag

-f contract-checks turns the checks on, and -f no-contract-checks turns them off. The default depends on the world and the optimization level:

Build Default
the test world (wado test) on
any other world at -O0 on
any other world at -O1 and above off

The test world defaults on whatever the level, because finding bugs is what it is for. It already pays for that with the debug allocator.

builtin::contract_checks()

builtin::contract_checks() answers whether the build checks contracts. A function checks its contract with it, and this is the only shape it takes: the whole condition of an if statement with no else. A call anywhere else is a compile error.

if builtin::contract_checks() {
    assert 0 <= index < self.len(), "index out of bounds";
}

It is the mechanism, not a stopgap. A contract syntax, once one is designed, lowers to it.

Before NIR, the compiler keeps such an if where checks are on and deletes it outright where they are off, so a disabled check costs nothing at any optimization level. Folding the condition to false was not enough: the inliner sized the if false body before any pass pruned it, and Formatter::prepare_int_write stopped being inlined at -O2. For the same reason each site writes its check out rather than calling a shared helper: the call to a helper a disabled check left empty would remain.

Coverage does not count a check as a branch, because its condition is a build constant. Where the build keeps the check, its body is part of the enclosing region. Where the build deletes it, under -f no-contract-checks, it holds no line.

It is internal to core:builtin, so only the standard library calls it. Open to every program, it would be a removable assert. Every language that offers one reports it used for checks that must hold in production, and removed with them (assert under Python's -O, Rust's debug_assert! in RUSTSEC-2025-0137).

A skipped check is not an assumption

A disabled check is not evaluated, and the compiler does not assume its condition holds. Assuming it would turn a violation the specification calls unspecified, such as get_unchecked out of range, into unconstrained behavior. Zig's std.debug.assert and Swift's -Ounchecked do that, and their users report the damage as worse than removing the check.

A failed check is a failed assertion

A check is an assert, so it fails as one does: it reports the condition and its operands, then traps. -f bare-asserts drops the message as it does for any assertion.

What is checked

Each _unchecked function checks the clauses of its contract that cost constant time: an index in range, an end on a character boundary, a value that is a Unicode scalar, a byte below 0x80. A function that forwards to another _unchecked function relies on that one's check.

Sequence::get_unchecked on an Array checks nothing: the array access already traps out of range.

Roadmap

  1. -f contract-checks, builtin::contract_checks(), and the checks in core:prelude's _unchecked functions. Done. The first run of the test suites found one violation, in the standard library itself: StrSlice::replacen copied a non-matching byte at a time, so the tail view it appended could start inside a character.

Known gaps