Skip to content

Quantifier (forall/exists) binder sorts are never declared in SMT-LIB, so any spec quantifying over a datatype/tuple-sorted variable emits an undefined sort #142

Description

@coord-e

Summary

collect_sorts (src/chc/format_context.rs:209-225) gathers the sorts that need datatype declarations in the emitted SMT-LIB only from (a) pred_vars signatures, (b) clause.vars, and (c) the argument terms of atoms (via atom_sorts/term_sorts). It never visits the binder sorts of Formula::Exists / Formula::Forall. Compounding this, term_sorts ignores the sort carried by a quantified variable term:

// src/chc/format_context.rs:60
chc::Term::FormulaQuantifiedVar(_, _) => {}

As a result, when a forall/exists quantifies over a variable whose sort is a datatype, tuple, or any other non-builtin/composite sort that does not otherwise appear in the same clause, that sort is never collected, so no declare-datatype is emitted for it. The generated SMT then references an undeclared sort inside the quantifier, and any SMT solver rejects the query as ill-typed.

This is an incompleteness bug, not a soundness hole: the malformed SMT is rejected at parse time (before solving), surfaced as a generic verification error, so safe programs are falsely rejected rather than wrongly accepted. Because it fails during SMT parsing, it is independent of which CHC backend is used — the PCSat solver normally required for quantified specs would reject it just the same.

Reproduction

repro.rs — a postcondition that quantifies over an enum-sorted variable:

//@compile-flags: -C debug-assertions=offuse thrust_models::exists;enumE{A(i32),B,}#[thrust_macros::ensures(exists(|e:E| result >= 0))]fnf() -> i32{0}fnmain(){}
$ THRUST_OUTPUT_DIR=/tmp/out cargo run -q -- -Adead_code -C debug-assertions=false repro.rserror: verification error: Error { stdout: "(error \"line 19 column 85: invalid sorted variables: unknown sort 'E'\")\nsat\n", stderr: "" }

The generated /tmp/out/thrust_output.smt2 uses E in the quantifier binder but never declares it (no declare-datatype for E anywhere in the file):

(assert (forall ((v0 Int) (v1 Int))
(=> (and (= v0 0) p4 (= v1 v0) (not (exists ((e E)) (>= v1 0)))) false)))

The body need not even reference the bound variable — and referencing it does not help, because term_sorts drops the sort of a FormulaQuantifiedVar (line 60 above) and DatatypeCtor only recurses into its arguments without adding its own datatype sort.

Also reproduces for tuple-sorted binders

use thrust_models::exists;#[thrust_macros::ensures(exists(|p:(i32,i32)| result >= 0))]fnf() -> i32{0}fnmain(){}
error: verification error: Error { stdout: "(error \"line 19 column 85: invalid sorted variables: unknown sort 'Tuple<Int-Int>'\")\nsat\n", stderr: "" }

with the emitted assert containing (exists ((p Tuple<Int-Int>)) ...) and zero declare-datatype.

Control: the sort is declared when it appears outside the quantifier

Adding a parameter of the same sort makes E reach clause.vars, so it is collected and declared, and the unknown sort error disappears:

use thrust_models::exists;enumE{A(i32),B}#[thrust_macros::ensures(exists(|e:E| result >= 0))]fnf(used:E) -> i32{0}// `used: E` => E now appears as a clause varfnmain(){}

This isolates the defect to quantifier-binder-only sorts.

Root cause

src/chc/format_context.rs:

fncollect_sorts(system:&chc::System) -> BTreeSet<chc::Sort>{letmut sorts = BTreeSet::new();for def in&system.pred_vars{
sorts.extend(def.sig.clone());}for clause in&system.clauses{
sorts.extend(clause.vars.clone());atom_sorts(clause,&clause.head,&mut sorts);for a in clause.body.iter_atoms(){// walks atoms, but NOT quantifier bindersatom_sorts(clause, a,&mut sorts);}}
sorts
}

iter_atoms() (src/chc.rs:1504-1506) does descend into Exists/Forallbodies, so atoms inside a quantifier are visited — but the binder list Vec<(String, Sort)> is metadata that no atom references, and term_sorts discards the sort of the only term that mentions a bound variable (FormulaQuantifiedVar). So a binder-only sort is invisible to collect_sorts, hence never declared by FormatContext::from_system (format_context.rs:263-280).

Expected behavior / fix direction

collect_sorts must also collect the sorts of Exists/Forall binders (and, defensively, the sort of FormulaQuantifiedVar in term_sorts). Concretely, walk the formula structure of clause.head/clause.body and, for each Formula::Exists(vars, _) / Formula::Forall(vars, _), sorts.extend(vars.iter().map(|(_, s)| s.clone())), and change the FormulaQuantifiedVar(sort, _) arm of term_sorts to insert sort. Either change alone fixes the binder case; doing both is robust. (Composite binder sorts then also need their monomorphized/builtin datatype declarations, which from_system already derives from the collected sort set, so collecting the sort is sufficient.)

Related but distinct

  • Undefined sort name in SMT2 when predicate argument is a struct containing a closure #47 ("Undefined sort name in SMT2 when predicate argument is a struct containing a closure") is also an "undefined sort" symptom, but its cause is the predicate define-fun emitting a Tuple sort for a closure-containing struct argument — a different code path with a different trigger. The bug here is specifically about forall/exists binder sorts in collect_sorts/term_sorts and triggers on plain enums/tuples with no closures or predicates involved.

Environment

  • thrust @ 70846d7
  • rustc nightly-2025-09-08 (per rust-toolchain.toml)
  • Z3 4.16.0, default solver configuration (THRUST_SOLVER=z3)

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