Skip to content

Implicit derefs (rustc adjustments) are dropped in annotation translation, so any v.len()/v.length/s.0 on a &mut parameter builds an ill-sorted term and ICEs — only the explicit (*v) spelling works #239

Description

@coord-e

Summary

The annotation-to-formula translator (analyze::annot_fn::AnnotFnTranslator::to_formula_or_term,
src/analyze/annot_fn.rs:629) walks the type-checked HIR of a #[thrust::formula_fn] companion
expression by expression, and never consults TypeckResults::expr_adjustments. Rustc does not
represent an auto-deref as a HIR node — it records it as an Adjust::Deref adjustment on the
base/receiver expression. So every implicit dereference written in an annotation is silently
dropped when the term is built.

For a shared reference this is invisible: <&T as Model>::Ty = &T::Ty becomes Sort::Box, and
chc::unbox (src/chc/unbox.rs:74-87, applied in System::solve, src/chc.rs:2045) erases
Sort::Box entirely before the system is encoded — so a missing Box deref never changes the
resulting SMT.

For a mutable reference it is fatal: <&mut T as Model>::Ty = Mut<T::Ty> becomes
Sort::Mut(_), which unbox_sort deliberately keeps (it is a genuine (current, prophecy) pair).
Projecting/indexing/len-ing through it without the MutCurrent step produces an ill-sorted term,
and Thrust aborts with an ICE while computing sorts for the SMT-LIB2 encoding.

The net effect is that the most ordinary way to specify a mutating function —

#[thrust_macros::requires(v.len() > 0)]fnpop_one(v:&mutVec<i64>){ .. }

— cannot be written at all. Every annotation on a &mut parameter must spell out (*v) by hand,
including in places where Rust itself inserts the deref for you.

This is not a missing feature: the same annotation text is accepted and correctly translated when
the parameter is &Vec<i64> or Vec<i64>. Only the &mut spelling ICEs.

Reproduction

All five files below are rejected with an ICE. Command as in the README
(--edition 2021 makes no difference):

$ cargo run --quiet -- -Adead_code -C debug-assertions=false <file>.rs

1. Method call on a &mut receiver (requires)

mut_len.rs:

#[thrust_macros::requires(v.len() == 2)]#[thrust_macros::ensures(true)]fnf(v:&mutVec<i64>){assert!(v.len() == 2);}fnmain(){letmut v:Vec<i64> = Vec::new();
v.push(1);
v.push(2);f(&mut v);}
thread 'rustc' panicked at src/chc.rs:213:18:
invalid tuple_elem
2: thrust::chc::Sort::tuple_elem at ./src/chc.rs:213:18
3: thrust::chc::Term<V>::sort at ./src/chc.rs:670:55
4: thrust::chc::Clause::term_sort at ./src/chc.rs:1923:14
5: thrust::chc::format_context::term_sorts at ./src/chc/format_context.rs:29:25
6: thrust::chc::format_context::atom_sorts at ./src/chc/format_context.rs:73:9
7: thrust::chc::format_context::collect_sorts
8: thrust::chc::format_context::FormatContext::from_system
9: thrust::chc::smtlib2::System::new at ./src/chc/smtlib2.rs:704:19

v is bound to a term of sort Mut (Tuple (Array Int Int) Int). Seq::len is translated at
src/analyze/annot_fn.rs:826-830 as to_term(receiver).tuple_proj(1), i.e. tuple_elem applied
directly to the Mut sort.

2. Same, in ensures

ensures_len.rs — identical ICE, so the defect is not specific to the precondition path:

#[thrust_macros::requires(true)]#[thrust_macros::ensures(v.len() == 2)]fnf(v:&mutVec<i64>){}fnmain(){}

3. Named field access through a &mut (v.length)

mut_field.rs:

#[thrust_macros::requires(v.length == 2)]#[thrust_macros::ensures(true)]fnf(v:&mutVec<i64>){assert!(v.len() == 2);}fnmain(){letmut v:Vec<i64> = Vec::new();
v.push(1);
v.push(2);f(&mut v);}
thread 'rustc' panicked at src/analyze/annot_fn.rs:789:30:
unknown named field in formula

Here the failure is one step earlier: ExprKind::Field (src/analyze/annot_fn.rs:775-793) resolves
the field name against self.expr_ty(expr).ty_adt_def(), which is the unadjusted type
model::Mut<Seq<Int>>. Mut is pub struct Mut<T: ?Sized>(PhantomData<T>), so it has no field
named length and the lookup .expect("unknown named field in formula") fires. (v.array[i]
inside a requires fails the same way.)

4. Numeric field on a user struct behind &mut

mut_tuple_field.rsinvalid tuple_elem, showing this is not Vec/Seq-specific:

structS{a:i64,b:i64}impl thrust_models::ModelforS{typeTy = (thrust_models::model::Int, thrust_models::model::Int);}#[thrust_macros::requires(s.0 == 1)]#[thrust_macros::ensures(true)]fnf(s:&mutS){assert!(s.a == 1);}fnmain(){letmut s = S{a:1,b:2};f(&mut s);}

