Skip to content

Commit 33c3904

Browse files
authored
check-rust: linear SSA for let mut reassignment (#1465)
## Summary Implements straight-line `let mut` reassignment encoding for `assura check-rust` (the residual called out after epic #1456). ### Why it was skipped in #1459 / PR #1464 - #1459 AC allowed **encode *or* residual rewrite hints**; demos closed without needing `+=` - Linear SSA is still a real encoder change with soundness edges (CFG-shaped mutation) - Epic batch prioritized demos/docs/interop over deeper body encode ### What this does - Extend `fold_simple_lets` to apply `x = e` and `x += e` (and other assign-ops) on a **straight-line** path - Still BNM for assigns inside `if` / `match` / loops (no full CFG SSA) - Unit + integration tests; docs residual tables updated ### Not in scope - Full SSA / phi nodes / loop mutation - Verus-level borrow modeling ## Test plan - [x] `cargo test -p assura --lib --locked let_mut_` - [x] `cargo test -p assura --test check_rust_body_ir --locked let_mut` - [x] Manual: `assura check-rust` on `let mut y = x; y += 1; y` with `@ensures result == x + 1` → verified --------- Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
1 parent 5d84eeb commit 33c3904

6 files changed

Lines changed: 203 additions & 30 deletions

File tree

CONTRIBUTING.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,12 @@ gaps):
213213
|-------|--------|
214214
| Panic paths (`/0`, `%0`, `/`/`%` with zero-including path divisors, `is_multiple_of(0)`, literal `0.ilog2()`) | Soundness: do not encode panic as free SMT div/mod |
215215
| `rem_euclid`/`div_euclid`/`div_ceil`/`next_multiple_of` with non-positive or zero-including divisors | Same soundness rule; use a positive const or `NonZeroU*` param |
216-
| `let mut y = x; y += 1; y` (reassignment) | Pure `let mut` fold only (#1343); mutation/SSA not modeled; rewrite to pure lets |
216+
| Assignments inside `if`/`match`/loops (not linear) | Linear SSA fold only; straight-line `let mut y = x; y += 1; y` is modeled |
217217
| Bare `checked_*` / `overflowing_*` without peel (Option or `(T, bool)` return as the result type) | Intentional: peel with `.unwrap_or` / `.unwrap_or_default` / `.is_some()` / `.is_none()` / `.0` / `.1`; full Option/tuple values are not IR types |
218218

219-
CLI prints these rewrite hints on `body_not_modeled` exit (pointing at
220-
`docs/CHECK-RUST-SURFACE.md`). Full SSA mutation encode is tracked under
221-
the check-rust competitiveness epic, not required for residual honesty.
219+
CLI prints rewrite hints on `body_not_modeled` exit (pointing at
220+
`docs/CHECK-RUST-SURFACE.md`). Straight-line `let mut` reassignment is
221+
folded in `fold_simple_lets` (linear SSA); CFG-shaped mutation remains residual.
222222

223223
Signed path-param `reverse_bits`/`swap_bytes`/`count_*`/`trailing_*`/`leading_*`
224224
use synthetic `2^64` bit-pattern map for full i64 (same as `count_ones`).

crates/assura-cli/src/check/check_rust.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -496,10 +496,10 @@ pub(crate) fn run_check_rust(
496496
} else if total_body_not_modeled > 0 {
497497
eprintln!(
498498
"{total_body_not_modeled} item(s) not proven against the Rust body \
499-
(body_not_modeled). Rewrite hints: prefer pure lets (no mut \
500-
reassignment); peel checked_*/overflowing_* with .unwrap_or / \
501-
.is_some() / .0; avoid panic div/mod. Or add co-located \
502-
{{Name}}.ir. Surface map: docs/CHECK-RUST-SURFACE.md"
499+
(body_not_modeled). Rewrite hints: keep mutation on a straight \
500+
line (no assign inside if/match/loop); peel checked_*/overflowing_* \
501+
with .unwrap_or / .is_some() / .0; avoid panic div/mod. Or add \
502+
co-located {{Name}}.ir. Surface map: docs/CHECK-RUST-SURFACE.md"
503503
);
504504
process::exit(1);
505505
} else if total_verified == 0 {

crates/assura-cli/src/check/rust_body_ir/mod.rs

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -102,40 +102,58 @@ fn body_return_from_block(block: &syn::Block) -> Option<String> {
102102
}
103103
}
104104

105-
/// Fold `let a = e1; let b = a + 1; b` (or `return b`) into a single expression.
106-
/// Only simple `Pat::Ident` bindings without type ascriptions; `mut` is allowed
107-
/// when the binding is never reassigned (pure fold). Final stmt is
108-
/// path/return/expression that may reference prior binds.
105+
/// Fold simple statement sequences into a single expression via substitution
106+
/// (linear SSA, not a full CFG).
107+
///
108+
/// Supported:
109+
/// - `let a = e1; let b = a + 1; b` (pure lets; `mut` ok without reassignment)
110+
/// - `let mut y = x; y += 1; y` / `y = y + 1; y` (linear reassignment)
111+
/// - Final stmt: path / return / expression referencing prior binds
112+
///
113+
/// Not supported (returns None → body_not_modeled): assignments inside
114+
/// `if`/`match`/loops, multi-LHS patterns, type ascriptions on lets, bare
115+
/// mid-block expressions that are not assignments.
109116
fn fold_simple_lets(stmts: &[syn::Stmt]) -> Option<syn::Expr> {
110117
if stmts.len() < 2 {
111118
return None;
112119
}
113-
// split_last -> (last_elem, prefix)
114-
let (last, binds) = stmts.split_last()?;
120+
let (last, prefix) = stmts.split_last()?;
121+
// name → current expression (already substituted for earlier names)
115122
let mut env: Vec<(String, syn::Expr)> = Vec::new();
116-
for stmt in binds {
117-
let syn::Stmt::Local(local) = stmt else {
118-
return None;
119-
};
120-
// Reject any later assignment to mut bindings (fold is pure substitute).
121-
let name = match &local.pat {
122-
syn::Pat::Ident(id) if id.by_ref.is_none() && id.subpat.is_none() => {
123-
id.ident.to_string()
123+
for stmt in prefix {
124+
match stmt {
125+
syn::Stmt::Local(local) => {
126+
let name = match &local.pat {
127+
syn::Pat::Ident(id) if id.by_ref.is_none() && id.subpat.is_none() => {
128+
id.ident.to_string()
129+
}
130+
_ => return None,
131+
};
132+
let init = local.init.as_ref()?;
133+
if init.diverge.is_some() {
134+
return None;
135+
}
136+
let mut init_expr = (*init.expr).clone();
137+
for (n, e) in env.iter().rev() {
138+
init_expr = substitute_ident_expr(init_expr, n, e);
139+
}
140+
if let Some((_, slot)) = env.iter_mut().find(|(n, _)| n == &name) {
141+
*slot = init_expr;
142+
} else {
143+
env.push((name, init_expr));
144+
}
145+
}
146+
syn::Stmt::Expr(expr, _) => {
147+
apply_linear_assignment(expr, &mut env)?;
124148
}
125149
_ => return None,
126-
};
127-
let init = local.init.as_ref()?;
128-
if init.diverge.is_some() {
129-
return None;
130150
}
131-
env.push((name, (*init.expr).clone()));
132151
}
133152
let mut final_expr: syn::Expr = match last {
134153
syn::Stmt::Expr(syn::Expr::Return(ret), _) => (*ret.expr.as_ref()?.as_ref()).clone(),
135154
syn::Stmt::Expr(e, _) => e.clone(),
136155
_ => return None,
137156
};
138-
// Substitute later binds first so earlier names expand fully.
139157
for (name, init) in env.into_iter().rev() {
140158
final_expr = substitute_ident_expr(final_expr, &name, &init);
141159
}
@@ -144,6 +162,67 @@ fn fold_simple_lets(stmts: &[syn::Stmt]) -> Option<syn::Expr> {
144162
Some(distribute_if_binary(paren_if_match_operands(final_expr)))
145163
}
146164

165+
/// Apply `x = e` or `x += e` (etc.) to the linear env. Name must already be bound.
166+
fn apply_linear_assignment(expr: &syn::Expr, env: &mut [(String, syn::Expr)]) -> Option<()> {
167+
match expr {
168+
syn::Expr::Assign(a) => {
169+
let name = expr_simple_ident_name(&a.left)?;
170+
let mut rhs = (*a.right).clone();
171+
for (n, e) in env.iter().rev() {
172+
rhs = substitute_ident_expr(rhs, n, e);
173+
}
174+
let (_, slot) = env.iter_mut().find(|(n, _)| n == &name)?;
175+
*slot = rhs;
176+
Some(())
177+
}
178+
syn::Expr::Binary(b) => {
179+
let plain = assign_op_to_bin_op(b.op)?;
180+
let name = expr_simple_ident_name(&b.left)?;
181+
let mut rhs = (*b.right).clone();
182+
for (n, e) in env.iter().rev() {
183+
rhs = substitute_ident_expr(rhs, n, e);
184+
}
185+
let cur = env.iter().find(|(n, _)| n == &name)?.1.clone();
186+
let combined = syn::Expr::Binary(syn::ExprBinary {
187+
attrs: Vec::new(),
188+
left: Box::new(cur),
189+
op: plain,
190+
right: Box::new(rhs),
191+
});
192+
let (_, slot) = env.iter_mut().find(|(n, _)| n == &name)?;
193+
*slot = combined;
194+
Some(())
195+
}
196+
_ => None,
197+
}
198+
}
199+
200+
fn expr_simple_ident_name(expr: &syn::Expr) -> Option<String> {
201+
match expr {
202+
syn::Expr::Path(p) if p.path.segments.len() == 1 && p.qself.is_none() => {
203+
Some(p.path.segments[0].ident.to_string())
204+
}
205+
syn::Expr::Paren(p) => expr_simple_ident_name(&p.expr),
206+
_ => None,
207+
}
208+
}
209+
210+
fn assign_op_to_bin_op(op: syn::BinOp) -> Option<syn::BinOp> {
211+
match op {
212+
syn::BinOp::AddAssign(_) => Some(syn::parse_quote!(+)),
213+
syn::BinOp::SubAssign(_) => Some(syn::parse_quote!(-)),
214+
syn::BinOp::MulAssign(_) => Some(syn::parse_quote!(*)),
215+
syn::BinOp::DivAssign(_) => Some(syn::parse_quote!(/)),
216+
syn::BinOp::RemAssign(_) => Some(syn::parse_quote!(%)),
217+
syn::BinOp::BitXorAssign(_) => Some(syn::parse_quote!(^)),
218+
syn::BinOp::BitAndAssign(_) => Some(syn::parse_quote!(&)),
219+
syn::BinOp::BitOrAssign(_) => Some(syn::parse_quote!(|)),
220+
syn::BinOp::ShlAssign(_) => Some(syn::parse_quote!(<<)),
221+
syn::BinOp::ShrAssign(_) => Some(syn::parse_quote!(>>)),
222+
_ => None,
223+
}
224+
}
225+
147226
/// Lift if/match out of binary/unary/method so multi-block encode can fire.
148227
/// `(if c { a } else { b }) ⊕ r` → `if c { a ⊕ r } else { b ⊕ r }` (and right/match/unary).
149228
fn distribute_if_binary(expr: syn::Expr) -> syn::Expr {

crates/assura-cli/src/check/rust_body_ir/tests.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2071,6 +2071,52 @@ fn f(x: i64) -> i64 {
20712071
assert!(ir.contains("arith add"), "body={body}\nir={ir}");
20722072
}
20732073

2074+
#[test]
2075+
fn let_mut_add_assign_encodes() {
2076+
let src = r#"
2077+
fn f(x: i64) -> i64 {
2078+
let mut y = x;
2079+
y += 1;
2080+
y
2081+
}
2082+
"#;
2083+
let body = extract_body_return(src, "f").expect("extract mut+=");
2084+
let ir = try_ir_from_rust_body("F", &px(), Some("i64"), &body).expect("encode");
2085+
assert!(ir.contains("arith add"), "body={body}\nir={ir}");
2086+
}
2087+
2088+
#[test]
2089+
fn let_mut_plain_reassign_encodes() {
2090+
let src = r#"
2091+
fn f(x: i64) -> i64 {
2092+
let mut y = x;
2093+
y = y + 1;
2094+
y
2095+
}
2096+
"#;
2097+
let body = extract_body_return(src, "f").expect("extract mut=");
2098+
let ir = try_ir_from_rust_body("F", &px(), Some("i64"), &body).expect("encode");
2099+
assert!(ir.contains("arith add"), "body={body}\nir={ir}");
2100+
}
2101+
2102+
#[test]
2103+
fn let_mut_reassign_inside_if_still_bnm() {
2104+
// Control-flow mid-block is out of linear SSA scope.
2105+
let src = r#"
2106+
fn f(x: i64) -> i64 {
2107+
let mut y = x;
2108+
if x > 0 {
2109+
y += 1;
2110+
}
2111+
y
2112+
}
2113+
"#;
2114+
assert!(
2115+
extract_body_return(src, "f").is_none(),
2116+
"if-with-assign should not fold"
2117+
);
2118+
}
2119+
20742120
#[test]
20752121
fn checked_neg_unwrap_or_encodes() {
20762122
let ir = try_ir_from_rust_body("N", &px(), Some("i64"), "x.checked_neg().unwrap_or(0)")

crates/assura-cli/tests/check_rust_body_ir.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2015,6 +2015,54 @@ fn f(x: i64) -> i64 {
20152015
assert_eq!(v["body_not_modeled"], 0, "{stdout}");
20162016
}
20172017

2018+
/// Straight-line `let mut` + `+=` / `=` reassignment (linear SSA fold).
2019+
#[test]
2020+
fn check_rust_encodes_let_mut_reassign() {
2021+
let tmp = unique_temp("assura_check_rust_let_mut_re");
2022+
let _ = std::fs::remove_dir_all(&tmp);
2023+
std::fs::create_dir_all(&tmp).unwrap();
2024+
std::fs::write(
2025+
tmp.join("ok.rs"),
2026+
r#"
2027+
/// @ensures result == x + 1
2028+
fn f(x: i64) -> i64 {
2029+
let mut y = x;
2030+
y += 1;
2031+
y
2032+
}
2033+
"#,
2034+
)
2035+
.unwrap();
2036+
let out = Command::new(assura_bin())
2037+
.args(["check-rust", "--json", tmp.join("ok.rs").to_str().unwrap()])
2038+
.output()
2039+
.unwrap();
2040+
let stdout = String::from_utf8_lossy(&out.stdout);
2041+
assert!(out.status.success(), "mut+= should prove: {stdout}");
2042+
let v: serde_json::Value = serde_json::from_str(&stdout).expect("json");
2043+
assert_eq!(v["body_not_modeled"], 0, "{stdout}");
2044+
assert!(v["verified"].as_u64().unwrap_or(0) >= 1, "{stdout}");
2045+
2046+
std::fs::write(
2047+
tmp.join("ok2.rs"),
2048+
r#"
2049+
/// @ensures result == x + 1
2050+
fn g(x: i64) -> i64 {
2051+
let mut y = x;
2052+
y = y + 1;
2053+
y
2054+
}
2055+
"#,
2056+
)
2057+
.unwrap();
2058+
let out = Command::new(assura_bin())
2059+
.args(["check-rust", "--json", tmp.join("ok2.rs").to_str().unwrap()])
2060+
.output()
2061+
.unwrap();
2062+
let stdout = String::from_utf8_lossy(&out.stdout);
2063+
assert!(out.status.success(), "mut= should prove: {stdout}");
2064+
}
2065+
20182066
/// checked_neg().unwrap_or encodes (MIN → alt).
20192067
#[test]
20202068
fn check_rust_encodes_checked_neg_unwrap() {

docs/CHECK-RUST-SURFACE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ counterexample. Width limits are typically **through 64 bits** unless noted.
5555
| Area | Examples |
5656
|------|----------|
5757
| Control | `if` / `else`, `match` |
58-
| Binding | Multi-`let`, pure `let mut` (no reassignment), `let y = if/match …; y + n` |
58+
| Binding | Multi-`let`, pure `let mut`, linear reassignment (`y += 1`, `y = y + 1` on a straight-line path), `let y = if/match …; y + n` |
5959
| Composition | if/match over binary ops (both sides), method-on-if receivers, cast-of-if |
6060
| References | Peel outer `&` / `*` layers |
6161

@@ -99,7 +99,7 @@ reports `body_not_modeled` and exits **1**. They are not silent Verified.
9999

100100
| Shape | Why / what to do |
101101
|-------|------------------|
102-
| `let mut y = x; y += 1; y` (reassignment) | Pure `let mut` fold only; mutation/SSA not modeled. Prefer pure expressions or immutable lets. |
102+
| Assignments inside `if` / `match` / loops | Linear SSA only (no CFG). Rewrite to pure expressions or branch-local values. |
103103
| Bare `checked_*` / `overflowing_*` as the **return type** (full `Option` / `(T, bool)`) | Peel: `.unwrap_or` / `.unwrap_or_default` / `.is_some()` / `.is_none()` / `.0` / `.1`. Full Option/tuple values are not IR result types. |
104104
| Bodies outside Bucket A (I/O, arbitrary methods, complex ADTs, …) | Supply a co-located `{Name}.ir`, simplify the body, or keep contracts on `.assura` + generated code. |
105105

0 commit comments

Comments
 (0)