Skip to content

Incompleteness: a &mut into an enum/Option whose payload is a Vec (any Seq-modeled container) defeats the default solver configuration — a five-line Option<Vec<i64>> program times out, while the same code with the container in a struct field, or moved out by value, verifies in 0.5 s #263

Description

@coord-e

Summary

Storing a container behind an enumOption<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:

  1. 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.
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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