Fix the semantics of the signed remainder: MIN % -1 overflows - #21
Open
karthikbhargavan wants to merge 1 commit into
Open
Fix the semantics of the signed remainder: MIN % -1 overflows#21karthikbhargavan wants to merge 1 commit into
MIN % -1 overflows#21karthikbhargavan wants to merge 1 commit into
Conversation
In Rust, `iN::MIN % -1` panics with "attempt to calculate the remainder with overflow" (like `iN::MIN / -1`), and `iN::MIN.checked_rem(-1)` returns `None`. The remainder models returned 0 instead: the result is representable, so the range checks which catch the division overflow do not trigger for the remainder. - Lean: make `IScalar.rem` fail with `integerOverflow` when `x = MIN && y = -1` (mirroring `IScalar.div`), add the corresponding `hNoOverflow` hypothesis to the `rem` spec theorems, extend the `checked_rem` specs with the overflow disjunct, and add regression tests. - F*, Coq: add the overflow check to `scalar_rem` (in Coq, also add the missing division-by-zero check: `Z.rem x 0 = x` so `x % 0` returned `Ok x`). - Interpreter: add the overflow check to the concrete evaluation of `Rem`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
abentkamp
reviewed
Jul 29, 2026
abentkamp
left a comment
There was a problem hiding this comment.
Looks good!
Only one small issue: I think we should try to separate Lean tests from the library, so that they don't slow down the regular build.
Comment on lines
+69
to
+84
| -- Checking that `MIN % -1` overflows (like `MIN / -1`) while `MIN % 1` succeeds | ||
| #assert (IScalar.rem (I8.ofInt (-2^7)) (I8.ofInt (-1)) == fail integerOverflow) | ||
| #assert (IScalar.rem (I16.ofInt (-2^15)) (I16.ofInt (-1)) == fail integerOverflow) | ||
| #assert (IScalar.rem (I32.ofInt (-2^31)) (I32.ofInt (-1)) == fail integerOverflow) | ||
| #assert (IScalar.rem (I64.ofInt (-2^63)) (I64.ofInt (-1)) == fail integerOverflow) | ||
| #assert (IScalar.rem (I128.ofInt (-2^127)) (I128.ofInt (-1)) == fail integerOverflow) | ||
| #assert (IScalar.rem (I8.ofInt (-2^7)) (I8.ofInt 1) == ok (I8.ofInt 0)) | ||
| #assert (IScalar.rem (I16.ofInt (-2^15)) (I16.ofInt 1) == ok (I16.ofInt 0)) | ||
| #assert (IScalar.rem (I32.ofInt (-2^31)) (I32.ofInt 1) == ok (I32.ofInt 0)) | ||
| #assert (IScalar.rem (I64.ofInt (-2^63)) (I64.ofInt 1) == ok (I64.ofInt 0)) | ||
| #assert (IScalar.rem (I128.ofInt (-2^127)) (I128.ofInt 1) == ok (I128.ofInt 0)) | ||
| #assert (IScalar.rem (I32.ofInt (-7)) (I32.ofInt (-1)) == ok (I32.ofInt 0)) | ||
| #assert (IScalar.rem (I32.ofInt 7) (I32.ofInt 3) == ok (I32.ofInt 1)) | ||
| #assert (IScalar.rem (I32.ofInt 7) (I32.ofInt 0) == fail divisionByZero) | ||
| #assert (UScalar.rem (U32.ofNat 7) (U32.ofNat 3) == ok (U32.ofNat 1)) | ||
| #assert (UScalar.rem (U32.ofNat 7) (U32.ofNat 0) == fail divisionByZero) |
There was a problem hiding this comment.
These tests should probably converted to Rust and go into tests/src
|
Oh, but the pull request should go into dev, not main! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The scalar remainder models (Lean
IScalar.rem, F*/Coqscalar_rem, and theinterpreter's concrete evaluation of
Rem) treatediN::MIN % -1as a successreturning
0, whereas the operation panics in Rust. The division modelsalready fail on
iN::MIN / -1(explicitly in Lean, via the range check inF*/Coq and the interpreter), so remainder was inconsistent with division. The
remainder result
0is representable, which is why the range checks thatcatch the division overflow never trigger for the remainder. This PR makes the
signed remainder fail on
x = MIN && y = -1, strengthens the associated spectheorems (
remandchecked_rem), and adds regression tests. Unsignedremainder and the division-by-zero behavior are unchanged.
Rust semantics
i32::MIN % -1panics ("attempt to calculate the remainder with overflow")in both debug and release builds; remainder overflow, like division
overflow, is not demoted to wrapping in release mode (RFC 560 / behavior
of
Remon integers). rustc even rejects the constant form statically:(With literal constants,
rustcfails at compile time:error: this arithmetic operation will overflow: attempt to compute the remainder of `i32::MIN % -1_i32`, which would overflow.)The
i32::checked_remdocumentation: "Checked integer remainder. Computesself % rhs, returningNoneifrhs == 0or the division results inoverflow." So
i32::MIN.checked_rem(-1) == None, while the Leanchecked_remspec theorems provednone ↔ y = 0.MIR encodes this:
Remwith panic-on-overflow semantics fails whenx = MIN && y = -1, exactly likeDiv.Changes
backends/lean/Aeneas/Std/Scalar/Ops/Rem.lean:IScalar.remnow fails withintegerOverflowwhenx.val = IScalar.min ty && y.val = -1(mirroringIScalar.div);IScalar.rem_bv_spec,IScalar.rem_specand the per-typerem_bv_spec/rem_spectheorems take the correspondinghNoOverflowhypothesis (as the
divspecs already do); regression#assertvectors:MIN % -1fails fori8..i128,MIN % 1 = 0, plain signed/unsigned casesand by-zero cases unchanged.
backends/lean/Aeneas/Std/Scalar/CheckedOps/Rem.lean: the signedchecked_remspecs now statenone ↔ y = 0 ∨ (x = MIN ∧ y = -1)(same formulation aschecked_div).backends/fstar/Primitives.fst:scalar_remfails onx = scalar_min ty && y = -1;assert_normchecks forMIN % -1andMIN % 1.backends/coq/Primitives.v: same check inscalar_rem. This also adds thepreviously missing division-by-zero check: in Coq
Z.rem x 0 = x, soscalar_rem x 0returnedOk xinstead of failing.src/interp/InterpExpressions.ml: the concrete evaluation ofRemerrors(panic) on
MIN % -1;Divalready errored via themk_scalarrangecheck.
Not touched:
backends/hol4/primitivesScript.smlhas the same gap (int_remguardedonly by
y = 0); left as is since I could not run HOL4 locally to fix thedependent proofs.
int_remalso disagrees with Rust on the sign of the result fornegative dividends when the division is inexact (e.g.
int_rem (-7) 3evaluates to
-2, Rust gives-1; the existingassert_normchecks onlyexercise
|x| < |y|). That is a separate pre-existing issue, out of scopehere.
Test evidence
lake buildof theAeneaslibrary succeeds (1454 jobs),including the new
#assertregression vectors (which are checked atelaboration time) and the reworked proofs.
fstar.exe Primitives.fstverifies the module, including the newassert_normchecks.dune buildofsrc(against the pinned Charon) succeeds.the guard style of
scalar_divin the same file.🤖 Generated with Claude Code