&mut [T] behaves identically (#[requires(s.len() == 2)] fn f(s: &mut [i64])invalid tuple_elem).

5. invariant! — the realistic case

mut_invariant.rs. This is the shape any in-place loop over a &mut container takes, and it ICEs
before verification even starts:

fnzero_all(v:&mutVec<i64>){letmut i = 0_usize;while i < v.len(){
thrust_macros::invariant!(|i:usize, v:&mutVec<i64>| i <= v.len());
v[i] = 0;
i += 1;}}fnmain(){letmut v:Vec<i64> = Vec::new();
v.push(1);zero_all(&mut v);assert!(v.len() == 1);}
thread 'rustc' panicked at src/chc.rs:213:18:
invalid tuple_elem

#[thrust_macros::param(v: { w: &mut Vec<i64> | w.len() == 2 })] reaches the same ICE, so
requires, ensures, param and invariant! are all affected — every surface that lowers to a
formula_fn.

Controls

The defect is specific to the reference mutability of the annotated parameter, not to the
annotation text. Keeping requires(v.len() == 2) byte-for-byte identical and changing only the
parameter type:

fnf(v:&Vec<i64>)// accepted, translated correctlyfnf(v:Vec<i64>)// accepted, translated correctlyfnf(v:&mutVec<i64>)// ICE

And writing the deref that rustc would have inserted makes the &mut version work:

#[thrust_macros::requires((*v).len() == 2)]// accepted#[thrust_macros::requires((*v).length == 2)]// acceptedfnf(v:&mutVec<i64>){assert!(v.len() == 2);}

The &-version is genuinely translated, not vacuously accepted — calling it with a 1-element vector
is correctly rejected:

$ # fn f(v: &Vec<i64>) with requires(v.len() == 2), called as f(&v) where v.len() == 1error: verification error: Unsat

as is the explicit-deref &mut version.

annotationv: Vec<i64>v: &Vec<i64>v: &mut Vec<i64>
v.len() == 2okokICEinvalid tuple_elem
v.length == 2okokICEunknown named field in formula
(*v).len() == 2ok
(*v).length == 2ok

Root cause

AnnotFnTranslator::to_formula_or_term (src/analyze/annot_fn.rs:629) matches on ExprKind alone.
grep -rn 'expr_adjustments\|Adjustment\|adjusted' src/ returns nothing: adjustments are never read
anywhere in the analyzer.

The three affected arms all take to_term(base) unchanged:

ExprKind::Field(expr, field) => {// src/analyze/annot_fn.rs:775let index = /* looked up on self.expr_ty(expr) — the UNADJUSTED type */;let term = self.to_term(expr);FormulaOrTerm::Term(term.tuple_proj(index))}ExprKind::Index(array, index, _) => {// src/analyze/annot_fn.rs:795let array_ty = self.expr_ty(array);// unadjustedlet array_term = self.to_term(array);
...}ExprKind::MethodCall(method, receiver, args, _) => {// src/analyze/annot_fn.rs:809ifSome(def_id) == self.def_ids.seq_len(){let t = self.to_term(receiver);// no MutCurrentreturnFormulaOrTerm::Term(t.tuple_proj(1));}
...
}

model::Mut<T> declares impl<T> std::ops::Deref for Mut<T> { type Target = T; } (std.rs:84),
which is exactly what makes v.len() / v.length / s.0 type-check inside a formula — rustc
resolves them through one auto-deref step. That step is the "current value" projection the analyzer
spells Term::MutCurrent, and it is precisely what gets lost.

Only ExprKind::Unary(UnOp::Deref) (src/analyze/annot_fn.rs:697-719) ever emits mut_current() /
box_current(), which is why the explicit (*v) spelling is the only one that works.

Why the bug is invisible for &T and Box<T>: unbox_sort (src/chc/unbox.rs:74) collapses
Sort::Box(inner) => unbox_sort(*inner) but keeps Sort::Mut(inner) => Sort::Mut(..), and
unbox_term likewise erases Term::Box/Term::BoxCurrent while preserving Term::Mut/
Term::MutCurrent/Term::MutFinal. A dropped Box deref is therefore a no-op after unboxing; a
dropped Mut deref leaves a tuple_elem/deref/select standing on a Sort::Mut, which
Sort::tuple_elem (src/chc.rs:213) and Sort::deref (src/chc.rs:206) reject.

Why it matters

Specifying a function that mutates through &mut is the primary use of requires/ensures in
Thrust, and &mut Vec<T> / &mut [T] / &mut MyStruct parameters are the norm in real programs.
Today every such annotation has to be written in a dialect that deviates from Rust — (*v).length
instead of v.len() — with no diagnostic pointing that out: the user gets an ICE with no span, and
nothing in the message mentions the annotation, the parameter, or the missing deref. The same
expression working for &Vec<i64> and failing for &mut Vec<i64> makes it look like &mut
parameters are unsupported rather than that one token is missing.

The invariant! case (repro 5) is the sharpest: writing an invariant over a &mut container — the
only way to verify an in-place loop — has no non-ICE-ing natural spelling.

Expected behavior

The HIR→formula translation should apply the adjustments rustc recorded for an expression before
using its term. Concretely, to_term/to_formula_or_term should consult
self.typeck.expr_adjustments(expr) and, for each Adjust::Deref, wrap the term in
Term::MutCurrent or Term::BoxCurrent according to the adjusted-from type (mirroring the
ExprKind::Unary(UnOp::Deref) arm), and use the adjusted type wherever the current code calls
self.expr_ty(base) to classify the base (field-name lookup at :781-789, the is_seq test at
:799-801). Adjust::Borrow needs no term change, matching how AddrOf-free autorefs behave today.

Failing that, an unhandled adjustment should at minimum produce a diagnostic on the annotation's
span rather than an ICE deep in the SMT encoder.

Relation to existing issues

Environment

  • thrust @ cd6b330
  • rustc nightly-2025-09-08 (per rust-toolchain.toml)
  • Z3 5.0.0 (irrelevant here — every repro aborts before the solver is invoked)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions