1.3. Symbols, States and Systems
NB: this section is not just an overview of a specific part of cvc.lean. It also serves as an
example of the kind of very, very specialized API that can be built on top of this library thanks
to Lean 4's extreme flexibility; and cvc.lean's design itself, also.
Ergonomics is such a big concern for cvc.lean that it goes way beyond being a strongly-typed-lean
version of the original cvc5 C++ API. I also provides very high-level features for typical SMT
solver use cases.
This is a necessity to some extent: strongly-typed terms mean that in general we cannot put all our
terms in a collection (typically a red/black map from names to terms): some may be Term Bool
while others are Term Int, or anything else, or the actual type the terms may not even be
known at compile-time.
For instance, say we're solving some linear constraints over three variables/symbols x, y, and
z. We could follow the same workflow as the previous examples and handle each variable separately.
That would be pretty cumbersome, not to mention tedious if we have many variables and keep passing
them around to functions that create terms/do SMT-things using these variables.
We could just define our own structure, something like this:
structure AdHocVars where
flag : Term Bool
x : Term Int
y : Term Int
z : Term Int
We still need to declare variables one-by-one and build the structure, but at least we can pass
terms for the variables more efficiently. Assuming we want to retrieve Values for these
variables at some point, we now need to either retrieve the values one-by-one, or define and
instrument a special structure for that.
namespace AdHocVars
def declare : Smt AdHocVars := do
let flag ← Smt.declare "flag" Bool
let x ← Smt.declare "x" Int
let y ← Smt.declare "y" Int
let z ← Smt.declare "z" Int
return ⟨flag, x, y, z⟩
structure Values where
flag : Value Bool
x : Value Int
y : Value Int
z : Value Int
def getModel (vars : AdHocVars) : Smt.Sat Values := do
let flag ← Smt.getValue vars.flag
let x ← Smt.getValue vars.x
let y ← Smt.getValue vars.y
let z ← Smt.getValue vars.z
return ⟨flag, x, y, z⟩
end AdHocVars
This is fine, but clearly one could provide a more generic way to define a symbol structure and jump between symbols, terms, values, and more.
The Symbols class does exactly that. We will not discuss it in details in this overview
and instead just showcase what it lets us do. It comes with a syntax extension for defining symbol
structures that we use right away.
open Cvc.Symbols.Dsl
/-- My variables. -/
symbol structure Vars where
/-- Some Boolean flag. -/
flag : Bool
/-- My first variable. -/
x : Int
/-- My second variable. -/
y : Int
/-- My last variable. -/
z : Int
This defines a Vars structure:
#print Vars
structure Vars (R : Symbol.Repr) : Type
number of parameters: 1
fields:
Vars.flag : R Bool
Vars.x : R Int
Vars.y : R Int
Vars.z : R Int
constructor:
Vars.mk {R : Symbol.Repr} (flag : R Bool) (x y z : R Int) : Vars R
Without getting too technical, this makes Vars flexible regarding type of its fields. Of
particular interest are
-
Vars.Idents: fields are just their name, for instanceflag := "flag".Pre-declaration version of
Vars, already built for us byVars.idents.#print Vars.Idents #print Vars.idents -
Vars.Terms: fields are symbol-terms, for instanceflag : Term Bool.#print Vars.TermsResult of declaring
Vars.Idents, see example below. -
Vars.Model: fields are values, for instanceflag : Value Bool.#print Vars.Model #print Vars.Values -- alias for `Vars.Model`Result of get-value-ing
Vars.Terms, see example below. -
Vars.Predicate: a function taking aVars.Termsand producing aFormulain theTerm.Buildmonad.If you don't like long names, use its alias
Vars.Pred.#print Vars.Predicate #print Vars.Pred
With Vars and know a little bit about its bells and whistles, instrumentation is easy.
namespace Vars
open Cvc.Term.Dsl
def solveIO
(constraints : Array Vars.Predicate)
: IO (Option Vars.Model) := Smt.runIO do
-- don't forget this (I always do)
Smt.setOption .produceModels
-- `Vars` already knows its own identifiers
let idents := Vars.idents
-- declaration is straightforward
let vars ← idents.declare
for constraint in constraints do
Smt.assert (← constraint vars)
Smt.checkSatAnd
(ifSat := do
-- retrieving the model is easy:
let model ← vars.getModel
return some model)
(ifUnsat := return none)
def solveShowIO
(constraints : Array Vars.Predicate)
: IO Unit := do
if let some model ← solveIO constraints then
println! "found a solution 😺"
-- the fields of `Vars` are more complex than presented
-- above: they also carry the `.name` of the symbol
println! " \
{model.flag.name} ↦ {model.flag}, \
{model.x.name} ↦ {model.x}, \
{model.y.name} ↦ {model.y}, \
{model.z.name} ↦ {model.z}\
"
else println! "no solution exists 😿"
end Vars
Let's put it all together and solve some equations. We use the smtPred! part of the Cvc.Term.Dsl
which makes it easy to define a function into a Term.Build Formula. It is essentially the
same as fun vars => smt! ....
open Cvc.Term.Dsl in
#eval Smt.runIO do
let constraints : Array Vars.Predicate := #[
smtPred! vars => 2*vars.x + vars.y - 2 * vars.z = 3,
smtPred! vars => vars.x - vars.y - vars.z = 0,
-- implication is written `→` in `smt!`
smtPred! ⟨flag, x, y, z⟩ => flag → x + y + 3 * z = 12,
smtPred! ⟨flag, x, y, z⟩ => flag → x + y + z = 0,
]
println! "solving without forcing `flag`..."
Vars.solveShowIO constraints
println! "\nsolving with `flag` force to true..."
Vars.solveShowIO <| constraints.push (smtPred! vars => vars.flag)
solving without forcing `flag`... found a solution 😺 flag ↦ false, x ↦ 1, y ↦ 1, z ↦ 0 solving with `flag` force to true... no solution exists 😿
That's already pretty good, but we're just getting started. A common use case for SMT solvers is model-checking for transition system. A concrete example follows, but very briefly a transition system is three things:
-
a notion of state: a bunch of symbols/variables, such as our
Varssymbol structure; -
some constraints
initover a state specifying the initial states of the system; -
some constraints
stepbetween two statescurrentandnextspecifying thatnextis a successor ofcurrent.
Given some notion of state, such systems can take various forms, for instance
Constrained Horn Clauses. Let's keep things simple and just see them as an init predicate
over a state ≈ State → Formula, and a step relation over two states ≈
State → State → Formula.
Take for instance a stopwatch system with two inputs: startStop reset : Bool, an output
counting : Int counting the time, and an internal variable isCounting : Bool keeping track of
whether the stopwatch is counting. Then, we could define this system as
namespace Implemented
structure State where
startStop : Bool
reset : Bool
isCounting : Bool
counting : Int
def init (state : State) : Bool :=
¬ state.isCounting ∧ state.counting = 0
def step (curr next : State) : Bool :=
next.isCounting =
if next.startStop then ¬ curr.isCounting
else curr.isCounting
∧
next.counting =
if next.reset then 0
else if next.isCounting then curr.counting + 1
else curr.counting
end Implemented
Once we have such a representation, model-checking consists in checking whether some properties
(state-predicates) are verified by the system. That is, the properties are true in all reachable
states (reachable through step-transition(s) from init) in which case each property is called
an invariant of the system. If not, model-checkers are expected to produce a counterexample
(cex) trace: a sequence of concrete (valued) states such that
-
the first one verifies
init, -
element
i+1is astep-successor of elementi, and -
the last element makes a property false.
On Implemented.State for instance:
-
0 ≤ state.counteris an invariant; -
state.counter ≠ 2is not an invariant.Writing states as
⟨startStop, reset, isCounting, counter⟩, a cex trace for it could be-
⟨true, false, false, 0⟩at0- initial state -
⟨true, false, true, 1⟩at1-stepholds between0and1 -
⟨false, false, true, 2⟩at2-stepholds between1and2
-
Now, in this context we immediately run into the notion of unrolling. We're not just talking about
a notion of symbols in the sense of Vars from previous examples, we would ideally like to
talk about state symbols/terms/values that can be unrolled at some index k : Nat.
In an ideal world, we could just use the symbol-structure from previous example and replace
symbol structure ... with state structure .... Something like this:
open Cvc.State.Dsl in
/-- State structure. -/
state structure Sw.State where
/-- Input start/stop button. -/
startStop : Bool
/-- Input reset button. -/
reset : Bool
/-- Internal is-counting flag. -/
isCounting : Bool
/-- Output time counter. -/
counter : Int
State-structures give us access to all the usual symbol goodies (Sw.State.Idents,
Sw.State.Terms, Sw.State.Model) but also to unrolling-specific versions:
Sw.State.IdentsAt, Sw.State.TermsAt, and Sw.State.ModelAt.
They also give access to Sw.State.PredicateAt (Sw.State.PredAt) and
Sw.State.RelationAt (Sw.State.RelAt). They let us express, respectively, state
predicates at some unrolling depth Sw.State.PredicateAt k and state relations between a
state at k and a state at k + 1: Sw.State.RelationAt k.
Because predicates/relations are strongly-typed on their unrolling depth, this prevents a lot of errors where we would pass states at an incorrect depth to a relation-term-builder or a function checking a predicate at a precise depth.
We're still missing a notion of trace, and if we are being honest we just want a notion of system
that takes our init, step, and candidates (properties) and just do everything for us.
Introducing the system structure syntax extension, applied here to Sw.State defined above.
open Cvc.Term.Dsl
open Cvc.Sys.Dsl
/-- Stopwatch system. -/
system structure Sw for Sw.State where
init : Sw.State.StatePred :=
smtPred! state => ¬ state.isCounting ∧ state.counter = 0
step : Sw.State.StateRel := smtRel! curr next =>
(
next.isCounting =
if next.startStop then ¬ curr.isCounting
else curr.isCounting
) ∧ (
next.counter =
if next.reset then 0 else
if next.isCounting then curr.counter + 1
else curr.counter
)
namedCandidates := .ofList [
("counter positive",
smtPred! state => 0 ≤ state.counter),
("counter ≠ 2",
smtPred! state => state.counter ≠ 2),
]
Sw is two things:
-
a
Symbols.Unrollerthat handles states (Sw.State) as well as ourinitpredicate andsteprelation. -
some
Sys.Candidatesthat keep track of eachSys.Candidate's status.
Now, Sw is parameterized by a Nat which, crucially, does not correspond to the
unrolling depth. Sw length denotes unrolled systems of length state(s), meaning
that Sw 0 is an unrolling of 0 states. Sw 1 is an unrolling of 1 state.
This is a bit weird, but it allows creating a Sw 0 without declaring any term, i.e.
without being in the Smt monad.
Let's instrument Sw so that we can display what the candidate statuses are.
namespace Sw
/-- `Sw k.succ`, in `Sw 0` candidates have no status. -/
def print {k : Nat} (sw : Sw k.succ) : IO Unit := do
println! "candidates status at depth {k}:"
let .mk unknown invariant falsified := sw.candidates
if ¬ unknown.isEmpty then
println! "- {unknown.size} unknown(s)"
for (name, unk) in unknown do
let baseUpTo :=
if let some k := unk.baseValidUpTo?
then s!", valid up to step {k}"
else ""
println! " `{name}`{baseUpTo}"
if ¬ invariant.isEmpty then
println! "- {invariant.size} invariant(s)"
for (name, inv) in invariant do
println! " `{name}`, {inv.provedAt}-inductive"
if ¬ falsified.isEmpty then
println! "- {falsified.size} falsified"
for (name, fls) in falsified do
println! " `{name}`, \
falsified in {fls.falsifiedAt} steps"
for ⟨k, state⟩ in fls.cex do
println! " - at {k}: \
startStop := {state.startStop}, \
reset := {state.reset}, \
isCounting := {state.isCounting}, \
counter := {state.counter}\
"
end Sw
Feel free to dive into the Cvc.Sys API, but let's conclude our discussion of strongly-typed
symbols/states/systems the output of the do-it-for-me function Cvc.Sys.kInduction.
First, let's only allow unrolling up to two states, meaning one step:
#eval Smt.runIO do
Smt.setOption .produceModels
-- none of the type annotations are needed
let stateIdents := Sw.State.idents
let sw0 : Sw 0 := Sw.ofIdents stateIdents
let maxSteps := 2
println! "running with `maxSteps := {maxSteps}`"
let res : (k : Nat) × Sw k.succ ← sw0.kInduction maxSteps
let ⟨k, sw⟩ := res
println! "→ done at `length = {k.succ} = depth + 1`\n"
sw.print
running with `maxSteps := 2` → done at `length = 2 = depth + 1` candidates status at depth 1: - 1 unknown(s) `counter ≠ 2`, valid up to step 1 - 1 invariant(s) `counter positive`, 1-inductive
That was not enough to falsify our bad candidate. Let's start again but unroll deeper.
#eval Smt.runIO do
Smt.setOption .produceModels
-- none of the type annotations are needed
let stateIdents : Sw.State.Idents := Sw.State.idents
let sw0 : Sw 0 := Sw.ofIdents stateIdents
let maxSteps := 10
println! "running with `maxSteps := {maxSteps}`"
let res : (k : Nat) × Sw (k + 1) ← sw0.kInduction maxSteps
let ⟨k, sw⟩ := res
println! "→ done at `length = {k.succ} = depth + 1`\n"
sw.print
running with `maxSteps := 10` → done at `length = 3 = depth + 1` candidates status at depth 2: - 1 invariant(s) `counter positive`, 1-inductive - 1 falsified `counter ≠ 2`, falsified in 2 steps - at 2: startStop := false, reset := false, isCounting := true, counter := 2 - at 1: startStop := true, reset := false, isCounting := true, counter := 1 - at 0: startStop := false, reset := false, isCounting := false, counter := 0
Before moving on, note that Sys.kInduction is not limited to Sw 0. Let's see what
combining the two previous examples looks like before moving on.
#eval Smt.runIO do
Smt.setOption .produceModels
-- none of the type annotations are needed
let stateIdents := Sw.State.idents
let sw0 : Sw 0 := Sw.ofIdents stateIdents
let maxSteps := 2
println! "running with `maxSteps := {maxSteps}`"
let res : (k : Nat) × Sw k.succ ← sw0.kInduction maxSteps
let ⟨k, sw⟩ := res
println! "→ done at `length = {k.succ} = depth + 1`\n"
sw.print
if sw.isDone then
println! "\nunexpected: no more unknown candidates"
return ()
println! "\nsome candidates are still unknown...\n"
let maxSteps := 10
println! "running with `maxSteps := {maxSteps}`"
let res : (k : Nat) × Sw (k + 1) ← sw.kInduction maxSteps
let ⟨k, sw⟩ := res
println! "→ done at `length = {k.succ} = depth + 1`\n"
sw.print
running with `maxSteps := 2` → done at `length = 2 = depth + 1` candidates status at depth 1: - 1 unknown(s) `counter ≠ 2`, valid up to step 1 - 1 invariant(s) `counter positive`, 1-inductive some candidates are still unknown... running with `maxSteps := 10` → done at `length = 3 = depth + 1` candidates status at depth 2: - 1 invariant(s) `counter positive`, 1-inductive - 1 falsified `counter ≠ 2`, falsified in 2 steps - at 2: startStop := false, reset := false, isCounting := true, counter := 2 - at 1: startStop := true, reset := false, isCounting := true, counter := 1 - at 0: startStop := false, reset := false, isCounting := false, counter := 0