Skip to main content

Debugging SMT failures

You added @verify(formal) to a function, the compiler says the solver can't prove the postcondition, and you don't know why. This page is the playbook.

The four states​

Solver saysMeaningYour move
unsatobligation is truenothing — compile proceeds
sat + counter-examplefalse — the obligation can be violatedfix the code or weaken the contract
unknowncan't decide in time / too hardmake the obligation easier
timeoutdidn't finish in smt_timeout_mssimplify or raise the budget

Read the counter-example​

verum verify prints a per-function report, not a source-anchored diagnostic. Captured directly (not reconstructed) from fn abs_maybe(x: Int) -> Int ensures result > 0 { x }, a minimal postcondition violation:

Verification Report:
============================================================
✗ abs_maybe: Failed in 0.01s
Counterexample: Counterexample:
result = 0
x = 0

Violates: postcondition violation

Summary: 1 proved, 1 failed, 0 timeout, 0 skipped

There's no source line or caret — the counter-example is a flat list of bindings under Counterexample:, and it's the weakest violating input the solver found by checking the function symbolically, not necessarily a value tied to any specific call site. For a push-style obligation you'd read the same shape, with self's fields and the pushed value in place of result/x.

Often the counter-example reveals an edge case you hadn't considered (overflow, empty collection, NaN).

Diagnostic flags​

Ask the prover to narrate what it did:

$ VERUM_TRACE_PROOFS=1 verum verify src/stack.vr

Each line names the tactic, the goal it faced and the outcome; an apply additionally reports how the lemma was instantiated and, on a failure, both sides of the unification. This distinguishes the two failures that look identical from the outside: a goal that is hard, and a goal that is unconstrained because its predicate never reflected.

There is currently no flag that writes the generated SMT-LIB to disk.

Time each obligation:

$ verum analyze --refinement
obligation routed ms result
stack.push/postcond#1 smt-backend 8 unsat
stack.push/postcond#2 smt-backend 340 unsat ← slow
stack.merge/postcond#3 smt-backend 72 unsat
stack.balance/postcond#1 portfolio 800 unknown ← problem

Playbook — "solver can't prove a true obligation"​

1. Is it actually true?​

Obvious but essential. Trace by hand; write a property test:

@property
fn push_grows_len(s: Stack<Int>, x: Int) {
let before = s.len();
let after = s.push(x).len();
assert_eq(after, before + 1);
}

If the property test finds a counter-example, the claim is false.

2. Missing invariant​

For loops: the invariant clauses must imply the postcondition when combined with the exit condition. Common omissions:

  • Loop-variable bounds (0 <= i && i <= n).
  • Accumulator invariants (sum == i * (i+1) / 2).
  • Structural invariants (xs.iter().take(i).all(|x| pred(x))).

Write the post-loop state as a conjunction; each conjunct needs explicit support from an invariant.

3. Missing decreases​

Loop termination isn't proven automatically for complex loops. Supply an explicit decreases measure; clause.

4. Quantifier trouble​

forall x: Int. P(x) — unbounded — forces the solver to synthesise instantiations. Bound it:

forall i in 0..n. P(i) // has bounds
forall x: Int where 0 <= x && x <= max. P(x)

5. Nonlinear arithmetic​

Different solver adapters vary in nonlinear strength. The capability router already routes nonlinear goals to the strongest available adapter, but if that's still not enough, escalate:

@verify(thorough) fn nonlinear_fn(...) -> ... { ... }

thorough races the available solver adapters and tactic-based proof search in parallel and takes the first success. Otherwise, supply lemmas that linearise the reasoning.

6. The helper never reflected​

If the predicate calls a helper, that helper must qualify for reflection (pure, single expression, parameterised). Otherwise the solver sees an uninterpreted function it cannot unfold — and the goal is unconstrained rather than hard. The reflection warnings name the leaf that failed.

See Logic functions.

7. Too many obligations in one function​

Split the function. Each obligation is solved independently; smaller goals are easier.

Playbook — "solver times out"​

  1. Raise the budget (temporarily, to see if it's a hard-limit issue):

    [verify]
    solver_timeout_ms = 30_000
  2. Simplify the body. Extract intermediate computations into helper functions with their own contracts. Split conjunctive postconditions.

  3. Escalate the strategy:

    @verify(thorough) // races the SMT layer + proof search in parallel
    @verify(certified) // thorough + orthogonal cross-validation

    The capability router already routes nonlinear / string / finite-model-finding goals to the strongest available adapter and LIA / bitvector / array goals to the cheapest one, so you do not need to pin a backend — escalating the strategy is the right lever.

  4. Supply inductive hints:

    // Prove a lemma separately; use it inside the main proof.
    lemma helper_monotonic(a: Int, b: Int)
    requires a <= b
    ensures f(a) <= f(b)
    { by induction a { ... } }
  5. Use assume sparingly to prune search:

    if !likely_to_help { return result; }
    assume(condition_that_holds); // hint to solver
    // ... rest of function ...

Playbook — "counter-example is weird"​

"Weird" usually means one of:

  • Integer overflow: Int is 64-bit; the counter-example may involve values near Int.MAX. Add explicit bounds.
  • NaN / infinity: Floats allow NaN which is ≠ to everything. Use is_finite() guard.
  • Empty collection: sizes zero break many invariants. Add a self.len() > 0 refinement or handle the empty case.
  • Ghost field: a field whose value is unconstrained. Add an invariant linking it to observable state.

Playbook — "portfolio disagreement"​

With @verify(thorough) several adapters race the same obligation. If two return conflicting verdicts, that is a potential solver bug rather than a defect in your code. Action:

  1. Re-run each strategy on its own to see which adapter produced which verdict.
  2. Check for timeouts — sometimes unknown is reported as sat by one tool and unsat by another due to resource limits.
  3. File a bug with the minimal reproducer — both to Verum and to the upstream solver maintainer.

General tips​

  • Start small: verify one short function completely before adding @verify(formal) project-wide.
  • Incremental proving: prove sub-claims as named lemmas that the main theorem can apply.
  • Cache awareness: if you edit only the body and the solver suddenly complains, try verum smt-stats --reset.
  • Print debug info: verum verify --trace obligation_id=push_postcond#1 shows the SMT interaction step by step.

See also​