z3/doc/design_recfuns.md

3.9 KiB

Design for handling recursive functions

Main source of inspiration is [Sutter, Köksal & Kuncak 2011], as implemented in Leon, but the main differences is that we should unroll function definitions directly from the inside of Z3, in a backtracking way. Termination and fairness are ensured by iterative-deepening on the maximum number of unrollings in a given branch.

Unfolding

The idea is that every function definition f(x1…xn) := rhs[x1…xn] is compiled into:

  • a list of cases A_f_i[x1…xn] => f(x1…xn) = rhs_i[x1…xn]. When A_f_i[t1…tn] becomes true in the model, f(t1…tn) is said to be unfolded and the clause A_f_i[t1…tn] => f(t1…tn) = rhs_i[t1…tn] is added as an auxiliary clause.
  • a list of constraints Γ_f_i[x1…xn] <=> A_f_i[x1…xn] that states when A_f_i[x1…xn] should be true, depending on inputs x1…xn. For every term f(t1…tn) present in congruence closure, we immediately add all the Γ_f_i[t1…tn] <=> A_f_i[t1…tn] as auxiliary clauses (maybe during internalization of f(t1…tn)?).

where each A_f_i[x1…xn] is a special new predicate representing the given case of f, and rhs_i does not contain any ite. We assume pattern matching has been compiled to ite beforehand.

For example, fact(n) := if n<2 then 1 else n * fact(n-1) is compiled into:

  • A_fact_0[n] => fact(n) = 1
  • A_fact_1[n] => fact(n) = n * fact(n-1)
  • A_fact_0[n] <=> n < 2
  • A_fact_1[n] <=> ¬(n < 2)

The 2 first clauses are only added when A_fact_0[t] is true (respectively A_fact_1[t] is true). The 2 other clauses are added as soon as fact(t) is internalized.

Termination

To ensure termination, we define variables:

  • unfold_depth: int
  • current_max_unfold_depth: int
  • global_max_unfold_depth: int

and a special literal [max_depth=$n] for each n:int. Solving is done under the local assumption [max_depth=$current_max_unfold_depth] (this should be handled in some outer loop, e.g. in a custom tactic).

Whenever A_f_i[t1…tn] becomes true (for any f), we increment unfold_depth. If unfold_depth > current_max_unfold_depth, then the conflict clause [max_depth=$current_max_unfold_depth] => Γ => false where Γ is the conjunction of all A_f_i[t1…tn] true in the trail.

For non-recursive functions, we don't have to increment unfold_depth. Some other functions that are known

If the solver answers "SAT", we have a model. Otherwise, if [max_depth=$current_max_unfold_depth] is part of the unsat-core, then we increase current_max_unfold_depth. If current_max_unfold_depth == global_max_unfold_depth then we report "UNKNOWN" (reached global depth limit), otherwise we can try to solve() again with the new assumption (higher depth limit).

Tactic

there should be a parametrized tactic funrec(t, n) where t is the tactic used to solve (under assumption that depth is limited to current_max_unfold_depth) and n is an integer that is assigned to global_max_unfold_depth.

This way, to try and find models for a problem with recursive functions + LIA, one could use something like (funrec (then simplify dl smt) 100).

Expected benefits

This addition to Z3 would bring many benefits compared to current alternatives (Leon, quantifiers, …)

  • should be very fast and lightweight (compared to Leon or quantifiers). In particular, every function call is very lightweight even compared to Leon (no need for full model building, followed by unsat core extraction)
  • possibility of answering "SAT" for any QF_* fragment + recursive functions
  • makes define-funs-rec a first-class citizen of the language, usable to model user-defined theories or to analyze functional programs directly

Optimizations

  • maybe C_f_i literals should never be decided on (they can always be propagated). Even stronger: they should not be part of conflicts? (i.e. tune conflict resolution to always resolve these literals away, disregarding their level)