IVy as a theorem prover
In the development of systems, we sometimes have to reason about mathematical functions and relations in ways that automated theorem provers can’t handle reliably. For these cases, IVy provides a facility that allows the user to supply the necessary proofs. IVy’s approach to theorem proving is designed to make maximal use of automated provers. We do this by localizing the proof into “isolates”. Our verification conditions in each isolate are confined to a logical fragment or theory that an automated prover can handle reliably.
Primitive judgments
A logical development in IVy is a succession of statements or judgments. Each judgment must be justified by a primitive axiom or inference rule.
Type declarations
On such primitive is a type declaration, like this:
type t
This judgment can be read as “let t
be a type”. It is admissible
provided t
is new symbol that has not been used up to this point
in the development.
Functions and individuals
We can introduce a constant like this:
individual n : t
where n
is new. This judgment can be read as “let n
be a term of type
t
” and is admissible if symbol n
has not been used up to this point
in the development.
Similarly, we can introduce new function and relation symbols:
function f(X:t) : t
relation r(X:t,Y:t)
The first judgment can be read as “for any term X of type t, let
f(X) be a term of type t”. The second says “for any terms
X,Y of type t, let r(X,Y) be a proposition” (a term of
type bool
).
Axioms
An axiom is a proposition whose truth is admitted by fiat. For example:
axiom [symmety_r] r(X,Y) -> r(Y,X)
The free variables X,Y in the formula are taken as universally
quantified. The text symmetry_r
between brackets is a name for the
axiom and is optional. The axiom is simply admitted in the current
context without proof. Axioms are dangerous, since they can
introduce inconsistencies. You should use axioms only if you are
developing a foundational theory and you know what you are doing, or
to make a temporary assumption that will later be removed.
Properties
A property is a proposition that can be admitted as true only if it follows logically from judgments in the current context. For example:
property [myprop] r(n,X) -> r(X,n)
A property requires a proof (see below). If a proof is not supplied, IVy applies its default proof tactic. This calls the automated prover Z3 to attempt to prove the property from the previously admitted judgments in the current context.
The default tactic works by generating a verification condition to be checked by Z3. This is a formula whose validity implies that the property is true in the current context. In this case, the verification condition is:
#- (forall X,Y. r(X,Y) -> r(Y,X)) -> (r(n,X) -> r(X,n))
That is, it states that axiom symmetry_r
implies property myprop
.
IVy checks that this formula is within a logical fragment that Z3 can
decide, then passes the formula to Z3. If Z3 finds that the formula is
valid, the property is admitted.
Definitions
A definition is a special form of axiom that cannot introduce an inconsistency. As an example:
function g(X:t) : t
definition g(X) = f(X) + 1
This can be read as “for all X, let g(X) equal f(X) + 1”. The definition is admissible if the symbol g is “fresh”, meaning it does not occur in any existing properties or definitions in the current context. Further g must not occur on the right-hand side of the equality (that is, a recursive definition is not admissible without proof – see “Recursion” below).
Theory instantiations
IVy has built-in theories that can be instantiated with a specific type
as their carrier. For example, the theory of integer arithmetic is
called int
. It has the signature {+,-,*,/,<}
, plus the integer
numerals, and provides the axioms of Peano arithmetic. To instantiate
the theory int
using type u for the integers, we write:
type u
interpret u -> int
This declaration requires that type u
is not previously interpreted
and that the symbols {+,-,*,/,<}
in the signature of int
are
fresh. Notice, though, that the symbols {+,-,*,/,<}
are
overloaded. This means that +
over distinct types is considered to
be a distinct symbol. Thus, we can we can instantiate the int
theory over different types (and also instantiate other theories that
have these symbols in their signature).
Schemata
A schema is a compound judgment that takes a collection of judgments as input (the premises) and produces a judgment as output (the conclusion). If the schema is valid, then we can provide any type-correct values for the premises and the conclusion will follow.
Here is a simple example of a schema taken as an axiom:
axiom [congruence] {
type d
type r
function f(X:d) : r
#--------------------------
property X = Y -> f(X) = f(Y)
}
The schema is contained in curly brackets and gives a list of premises following a conclusion. In this case, it says that, given types d and r and a function f from d to r and any values X,Y of type d, we can infer that X=Y implies f(X) = f(Y). The dashes separating the premises from the conclusion are just a comment. The conclusion is always the last judgment in the schema. Also, notice the declaration of function f contains a variable X. The scope of this variable is only the function declaration. It has no relation to the variable X in the conclusion.
The keyword axiom
tells IVy that this schema should be taken as valid
without proof. However, as we will see, the default
tactic treats a schema differently from a simple proposition. That is,
a schema is never used by default, but instead must be explicitly
instantiated. This allows is to express and use facts that, if they
occurred in a verification condition, would take us outside the
decidable fragment.
Any judgment that has been admitted in the current context can be applied in a proof. When we apply a schema, we supply values for its premises to infer its conclusion.
property [prop_n] Z = n -> Z + 1 = n + 1
proof {
apply congruence
}
The proof
declaration tells IVy to apply the axiom schema congruence
to prove the property.
IVy tries to match the proof goal prop_n
to the schema’s conclusion by picking particular
values for premises, that is, the types d,r and function f. It also chooses terms for the
the free variables X,Y occurring in the schema. In this case, it
discovers the following assignment:
#- d = t
#- r = t
#- X = Z
#- Y = n
#- f(N) = N + 1
After plugging in this assignment, the conclusion of the rule exactly matches the property to be proved, so the property is admitted.
The problem of finding an assignment such as the one above is one of “second order matching”. It is a hard problem, and the answer is not unique. In case IVy did not succeed in finding the above match, we could also have written the proof more explicitly, like this:
property [prop_n_2] Z = n -> Z + 1 = n + 1
proof {
apply congruence with X=Z, Y=n, f(X) = X:t + 1
}
Each of the above equations acts as a constraint on the assignment. That is, it must convert X to Z, Y to n and f(X) to X + 1. Notice that we had to explicitly type X on the right-hand side of the last equation, since its type couldn’t be inferred (and in fact it’s not the same as the type of X on the left-hand side, which is d).
It’s also possible to write constraints that do not allow for any assignment. In this case, Ivy complains that the provided match is inconsistent.
Forward proofs
In the normal way of writing proofs, we start from a set of premises, then proceed to prove a sequence of facts (or lemmata), ending in a conclusion. Each fact must be seen to follow from the preceding facts by applying a valid schema.
For example, consider the following axiom schema:
relation man(X:t)
relation mortal(X:t)
axiom [mortality_of_man] {
property [prem] man(X)
#---------------
property mortal(X)
}
The scope of free variables such as X occurring in properties is
the entire schema. Thus, this schema says that, for any term X of
type t
, if we can prove that man(X)
is true, we can prove that
mortal(X)
is true.
We take as a given that Socrates is a man, and prove he is mortal:
individual socrates : t
axiom [soc_man] man(socrates)
property mortal(socrates)
proof {
apply mortality_of_man with prem = soc_man
}
The axiom mortality_of_man
, requires us supply the premise
man(socrates)
. Fortunately, we have this fact as an axiom.
To justify the conclusion mortal(socrates)
we tell Ivy to apply
axiom mortality_of_man
with soc_man
as its premise.
Ivy achieves this proof by matching the axiom mortality_of_man
against the provided premise and conclusion. In this process, it
discovers the substitution X = socrates
. Applying this substitution to
the axiom yields the deduction we wish to make. However, if we wanted
to be more explicit about the substitution, we could have written:
property mortal(socrates)
proof {
apply mortality_of_man with prem = soc_man, X = socrates
}
Theorems ========
Thus far, we have seen schemata used only as axioms. However, we can also prove the validity of a schema as a theorem. For example, here is a theorem expressing the transitivity of equality:
theorem [trans] {
type t
property [left] X:t = Y
property [right] Y:t = Z
#--------------------------------
property X:t = Z
}
We don’t need to provide a proof for this Ivy’s default tactic using Z3 can handle it. The verification condition that Ivy generates is:
#- X = Y & Y = Z -> X = Z
Here is a theorem that lets us eliminate universal quantifiers:
theorem [elimA] {
type t
function p(W:t) : bool
property [prem] forall Y. p(Y)
#--------------------------------
property p(X)
}
It says, for any predicate p, if p(Y)
holds for all Y, then
p(X)
holds for a particular X. Again, Z3 can
prove this easily. Now let’s derive a consequence of these facts. A
function f is idempotent if applying it twice gives the same
result as applying it once. This theorem says that, if f is
idempotent, then applying f three times is the same as applying it
once:
theorem [idem2] {
type t
function f(X:t) : t
property [idem] forall X. f(f(X)) = f(X)
#--------------------------------
property f(f(f(X))) = f(X)
}
The default tactic can’t prove this because the premise contains a function cycle with a universally quantified variable. Here’s the error message it gives:
#- error: The verification condition is not in the fragment FAU.
#-
#- The following terms may generate an infinite sequence of instantiations:
#- proving.ivy: line 331: f(f(X_h))
This means we’ll need to apply some other tactic. Here is one possible proof:
proof {
property [lem1] f(f(f(X))) = f(f(X)) proof {apply elimA with prem=idem}
property [lem2] f(f(X)) = f(X) proof {apply elimA with prem=idem}
apply trans with left = lem1
}
We think this theorem holds because f(f(f(X)))
is equal to f(f(X))
(by idempotence of f) which in turn is equal to f(X)
(again, by
idempotence of f). We then apply transitivity to infer f(f(f(X))) = f(X)
.
To prove that f(f(f(X))) = f(f(X))
from idempotence, we apply the
elimA
theorem with idem
as the premise. Let’s look in a little more detail at how this works.
The elimA
theorem has a premise forall X. p(X)
. Ivy matches has to find a substitution that
will match this formulas idem
, the premise that we want to use, which is forall X. f(f(X)) = f(X)
.
It discovers the following substitution:
#- t = t
#- p(Y) = f(f(Y)) = f(Y)
Now Ivy tries to match the conclusion p(X)
against the property lem1
we are proving, which
is f(f(f(X))) = f(f(X))
. It first plugs in the above substitution, so p(X)
becomes:
#- f(f(X)) = f(X)
Matching this with our goal lem1
, it then gets this substitution:
#- X = f(X)
Plugging these substitutions into elimA
as defined above, we obtain:
#- theorem [elimA] {
#- property [prem] forall Y. f(f(Y)) = f(Y)
#- #--------------------------------
#- property f(f(f(X))) = f(f(X))
#- }
which is just the inference we want to make. This might give the impression that Ivy can magically arrive at the instantiation of a lemma needed to produce a desired inference. In general, however, this is a hard problem. If Ivy fails, we can still write out the full substitution manually, like this:
#- property [lem1] f(f(f(X))) = f(f(X))
#- proof {
#- apply elimA with t=t, p(Y) = f(f(Y)) = f(Y), X=f(X)
#- }
The second step of our proof, deriving the fact f(f(X) = f(X)
is a
similar application of idem
. The final step uses transitivity,
with our two lemmas as premises. In this case, we only have to
specify the the left
premise of trans
is lem1
. This is enough
to infer the needed substitution. There would be no harm, however,
in writing right=lem2
.
Natural deduction
The above proof is an example of reasoning in a style called
natural deduction.
In fact, elimA
is a primitive inference rule in natural deduction.
In the natural deduction style, a premise of a theorem is often itself a theorem.
For example, here is a rule of natural deduction that is used to prove an implication:
theorem [introImp] {
function p : bool
function q : bool
theorem [lem] {
property [prem] p
#----------------------
property q
}
#----------------------
property p -> q
}
This rule says that, for any predicates p and q, if we can prove q from p, then
we can prove p -> q
. The premise lem
of this rule is itself a lemma, which we have to
supply in order to use the rule.
As an application of introImp
, let’s try to prove the following theorem:
relation p
relation q
property p & (p->q) -> q
To apply introImp
to prove the implication p & (p->q) -> q
, we first need a proof of q
from p & (p->q)
. So here’s the start of a proof:
proof {
theorem [lem1] {
property [p1] p & (p -> q)
property q
}
apply introImp
}
Notice we didn’t actually prove lem1
. By leaving out the proof, we
effectively pass the problem of proving lem1
on to Z3. With this
lemma, we can then apply introImp
to give us our goal. We could also
have said with lem=lem1
, but we didn’t have to because Ivy can
figure out the premise needed by introImp
just by matching the
conclusions.
Now, if we don’t want to burden Z3 with the proof of lem1
, we can
fill it in. We’ll use this rule for eliminating implications:
theorem [elimImp] {
function p : bool
function q : bool
property [prem1] p
property [prem2] p -> q
#----------------------
property q
}
This rule says that, for any predicates p and q, if we can
prove p and we can prove p -> q
, then we can prove q
. You
might also recognize the elimImp
rule as modus ponens.
Here’s a slightly more detailed proof of our theorem that includes a
proof of lem1
::
property p & (p->q) -> q
proof {
theorem [lem1] {
property [p1] p & (p -> q)
property q
} proof {
property [p2] p
property [p3] p -> q
apply elimImp with prem1 = p1
}
apply introImp
}
That is, from p & (p -> q)
we can infer p
and p -> q
.
Notice that when we applied elimImp
, we again specified
only one premise, since this was enough to determine the
substitution for p
in the rule.
If we want to add more detail to the proof, we can can go another
level deeper by adding proofs for p2
and p3
. We’ll use these two
additional rules for eliminating “and” operators:
axiom [elimAndL] {
function p : bool
function q : bool
property [prem] p & q
#----------------------
property p
}
axiom [elimAndR] {
function p : bool
function q : bool
property [prem] p & q
#----------------------
property q
}
Here is the more detailed proof using elimAndL
and elimAndR
:
property p & (p->q) -> q
proof {
theorem [lem1] {
property [p1] p & (p -> q)
property q
} proof {
property [p2] p proof {apply elimAndL with prem = p1}
property [p3] p -> q proof {apply elimAndR with prem = p1}
apply elimImp with prem1 = p2
}
apply introImp
}
Notice the hierarchical style of proofs in natural deduction. Each lemma is proved by a sequence of sub-lemmas, until we reach the bottom level at which the lemmas are proved by primitive rules.
The full set of inference rules of natural deduction (including
equality) can be found in the the Ivy standard header deduction
.
Many tutorial resources may be found on the World-Wide Web on writing
natural deduction proofs.
Backward proof
An alternative way to construct a proof in Ivy is backward chaining. While it is perhaps most natural to use proof rules to work forward from premises to conclusions, it is equally possible to use the same rules to work backward from conclusions to premises. When applying a rule, Ivy does not require that all premises of the rule be immediately supplied. An unsupplied premise becomes a subgoal which we must prove later. In effect, the proof of the missing premise is “pushed on the stack”.
As an example, let’s look at an alternative proof of the mortality of Socrates:
property mortal(socrates)
proof {
apply mortality_of_man
apply soc_man
}
When we apply mortality_of_man
to prove mortal(socrates)
we lack the
necessary premise man(socrates)
. Ivy is undeterred by this. It simply
matches the conclusion mortal(X)
to mortal(socrates)
, instantiates
the axiom mortality_of_man
and pushes the needed premise man(socrates)
on the goal stack. We then discharge this goal using soc_man
, leaving the
goal stack empty. Each proof step is applied to the top goal on the stack.
As you may have guessed, goals remaining on the stack at the end of the ‘proof’
are passed to Z3.
When chaining proof rules backward, it is helpful to be able to see
the intermediate subgoals, since we do not write them
explicitly. This can be done with the showgoals
tactic, like this:
property mortal(socrates)
proof {
apply mortality_of_man
showgoals
apply soc_man
}
When checking the proof, the showgoals
tactic has the effect of
printing the current list of proof goals, leaving the goals
unchanged. A good way to develop a backward proof is to start with
just the tactic showgoals
, and to add tactics before it. Running
the Ivy proof checker in an Emacs compilation buffer is a convenient
way to do this.
More examples of backward proofs
Here, once again, is our theorem about idempotence:
theorem [idem3] {
type t
function f(X:t) : t
property forall X. f(f(X)) = f(X)
#--------------------------------
property f(f(f(X))) = f(X)
}
Here is one possible proof in the backward style:
proof {
apply trans with Y = f(f(X))
apply elimA with X = f(X)
apply elimA with X = X
}
In fact, it is the same proof that we wrote in the forward style, but it is now both more succinct and more difficult to understand.
As another example, here is our proof of theorem mp
written in the
backward style:
property p & (p->q) -> q
proof {
apply introImp
apply elimImp with p = p
apply elimAndL with q = (p->q)
apply elimAndR with p = p
}
Again, it is much more succinct, but offers no intuition as to why the theorem is true.
When is a backward proof appropriate? One use case is when we wish to make a small transformation to a very complex formula before passing the problem to Z3. In this situation, writing out the resulting formula as an explicit lemma is probably a waste of effort, since the formula is intended to be consumed by an automated tool rather than a human user.
Skolemization and instantiation
Quantifiers are the usual reason that a proof goal lies outside Ivy’s decidable fragment. Usually, the fastest way to prove something that is not in the decidable fragment is to eliminate the quantifier that is causing the problem. This is possible with natural deduction, but sometimes very inconvenient. A better alternative is to use Skolemization and instantiation.
Here is the idempotence lemma done in this style:
theorem [idem4] {
type t
function f(W:t) : t
property [idem] forall Y. f(f(Y)) = f(Y)
#--------------------------------
property f(f(f(X))) = f(X)
}
proof {
instantiate idem with Y = X
}
Remember that when we tried to prove this before, Ivy complained the
the term f((Y))
put us outside the decidable fragment. This is
because Y
was universally quantified in the premise idem
. In
this proof, however, we use the instantiate
tactic to plug in the
value X
for the universally quantified Y
in idem
. This is
called quantifier instantiation and gives us the following proof
goal:
#- theorem [idem4] {
#- type t
#- function f(W:t) : t
#- property [idem] f(f(X)) = f(X)
#- #--------------------------------
#- property f(f(f(X))) = f(X)
#- }
Without the quantifier, Z3 has no problem handling this proof goal.
It turns out we can always handle purely first-order proof goals in
this way, provided we apply a further tactic called
skolemize
. This tactic allows us to deal with alternations of quantifiers.
Here is an example:
theorem [sk1] {
type t
individual a : t
relation r(X:t,Y:t)
property [prem] forall X. exists Y. r(X,Y)
#---
property exists Z. r(a,Z)
}
proof {
tactic skolemize
instantiate prem with X=a
instantiate with Z = _Y(a)
}
The skolemize
tactic replaces the existentially quantified Y
in prem
by a fresh function _Y(X)
. Here is the resulting proof goal:
#- theorem [sk1] {
#- function _Y(V0:t) : t
#- type t
#- individual a : t
#- relation r(V0:t,V1:t)
#- property [prem] forall X. r(X,_Y(X))
#- property exists Z. r(a,Z)
#- }
When skolemize
is done, the premises have only universal
quantifiers, and the conclusion has only existential quantifiers.
Now, we can finish the proof by instantiating the universal in
prem
with a
, giving r(a,_Y(a))
, and then instantiating the
existentially quantified Z
in the conclusion with _Y(a)
, which
also gives r(a,_Y(a))
. One way to describe the instantiation of
the conclusion is that we have provided the term _Y(a)
as a
witness for the existential quantifier. Our final proof goal is
trivial:
#- theorem [sk1] {
#- function _Y(V0:t) : t
#- type t
#- individual a : t
#- relation r(V0:t,V1:t)
#- property [prem] r(a,_Y(a))
#- property r(a,_Y(a))
#- }
All of this wasn’t really necessary, since the original theorem is in the decidable fragment. However, the approach of Skolemization followed by selective instantiation of quantifiers can be a quick way to reduce a proof problem to a decidable one. First, we try to get Z3 to discharge the proof goal automatically. If Ivy complains about a quantified variable, we eliminate that variable by Skolemization and instantiation. If we guess the wrong instantiation, Ivy will give us a counterexample.
Avoiding name clashes
How does Skolemization work if there is more than one variable with the same name? As an example, suppose we have this axiom:
relation s(X:t,Y:t)
axiom [prem] forall X. exists Y. s(X,Y)
We would like to prove this property:
individual a : t
individual b : t
property [sk2] exists Z,W. s(a,Z) & s(b,W)
To prove this, we would like to use the axiom twice, once for X=a
and once for X=b
. So let’s create two instances of it:
proof {
instantiate [p1] prem with X=a
instantiate [p2] prem with X=b
tactic skolemize
showgoals
}
Notice we gave the two instances distinct names, p1
and
p2
. After Skolemization, this is the proof goal we got:
#- theorem [prop1] {
#- individual _Y : t
#- individual _Y_a : t
#- property [p1] s(a,_Y)
#- property [p2] s(b,_Y_a)
#- property exists W,Z. s(a,Z) & s(b,W)
#- }
The Skolem symbol in p2
was automatically given a fresh name
_Y_a
so as not to clash with the symbol _Y
used in p1
. Now we
could witness the conclusion with W=_Y
and Z=_Y_a
. This is
fragile, though, because changes to the proof might result in a name
different from _Y_a
. A better approach is to use alpha renaming to
the two quantifiers different variable names. For example:
property exists Z,W. s(a,Z) & s(b,W)
proof {
instantiate [p1] prem<Y1/Y> with X=a
instantiate [p2] prem<Y2/Y> with X=b
tactic skolemize
instantiate with Z=_Y1, W=_Y2
}
By renaming the bound variables in our two copies of the axiom to
be Y1
and Y2
, we can make sure that the Skolem symbols have
predictable names. Again, there was no need to use manual
instantiation for this simple problem, but in more complex cases
with nested quantifiers, this approach can make it possible to let Z3
do most of the quantifier instantiation work by only instantiating
the quantifiers that the fragment checker complains about.
Recursion
Recursive definitions are permitted in IVy by instantiating a definitional schema. As an example, consider the following axiom schema:
#- axiom [rec[u]] {
#- type q
#- function base(X:u) : q
#- function step(X:q,Y:u) : q
#- fresh function fun(X:u) : q
#- #---------------------------------------------------------
#- definition fun(X:u) = base(X) if X <= 0 else step(fun(X-1),X)
#- }
This axiom was provided as part of the integer theory when we
interpreted type u as int
. It gives a way to construct a fresh
function fun
from two functions:
base
gives the value of the function for inputs less than or equal to zero.step
gives the value for positive X in terms of X and the value for X-1
A definition schema such as this requires that the defined function symbol be fresh. With this schema, we can define a recursive function that adds the non-negative numbers less than or equal to X like this:
function sum(X:u) : u
definition {
sum(X:u) = 0 if X <= 0 else (X + sum(X-1))
}
proof {
apply rec[u]
}
Notice that we wrote the definition in curly brackets. This causes Ivy to
treat it as an axiom schema, as opposed to a simple axiom.
We did this because the definition has a universally quantified variable
X
under arithmetic operators, which puts it outside the decidable
fragment. Because this definition is a schema, when we want to use it,
we’ll have to apply it explicitly,
In order to admit this definition, we applied the definition
schema rec[u]
. Ivy infers the following substitution:
#- q=t, base(X) = 0, step(X,Y) = Y + X, fun(X) = sum(X)
This allows the recursive definition to be admitted, providing that
sum
is fresh in the current context (i.e., we have not previously referred to
sum
except to declare its type).
Extended matching
Suppose we want to define a recursive function that takes an additional parameter. For example, before summing, we want to divide all the numbers by N. We can define such a function like this:
function sumdiv(N:u,X:u) : u
definition
sumdiv(N,X) = 0 if X <= 0 else (X/N + sumdiv(N,X-1))
proof
apply rec[u]
In matching the recursion schema rec[u]
, IVy will extend the
function symbols in the premises of rec[u]
with an extra parameter N so that
schema becomes:
#- axiom [rec[u]] = {
#- type q
#- function base(N:u,X:u) : q
#- function step(N:u,X:q,Y:u) : q
#- fresh function fun(N:u,X:u) : q
#- #---------------------------------------------------------
#- definition fun(N:u,X:u) = base(N,X) if X <= 0 else step(N,fun(N,X-1),X)
#- }
The extended schema matches the definition, with the following assignment:
#- q=t, base(N,X)=0, step(N,X,Y)=Y/N+X, fun(N,X) = sum2(N,X)
This is somewhat as if the functions were “curried”, in which case the
free symbol fun
would match the partially applied term sumdiv N
.
Since Ivy’s logic doesn’t allow for partial application of functions,
extended matching provides an alternative. Notice that,
to match the recursion schema, a function definition must be
recursive in its last parameter.
Induction
The default tactic can’t generally prove properties by induction. For that IVy needs manual help. To prove a property by induction, we define an invariant and prove it by instantiating an induction schema. Here is an example of such a schema, that works for the non-negative integers:
axiom [ind[u]] {
relation p(X:u)
theorem [base] {
individual x:u
property x <= 0 -> p(x)
}
theorem [step] {
individual x:u
property p(x) -> p(x+1)
}
#--------------------------
property p(X)
}
Like the recursion schema rec[u]
, the induction schema ind[u]
is
part of the integer theory, and becomes available when we interpret
type u
as int
.
Suppose we want to prove that sum(Y)
is always greater than or equal
to Y, that is:
property [sumprop] sum(Y) >= Y
We can prove this by applying our induction schema. Here is a backward version of the proof:
proof [sumprop] {
apply ind[u] with X = Y
proof [base] {
instantiate sum with X = x
}
proof [step] {
instantiate sum with X = x + 1
}
}
Applying ind[u]
produces two sub-goals, a base case and an induction step:
#- theorem [base] {
#- individual x:u
#- property x <= 0 -> sum(x) >= x
#- }
#- theorem [step] {
#- individual x:u
#- property [step] sum(x) >= x -> sum(x+1) >= x + 1
#- }
The default tactic can’t prove these goals because the definition of
sum
is a schema that must explicitly instantiated. Fortunately, it
suffices to instantiate this schema just for the specific arguments
of sum
in our subgoals. For the base case, we need to instantiate
the definition for X
, while for the induction step, we need
X+1
. The effect of the instantiate
tactic is to add an instance of the definition of
sum
to the proof context, so in particular, it will be used by the default tactic.
Notice that we referred to the definition of sum
by the name
sum
. Alternatively, we can name the definition itself and refer
to it by this name.
After instantiating the definition of sum
, our two subgoals look like this:
#- theorem [prop5] {
#- property [def2] sum(Y + 1) = (0 if Y + 1 <= 0 else Y + 1 + sum(Y + 1 - 1))
#- property sum(Y) >= Y -> sum(Y + 1) >= Y + 1
#- }
#- theorem [prop4] {
#- property [def2] sum(Y) = (0 if Y <= 0 else Y + sum(Y - 1))
#- property Y:u <= 0 -> sum(Y) >= Y
#- }
Because these goals are quantifier-free the default tactic can easily handle them, so our proof is complete.
As an alternative, here is the slightly more verbose forward version of this proof:
property [sumprop2] sum(Y) >= Y
proof [sumprop2] {
theorem [base] {
individual x:u
property x <= 0 -> sum(x) >= x
} proof {
instantiate sum with X = x
}
theorem [step] {
individual x:u
property sum(x) >= x -> sum(x+1) >= x + 1
} proof {
instantiate sum with X = x + 1
}
apply ind[u]
}
This version is more clear than the backward version in that the base case and inductive step of the proof are stated explicitly. For a complex inductive hypothesis, this style might get a bit verbose. To reduce the amount of text we need to type, we can introduce a function definition inside a proof, like this:
theorem [foo] {property sum(X) >= X}
proof {
function inv(X) = sum(X) >= X
property inv(X) proof {
theorem [base] {
individual x:u
property x <= 0 -> inv(x)
} proof {
instantiate sum with X = x
}
theorem [step] {
individual x:u
property inv(x) -> inv(x + 1)
} proof {
instantiate sum with X = x + 1
}
apply ind[u]
}
}
Naming
If we can prove that something exists, we can give it a name. For
example, suppose that we can prove that, for every X, there exists a
Y such that succ(X,Y)
. Then there exists a function that, given an
X, produces such a Y. We can define such a function called next
in the following way:
relation succ(X:t,Y:t)
axiom exists Y. succ(X,Y)
property exists Y. succ(X,Y) named next(X)
Provided we can prove the property, and that next
is fresh, we can
infer that, for all X, succ(X,next(X))
holds. Defining a function
in this way, (that is, as a Skolem function) can be quite useful in
constructing a proof. However, since proofs in Ivy are not generally
constructive, we have no way to compute the function next
, so we
can’t use it in extracted code.
Hierarchical proof development
As the proof context gets larger, it becomes increasingly difficult for the automated prover to handle all of the judgments we have admitted. This is especially true as combining facts or theories may take us outside the automated prover’s decidable fragment. For this reason, we need some way to break the proof into manageable parts. For this purpose, IVy provides a mechanism to structure the proof into a collection of localized proofs called isolates.
Isolates
An isolate is a restricted proof context. An isolate can make parts of its proof context available to other isolates and keep other parts hidden. Moreover, isolates can contain isolates, allowing us to structure a proof development hierarchically.
Suppose, for example, that we want to define a function f, but keep the exact definition hidden, exposing only certain properties of the function. We could write something like this:
isolate t_theory = {
implementation {
interpret t -> int
definition f(X) = X + 1
}
theorem [expanding] {
property f(X) > X
}
property [transitivity] X:t < Y & Y < Z -> X < Z
}
Any names declared within the isolate belong to its namespace. For
example, the names of the two properties above are
t_theory.expanding
and t_theory.transitivity
.
The isolate contains four declarations. The first, says the type t
is to be interpreted as the integers. This instantiates the theory
of the integers for type t
, giving the usual meaning to operators
like +
and <
. The second defines f to be the integer successor
function. These two declarations are contained an implementation
section. This means that the default tactic will use them only within
the isolate and not outside.
The remaining two statements are properties about t and f to be proved. These properties are proved using only the context of the isolate, without any judgments admitted outside.
Now suppose we want to prove an extra property using t_theory
:
isolate extra = {
theorem [prop] {
property Y < Z -> Y < f(Z)
}
proof {
instantiate t_theory.expanding with X = Z
}
}
with t_theory
The ‘with’ clause says that the properties in t_theory
should be
used by the default tactic within the isolate. In this case, the transitivity
property will be used by default. This pattern is particularly useful when
we have a collection of properties of an abstract datatype that we wish to
use widely without explicitly instantiating them.
Notice that the default tactic will not use the interpretation of t as the
integers and the definition of f as the successor function, since
these are in the implementation section of isolate t_theory
and are therefore
hidden from other isolates. Similarly, theorem expanding
is not used by default
because it is a schema. This is as intended, since including any of these facts would
put the verification condition outside the decidable fragment.
We used two typical techniques here to keep the verification
conditions decidable. First, we hid the integer theory and the
definition of f inside an isolate, replacing them with some
abstract properties. Second, we eliminated a potential cycle in the
function graph by instantiating the quantifier implicit in theorem
expanding
, resulting in a stratified proof goal.
Hierarchies of isolates
An isolate can in turn contain isolates. This allows us to structure a proof hierarchically. For example, the above proof could have been structured like this:
isolate extra2 = {
function f(X:t) : t
isolate t_theory = {
implementation {
interpret t -> int
definition f(X) = X + 1
}
theorem [expanding] {
property f(X) > X
}
property [transitivity] X:t < Y & Y < Z -> X < Z
}
theorem [prop] {
property Y < Z -> Y < f(Z)
}
proof
assume t_theory.expanding with X = Z
}
The parent isolate extra2
uses only the visible parts of the child isolate t_theory
.
Proof ordering and refinement
Thus far, proof developments have been presented in order. That is, judgments occur in the file in the order in which they are admitted to the proof context.
In practice, this strict ordering can be inconvenient. For example, from the point of view of clear presentation, it may often be better to state a theorem, and then develop a sequence of auxiliary definitions and lemmas needed to prove it. Moreover, when developing an isolate, we would like to first state the visible judgments, then give the hidden implementation.
To achieve this, we can use a specification section. The declarations in this section are admitted logically after the other declarations in the isolate.
As an example, we can rewrite the above proof development so that the visible properties of the isolates occur textually at the beginning:
isolate extra3 = {
function f(X:t) : t
specification {
theorem [prop] {
property Y < Z -> Y < f(Z)
}
proof
assume t_theory.expanding with X = Z
}
isolate t_theory = {
specification {
theorem [expanding] {
property f(X) > X
}
property [transitivity] X:t < Y & Y < Z -> X < Z
}
implementation {
interpret t -> int
definition f(X) = X + 1
}
}
}