Summary
Storing a container behind an enum — Option<Vec<T>>, enum State { Buffered(Vec<u8>), … } — and mutating it in place through match &mut e is one of the most common shapes in real Rust. Thrust cannot verify it: a five-line program that pushes two elements into an Option<Vec<i64>> and reads them back reports verification error: Timeout(30s), and is still unsolved after 300 s.
The property is not hard, and the program is not unusual. Three one-word variations of the same program each verify in ~0.5 s:
- put the
Vec in a struct field instead of an enum payload — 0.5 s;
- keep the
Option but move the payload out by value (match o { Some(mut v) => { v.push(x); Some(v) } … }) — 0.5 s;
- keep
match &mut o but make the payload a scalar (Option<i64>) — 0.4 s.
Only the combination mutable borrow × enum payload × Seq-modeled payload fails. In sort terms, the systems that solve instantly carry Mut<Seq<Int>> or Mut<Option<Int>> predicate arguments; the one that hangs is the only one that carries Mut<Option<Seq<Int>>> — a Mut over a datatype whose payload holds an SMT Array.
The failure is two-sided: the buggy variant of the same program (assert!(v.len() == 3)) also times out, so Thrust returns no verdict at all rather than falling back to a correct rejection.
Reproduction
A — control: the container in a struct field (struct_field.rs) — 0.5 s, safe
struct W { v: Vec<i64> }
impl thrust_models::Model for W { type Ty = Self; }
#[thrust::callable]
fn check(x: i64, y: i64) {
let mut w = W { v: Vec::new() };
w.v.push(x);
w.v.push(y);
assert!(w.v.len() == 2);
assert!(w.v[0] == x);
assert!(w.v[1] == y);
}
fn main() {}
B — the repro: the same container in an Option (option_payload.rs) — Timeout(30s)
#[thrust::callable]
fn check(x: i64, y: i64) {
let mut o: Option<Vec<i64>> = Some(Vec::new());
match &mut o { Some(v) => v.push(x), None => {} }
match &mut o { Some(v) => v.push(y), None => {} }
match o {
Some(v) => {
assert!(v.len() == 2);
assert!(v[0] == x);
assert!(v[1] == y);
}
None => assert!(false),
}
}
fn main() {}
$ cargo run --quiet -- -Adead_code -C debug-assertions=false option_payload.rs
error: verification error: Timeout(30s)
error: aborting due to 1 previous error
It is not a budget problem: THRUST_SOLVER_TIMEOUT_SECS=300 still times out.
C — workaround: same Option, payload moved out by value (option_byvalue.rs) — 0.5 s, safe
#[thrust::callable]
fn check(x: i64, y: i64) {
let o: Option<Vec<i64>> = Some(Vec::new());
let o1 = match o { Some(mut v) => { v.push(x); Some(v) } None => None };
let o2 = match o1 { Some(mut v) => { v.push(y); Some(v) } None => None };
match o2 {
Some(v) => { assert!(v.len() == 2); assert!(v[0] == x); assert!(v[1] == y); }
None => assert!(false),
}
}
fn main() {}
A user-defined enum is worse than Option: with enum E { Empty, Items(Vec<i64>) }, one push already exceeds the budget (112 s of solving).
enum E { Empty, Items(Vec<i64>) }
impl thrust_models::Model for E { type Ty = Self; }
#[thrust::callable]
fn check(x: i64) {
let mut e = E::Items(Vec::new());
match &mut e {
E::Items(v) => v.push(x),
E::Empty => {}
}
match e {
E::Items(v) => { assert!(v.len() == 1); assert!(v[0] == x); }
E::Empty => assert!(false),
}
}
fn main() {}
Evidence matrix
All at -Adead_code -C debug-assertions=false, z3 5.0.0, default THRUST_SOLVER_ARGS, wall-clock of the whole thrust-rustc run. "verdict" is under the default 30 s budget; the time column is the run under a 300 s budget where that differs.
| program |
container lives in |
borrow |
payload observed |
time |
verdict (30 s) |
bare Vec<i64> local, 2 pushes |
— |
— |
len + both elements |
0.4 s |
safe |
A struct W { v: Vec<i64> }, 2 pushes |
struct field |
&mut (implicit) |
len + both elements |
0.5 s |
safe |
Option<i64>, write then read back |
enum payload |
match &mut o |
the scalar |
0.4 s |
safe |
Option<Vec<i64>>, 1 push |
enum payload |
match &mut o |
len only |
1.3 s |
safe |
Option<Vec<i64>>, 1 push |
enum payload |
match &mut o |
len + v[0] |
24 s |
safe (borderline) |
B Option<Vec<i64>>, 2 pushes |
enum payload |
match &mut o |
len + both elements |
>300 s |
Timeout(30s) |
B with a wrong assertion (len == 3) |
enum payload |
match &mut o |
len |
>30 s |
Timeout(30s) |
C Option<Vec<i64>>, 2 pushes, by value |
enum payload |
none (moved out) |
len + both elements |
0.5 s |
safe |
enum E { Empty, Items(Vec<i64>) }, 1 push |
enum payload |
match &mut e |
len + v[0] |
112 s |
Timeout(30s) |
same via fn add(e: &mut E, x: i64) |
enum payload |
match &mut e |
len + v[0] |
114 s |
Timeout(30s) |
same but fn add(o: &mut Option<Vec<i64>>, x: i64) |
enum payload |
match &mut o |
len + v[0] |
149 s |
Timeout(30s) |
Two rows are worth calling out. The len only row shows the cost is not the enum match by itself — it is reasoning about the container's contents once they have travelled through the enum. And the wrong assertion row shows the failure is two-sided, unlike #243: Thrust no longer even rejects the unsafe program.
Root cause
The distinguishing sort is Mut<Enum<… Seq …>>
Dumping with THRUST_OUTPUT_DIR and listing the Mut sorts each system declares:
| program |
Mut sorts in the emitted SMT-LIB |
result |
| A (struct field) |
Mut<Tuple<Array<Int-Int>-Int>> |
0.5 s |
Option<i64> |
Mut<Int>, Mut<std.option.Option<Int>> |
0.4 s |
B (Option<Vec<i64>>) |
Mut<Tuple<Array<Int-Int>-Int>>, Mut<std.option.Option<Tuple<Array<Int-Int>-Int>>> |
hangs |
Mut over a Seq alone is affordable (row A). Mut over an enum with a scalar payload is affordable (row 2). Mut over an enum whose payload is Seq-modeled is not.
Why the enum, and not the struct
A struct is elaborated to a tuple, so w.v is a projection: the Vec reaches the predicate as one Tuple<Array,Int> argument and nothing else.
An enum is expanded instead (Env::bind_enum, src/refine/env.rs:737-…): for every variant it allocates a temp per field, recursively bind_impls it, and reconstructs the enum value with a matcher_pred atom. For Option<Seq<Int>> each binding therefore materialises the discriminant, the Seq as a Tuple<Array,Int> datatype, and — because bind_tuple decomposes it further — the Array and the length separately; var_type then rebuilds the enum term from those parts. Every one of them is a live local, so every downstream basic-block predicate carries all of them. In B the widest predicate has 20 arguments, of which 4 are raw (Array Int Int) and 5 are datatypes containing an array; the largest clause quantifies 112 variables (A: 15 arguments, 96 variables). The matcher_pred for that sort is a disjunction over array-carrying constructors:
(define-fun matcher_pred<A2_std.option.Option<Tuple<Array<Int-Int>-Int>>>
((x0 A0_Tuple<Array<Int-Int>-Int>) (v A2_std.option.Option<Tuple<Array<Int-Int>-Int>>)) Bool
(or (= v std.option.Option.None<Tuple<Array<Int-Int>-Int>>)
(= v (std.option.Option.Some<Tuple<Array<Int-Int>-Int>> x0))))
so each match adds a case split over equalities between datatypes that contain arrays, and the &mut adds a second copy of all of it for the prophecy.
Enum expansion depth is not the driver: THRUST_ENUM_EXPANSION_DEPTH_LIMIT of 3 or 4 gives the same times as the default 2 (and 1 makes the repro ICE, cf. #253).
The system is acyclic, and z3's eager Horn inlining is what diverges
Parsing B's 28 clauses and building the body → head predicate dependency graph gives no cycles at all. There is no user-written un-annotated helper and no loop; Vec::push carries an extern spec, so it contributes no template.
Feeding B's dump straight to z3:
$ z3 fp.spacer.global=true fp.validate=true thrust_output.smt2 # what Thrust passes today
(no answer in 90 s)
$ z3 thrust_output.smt2 # z3 defaults
(no answer in 90 s)
$ z3 fp.xform.inline_eager=false thrust_output.smt2
sat # 5.4 s
End to end, with THRUST_SOLVER_ARGS="fp.spacer.global=true fp.validate=true fp.xform.inline_eager=false":
| program |
default args |
+ fp.xform.inline_eager=false |
B (Option<Vec<i64>>, 2 pushes) |
Timeout(30s) |
safe, 7.1 s |
enum E, 1 push, inline |
Timeout(30s) |
safe, 1.1 s |
enum E, 1 push, via fn add(&mut E, i64) |
Timeout(30s) |
safe, 2.4 s |
Relation to #243
#243 reaches the same z3 option from a different program shape, and its model does not cover this one. It concludes that "ADT-sorted predicate arguments are affordable, and recursive templates are affordable, but not together", with the recursion coming from one inference template reused at two call sites of an un-annotated &mut-taking helper — and it notes that "v.push(x)-style code is unaffected because the Vec methods carry extern specs".
This repro has no recursion (the dependency graph is acyclic), no un-annotated helper (variant B is helper-free; the helper variants behave the same), and no &mut parameter (check takes two i64s). It is v.push(x)-style code. So ingredient 1 of #243 is absent and ingredient 2 alone suffices, once the ADT payload carries an Array. #243's suggested fix — representing a &mut T parameter by its two scalar components — would not reach this case either: the Mut here is created by match &mut o on a local, and its referent is an enum, not a scalar.
(As a caveat in the other direction: the two Sink-style variants I tried, where an un-annotated fn write(&mut self, x: i64) is called twice, time out even with the container directly in a struct field, i.e. they are #243 and not this issue. The rows in the matrix above are all helper-free or single-call.)
Suggested direction
Attack the encoding rather than the solver flag — as #243 records, fp.xform.inline_eager=false regresses tests/ui/{pass,fail}/iterators/fixed_filter_*.rs from correct to Timeout, so it is a diagnostic, not a patch.
Two candidates, in rough order of appeal:
- Do not re-materialise an enum's payload when it is already flow-bound.
bind_enum allocates a fresh field temp for every variant on every binding, and the same enum-valued local ends up constrained by several identical matcher_pred atoms in one clause (in B's largest clause, six distinct groups of components are each duplicated 2–4×). Reusing the existing binding would cut the array-sorted argument count roughly in half before anything else changes.
- Keep
Seq-modeled payloads out of datatype arguments. A Vec behind an enum reaches the predicates as Option<Tuple<Array,Int>>; passing the payload's (Array, Int) components alongside the discriminant instead — which is what the struct-field elaboration effectively already does, and what makes row A fast — would make the enum system structurally similar to the struct one.
Environment
- thrust @
2bf022d
- rustc
nightly-2025-09-08 (per rust-toolchain.toml), edition-2015 default and --edition=2021 behave identically here
- Z3 5.0.0 (
x64-glibc-2.39, the version .github/actions/setup-z3 pins), default THRUST_SOLVER_ARGS, default 30 s timeout
Summary
Storing a container behind an
enum—Option<Vec<T>>,enum State { Buffered(Vec<u8>), … }— and mutating it in place throughmatch &mut eis one of the most common shapes in real Rust. Thrust cannot verify it: a five-line program that pushes two elements into anOption<Vec<i64>>and reads them back reportsverification error: Timeout(30s), and is still unsolved after 300 s.The property is not hard, and the program is not unusual. Three one-word variations of the same program each verify in ~0.5 s:
Vecin a struct field instead of an enum payload — 0.5 s;Optionbut move the payload out by value (match o { Some(mut v) => { v.push(x); Some(v) } … }) — 0.5 s;match &mut obut make the payload a scalar (Option<i64>) — 0.4 s.Only the combination mutable borrow × enum payload ×
Seq-modeled payload fails. In sort terms, the systems that solve instantly carryMut<Seq<Int>>orMut<Option<Int>>predicate arguments; the one that hangs is the only one that carriesMut<Option<Seq<Int>>>— aMutover a datatype whose payload holds an SMTArray.The failure is two-sided: the buggy variant of the same program (
assert!(v.len() == 3)) also times out, so Thrust returns no verdict at all rather than falling back to a correct rejection.Reproduction
A — control: the container in a struct field (
struct_field.rs) — 0.5 s,safeB — the repro: the same container in an
Option(option_payload.rs) —Timeout(30s)It is not a budget problem:
THRUST_SOLVER_TIMEOUT_SECS=300still times out.C — workaround: same
Option, payload moved out by value (option_byvalue.rs) — 0.5 s,safeA user-defined enum is worse than
Option: withenum E { Empty, Items(Vec<i64>) }, one push already exceeds the budget (112 s of solving).Evidence matrix
All at
-Adead_code -C debug-assertions=false, z3 5.0.0, defaultTHRUST_SOLVER_ARGS, wall-clock of the wholethrust-rustcrun. "verdict" is under the default 30 s budget; the time column is the run under a 300 s budget where that differs.Vec<i64>local, 2 pusheslen+ both elementssafestruct W { v: Vec<i64> }, 2 pushes&mut(implicit)len+ both elementssafeOption<i64>, write then read backmatch &mut osafeOption<Vec<i64>>, 1 pushmatch &mut olenonlysafeOption<Vec<i64>>, 1 pushmatch &mut olen+v[0]safe(borderline)Option<Vec<i64>>, 2 pushesmatch &mut olen+ both elementsTimeout(30s)len == 3)match &mut olenTimeout(30s)Option<Vec<i64>>, 2 pushes, by valuelen+ both elementssafeenum E { Empty, Items(Vec<i64>) }, 1 pushmatch &mut elen+v[0]Timeout(30s)fn add(e: &mut E, x: i64)match &mut elen+v[0]Timeout(30s)fn add(o: &mut Option<Vec<i64>>, x: i64)match &mut olen+v[0]Timeout(30s)Two rows are worth calling out. The
lenonly row shows the cost is not the enum match by itself — it is reasoning about the container's contents once they have travelled through the enum. And the wrong assertion row shows the failure is two-sided, unlike #243: Thrust no longer even rejects the unsafe program.Root cause
The distinguishing sort is
Mut<Enum<… Seq …>>Dumping with
THRUST_OUTPUT_DIRand listing theMutsorts each system declares:Mutsorts in the emitted SMT-LIBMut<Tuple<Array<Int-Int>-Int>>Option<i64>Mut<Int>,Mut<std.option.Option<Int>>Option<Vec<i64>>)Mut<Tuple<Array<Int-Int>-Int>>,Mut<std.option.Option<Tuple<Array<Int-Int>-Int>>>Mutover aSeqalone is affordable (row A).Mutover an enum with a scalar payload is affordable (row 2).Mutover an enum whose payload isSeq-modeled is not.Why the enum, and not the struct
A struct is elaborated to a tuple, so
w.vis a projection: theVecreaches the predicate as oneTuple<Array,Int>argument and nothing else.An enum is expanded instead (
Env::bind_enum,src/refine/env.rs:737-…): for every variant it allocates a temp per field, recursivelybind_impls it, and reconstructs the enum value with amatcher_predatom. ForOption<Seq<Int>>each binding therefore materialises the discriminant, theSeqas aTuple<Array,Int>datatype, and — becausebind_tupledecomposes it further — theArrayand the length separately;var_typethen rebuilds the enum term from those parts. Every one of them is a live local, so every downstream basic-block predicate carries all of them. In B the widest predicate has 20 arguments, of which 4 are raw(Array Int Int)and 5 are datatypes containing an array; the largest clause quantifies 112 variables (A: 15 arguments, 96 variables). Thematcher_predfor that sort is a disjunction over array-carrying constructors:so each
matchadds a case split over equalities between datatypes that contain arrays, and the&mutadds a second copy of all of it for the prophecy.Enum expansion depth is not the driver:
THRUST_ENUM_EXPANSION_DEPTH_LIMITof 3 or 4 gives the same times as the default 2 (and 1 makes the repro ICE, cf. #253).The system is acyclic, and z3's eager Horn inlining is what diverges
Parsing B's 28 clauses and building the
body → headpredicate dependency graph gives no cycles at all. There is no user-written un-annotated helper and no loop;Vec::pushcarries an extern spec, so it contributes no template.Feeding B's dump straight to z3:
End to end, with
THRUST_SOLVER_ARGS="fp.spacer.global=true fp.validate=true fp.xform.inline_eager=false":+ fp.xform.inline_eager=falseOption<Vec<i64>>, 2 pushes)Timeout(30s)safe, 7.1 senum E, 1 push, inlineTimeout(30s)safe, 1.1 senum E, 1 push, viafn add(&mut E, i64)Timeout(30s)safe, 2.4 sRelation to #243
#243 reaches the same z3 option from a different program shape, and its model does not cover this one. It concludes that "ADT-sorted predicate arguments are affordable, and recursive templates are affordable, but not together", with the recursion coming from one inference template reused at two call sites of an un-annotated
&mut-taking helper — and it notes that "v.push(x)-style code is unaffected because theVecmethods carry extern specs".This repro has no recursion (the dependency graph is acyclic), no un-annotated helper (variant B is helper-free; the helper variants behave the same), and no
&mutparameter (checktakes twoi64s). It isv.push(x)-style code. So ingredient 1 of #243 is absent and ingredient 2 alone suffices, once the ADT payload carries anArray. #243's suggested fix — representing a&mut Tparameter by its two scalar components — would not reach this case either: theMuthere is created bymatch &mut oon a local, and its referent is an enum, not a scalar.(As a caveat in the other direction: the two
Sink-style variants I tried, where an un-annotatedfn write(&mut self, x: i64)is called twice, time out even with the container directly in a struct field, i.e. they are #243 and not this issue. The rows in the matrix above are all helper-free or single-call.)Suggested direction
Attack the encoding rather than the solver flag — as #243 records,
fp.xform.inline_eager=falseregressestests/ui/{pass,fail}/iterators/fixed_filter_*.rsfrom correct toTimeout, so it is a diagnostic, not a patch.Two candidates, in rough order of appeal:
bind_enumallocates a fresh field temp for every variant on every binding, and the same enum-valued local ends up constrained by several identicalmatcher_predatoms in one clause (in B's largest clause, six distinct groups of components are each duplicated 2–4×). Reusing the existing binding would cut the array-sorted argument count roughly in half before anything else changes.Seq-modeled payloads out of datatype arguments. AVecbehind an enum reaches the predicates asOption<Tuple<Array,Int>>; passing the payload's(Array, Int)components alongside the discriminant instead — which is what the struct-field elaboration effectively already does, and what makes row A fast — would make the enum system structurally similar to the struct one.Environment
2bf022dnightly-2025-09-08(perrust-toolchain.toml), edition-2015 default and--edition=2021behave identically herex64-glibc-2.39, the version.github/actions/setup-z3pins), defaultTHRUST_SOLVER_ARGS, default 30 s timeout