How to write a contradiction tactic with Ltac2
Authors
- Thomas Lamiaux
Summary
- how to use match! goal,
- how to choose among lazy_match! or match!,
- how to use quoting, and
- the Constr and Ind API to check properties on inductive types.
Table of content
- 1. Introduction
- 2. Matching the goal for P and ¬P
- 2.1 Choosing lazy_match!, match! or multi_match!
- 2.2 Matching Syntactically or Semantically
- 2.3 Error messages
- 3. Using Constr.Unsafe and Ind API to Add Syntactic Checks
- 3.1 Checking for Empty and Singleton Types
- 3.2 Checking for Empty and Singleton Hypotheses
- 4. Putting it All Together
Prerequisites
- Basic knowledge of Ltac2
- Ltac2 and its core library are available by default with Rocq
From Ltac2 Require Import Ltac2 Constr Printf.
1. Introduction
- A pair of hypotheses p : P and np : ¬P, such that any goal can be proven by destruct (np p)
- A hypothesis x : T where T is an inductive type without any constructor like False, in which case destruct x solves the current goal
- A hypothesis nx : ¬T such that T is an inductive type with one constructor without arguments, like True or 0 = 0; in other words, such that T is provable by constructor 1.
- If there is a hypothesis of type P in the context, then the goal is proven;
- otherwise, the goal is replaced by P.
2. Matching the goal for P and ¬P
2.1 Choosing lazy_match!, match! or multi_match!
- lazy_match! goal with picks a branch, and sticks to it to even if the
execution of the branch's body leads to a failure; there is no backtracking.
In practice, this alternative is sufficient for all applications where matching the syntax
is enough and deterministic.
- match! goal with picks the first branch that matches and evaluates its body.
If the evaluation fails, then it backtracks and picks another matching branch.
It can potentially pick the same one if all of the hypotheses have not been exhausted yet.
match! is useful as soon as matching the syntax is not enough, and we
need additional tests to see if we have picked the good hypotheses or not.
- multi_match! goal with behaves like match! except that it will further backtrack if its choice of a branch leads to a subsequent failure when linked with another tactic. multi_match! is useful when writing tactics that perform a choice and that we want to link with other tactics, like the constructor tactic.
2.2 Matching Syntactically or Semantically
2.2.1 Matching Syntactically
Goal ∀ P Q, P → ¬Q → ¬P → False.
intros.
lazy_match! goal with
| [p : ?t, np : ~(?t) |- _ ] ⇒ printf "Hypotheses are %I,%I,%t" p np t
| [ |- _ ] ⇒ printf "There are no hypotheses left to try"; fail
end.
Abort.
Once we have found P and ¬P, we want to prove False using the usual
destruct tactic that expects a Rocq term, that is, we want to write destruct (np p).
However, this is not possible since p and np are identifiers (of type ident), i.e.,
the names of the hypotheses, whereas destruct expects a Rocq term, not an identifier.
To get the terms associated to p and np, we must use Control.hyp : ident → constr,
which transforms an ident, corresponding to a hypothesis, into a Rocq term that
we can manipulate in Ltac2 (of type constr).
If there are no such hypotheses then it raises an error.
For p and np, this gives us:
[ let p := Control.hyp p in
let np := Control.hyp np in
...
]
Once we have the terms associated to the hypotheses,
we must return to Rocq's world to be able to use destruct.
We do so with $, which results in:
let p := Control.hyp p in
let np := Control.hyp np in
destruct ($np $p)
Notation, to do Control.hyp and $ at once is only available in Rocq 9.1 or above.
This leads us to the following script:
let p := Control.hyp p in
let np := Control.hyp np in
destruct ($np $p)
Ltac2 match_PnP_syntax () :=
lazy_match! goal with
| [p : ?t, np : ~(?t) |- _ ] ⇒
printf "Hypotheses are %I,%I,%t" p np t;
let p := Control.hyp p in
let np := Control.hyp np in
destruct ($np $p)
| [ |- _ ] ⇒ printf "There are no hypotheses left to try"; fail
end.
Goal ∀ P Q, P → ¬Q → ¬P → False.
intros. match_PnP_syntax ().
Qed.
Goal ∀ P, P → (P → nat) → False.
intros. Fail match_PnP_syntax ().
Abort.
This approach has the advantage that it is fast but the downside is that it will not match
a hypothesis of the form ?t → False, even though it is convertible to ~(?t).
This is because ~(_) is matched syntactically.
This is not what we want for a contradiction tactic.
To deal with ?t → False, we could be tempted to add an extra-pattern in
the definition, but this would not scale as any variations around ¬ would fail.
For instance, the following hypothesis is not picked up by the syntactic match.
Checking terms up to syntax is not a good notion of equality in type theory.
For instance, 4 + 1 is not syntactically equal to 5.
What we really want here is to compare types semantically, i.e., up to
some reduction, conversion or unification.
Note, however, that it would match ~((fun _ ⇒ P) 0) as t in ¬t is
matched up to conversion.
2.2.2 Matching up to Unification
Ltac2 match_PnP_unification_v1 () :=
match! goal with
| [p : ?t1, np : ?t2 |- _ ] ⇒
printf "Hypotheses are %I : %t, and %I : %t" p t1 np t2;
let p := Control.hyp p in
let np := Control.hyp np in
destruct ($np $p :> False)
| [ |- _ ] ⇒ printf "There are no hypotheses left to try"; fail
end.
Goal ∀ P Q, P → ¬Q → ¬P → False.
intros. match_PnP_unification_v1 ().
Qed.
Goal ∀ P, P → (P → nat) → False.
intros. Fail match_PnP_unification_v1 ().
Abort.
Goal ∀ P, P → (P → False) → False.
intros. match_PnP_unification_v1 ().
Qed.
Goal ∀ P, P → ((fun _ ⇒ ¬P) 0) → False.
intros. match_PnP_unification_v1 ().
Qed.
While this technically works, a better and more principled approach is to
directly use the primitive Std.unify : constr → constr → unit that
that unifies two terms, and raises an exception if it is not possible.
With this approach, there are much less chances to make an error,
like misunderstanding how unification is done by the tactics, or
forgetting the type annotation :> False.
Moreover, it scales much better. Conversion is only available for
Rocq 9.1 and later versions, so we will not utilize it in this how-to guide.
However, if it were available, we could essentially replace Std.unify
with Std.conversion to obtain the alternative version.
One could even consider parameterising the code by a check that could later
be instantiated with unification, conversion or some syntax check up to
reduction, like to the head normal form.
Ltac2 match_PnP_unification_v2 () :=
match! goal with
| [p : ?t1, np : ?t2 |- _ ] ⇒
printf "Hypotheses are %I : %t, and %I : %t" p t1 np t2;
Std.unify t2 '($t1 → False);
printf "Unification Worked!";
let p := Control.hyp p in
let np := Control.hyp np in
destruct ($np $p)
| [ |- _ ] ⇒ printf "There are no hypotheses left to try"; fail
end.
Goal ∀ P Q, P → ¬Q → ¬P → False.
intros. match_PnP_unification_v2 ().
Qed.
Goal ∀ P, P → (P → nat) → False.
intros. Fail match_PnP_unification_v2 ().
Abort.
Goal ∀ P, P → (P → False) → False.
intros. match_PnP_unification_v2 ().
Qed.
Goal ∀ P, P → ((fun _ ⇒ ¬P) 0) → False.
intros. match_PnP_unification_v2 ().
Qed.
An issue with unification is that it can unify evars.
For instance match_PnP_unification_v2 can solve the following goal,
which is not the case for the usual contradiction tactic.
This is costly and also not a good practice for automation tactics,
which should not unify evars behind the scene. This is because evars could be
unified in unexpected ways, leading to users getting stuck later.
Users should have control on whether evars are unified or not, hence
the different e-variants of tactics, such as assumption and eassumption.
To have finer control on what happens and reduce the cost of unification,
we can instead reduce our types to a head normal form and check if it is of the
form X → Y. (Note that → is an infix notation, thus X → Y should be seen as → X Y).
We can then check that X is t1 and that Y is False.
There are many primitives in Std that allow us to reduce term. Specifically,
to reduce to the head normal form, we can use Std.eval_hnf : constr → constr.
We can then match the head with lazy_match! with the pattern ?x → ?y.
We use lazy_match! as this is deterministic: our term is either a product
or it is not.
match! goal with
| [p : ?t1, np : ?t2 |- _ ] ⇒
let t2' := Std.eval_hnf t2 in
lazy_match! t2' with
| ?x → ?y ⇒ printf "(%I : %t) is a product %t -> %t" np t2 x y;
| _ ⇒ printf "(%I : %t) is not product" np t2;
end
| [ |- _ ] ⇒ printf "There are no hypotheses left to try"; fail
We then have to compare X with t1, and Y with False. We would like to
compare terms up to conversion as it is reasonably inexpensive, but still
very expressive. Unfortunately, there is no primitive for it at the moment.
Consequently, we compare them up to syntactic equality which is still very
expressive when combined with reduction. We can achieve this using Constr.equal : term → term → bool.
This gives us the following script, to which we add some printing functions
to see what is going on.
2.2.3 Matching up to Reduction
match! goal with
| [p : ?t1, np : ?t2 |- _ ] ⇒
let t2' := Std.eval_hnf t2 in
lazy_match! t2' with
| ?x → ?y ⇒ printf "(%I : %t) is a product %t -> %t" np t2 x y;
| _ ⇒ printf "(%I : %t) is not product" np t2;
end
| [ |- _ ] ⇒ printf "There are no hypotheses left to try"; fail
Ltac2 match_PnP_unification_v3 () :=
match! goal with
| [p : ?t1, np : ?t2 |- _ ] ⇒
printf "Hypotheses are %I : %t, and %I : %t" p t1 np t2;
lazy_match! (Std.eval_hnf t2) with
| ?x → ?y ⇒
printf "(%I : %t) is a product %t -> %t" np t2 x y;
if Bool.and (Constr.equal (Std.eval_hnf x) t1)
(Constr.equal (Std.eval_hnf y) 'False)
then printf "(%I : %t) is a contradiction of (%I : %t)" np t2 p t1;
let p := Control.hyp p in
let np := Control.hyp np in
destruct ($np $p)
else (printf "(%I : %t) is not a contradiction of (%I : %t)" np t2 p t1;
printf "----- Backtracking -----"; fail)
| _ ⇒ printf "(%I : %t) is not product" np t2;
printf "----- Backtracking -----"; fail
end
| [ |- _ ] ⇒ printf "There are no hypotheses left to try"; fail
end.
Goal ∀ P Q, P → ¬Q → ¬P → False.
intros. match_PnP_unification_v3 ().
Qed.
Goal ∀ P, P → (P → nat) → False.
intros. Fail match_PnP_unification_v3 ().
Abort.
Goal ∀ P, P → (P → False) → False.
intros. match_PnP_unification_v3 ().
Qed.
Goal ∀ P, P → ((fun _ ⇒ ¬P) 0) → False.
intros. match_PnP_unification_v3 ().
Qed.
This now fails, as evariables are no longer unified.
Goal ∀ P Q, P → ¬Q → False.
intros. eassert (X4 : _) by admit.
Fail match_PnP_unification_v3 ().
Abort.
2.2.4 Optimisation
Ltac2 match_PnP_unification_v4 () :=
match! goal with
| [np : ?t2 |- _ ] ⇒
printf "Hypothesis is %I : %t" np t2;
(* Check t2 is a negation ~P *)
lazy_match! (Std.eval_hnf t2) with
| ?x → ?y ⇒
if Constr.equal (Std.eval_hnf y) 'False then
printf "%t is a negation %t -> %t" t2 x y;
printf "Search for %t:" x;
printf " ------------------------";
(* Check for a hypothesis P *)
match! goal with
| [p : ?t1 |- _ ] ⇒
printf " Hypothesis is %I : %t" p t1;
if Constr.equal (Std.eval_hnf x) t1
then printf " %t is a contradiction of %t" t2 t1;
let np := Control.hyp np in
let p := Control.hyp p in destruct ($np $p)
else (printf " ----- Backtracking -----"; fail)
| [ |- _ ] ⇒ printf " There are no hypotheses left to try";
printf "----- Backtracking -----"; fail
end
else ( printf "%t is not a negation" t2;
printf "----- Backtracking -----"; fail)
| _ ⇒ printf "%t is not a negation" t2;
printf "----- Backtracking -----"; fail
end
| [ |- _ ] ⇒ printf "Failure: There are no hypotheses left to try"; fail
end.
Goal ∀ P Q, P → ¬Q → ¬P → False.
intros. match_PnP_unification_v4 ().
Qed.
Goal ∀ P, P → (P → nat) → False.
intros. Fail match_PnP_unification_v4 ().
Abort.
Goal ∀ P, P → (P → False) → False.
intros. match_PnP_unification_v4 ().
Qed.
Goal ∀ P, P → ((fun _ ⇒ ¬P) 0) → False.
intros. match_PnP_unification_v4 ().
Qed.
Goal ∀ P Q, P → ¬Q → False.
intros. eassert (X4 : _) by admit.
Fail match_PnP_unification_v4 ().
Abort.
2.3 Error Messages
Goal ∀ P, P → (P → nat) → False.
Fail match! goal with
| [ |- _ ] ⇒ Control.zero (Tactic_failure (Some (fprintf "No such contradiction")))
end.
Abort.
The error type exn is an open type, which mean we can add a constructor to it,
i.e., a new exception, at any point.
We can hence create a new exception just for contradiction.
Ltac2 Type exn ::= [ Contradiction_Failed (message option) ].
Goal ∀ P, P → (P → nat) → False.
Fail match! goal with
| [ |- _ ] ⇒ Control.zero (Contradiction_Failed (Some (fprintf "No such contradiction")))
end.
Abort.
3. Using Constr.Unsafe and Ind API to Add Syntactic Checks
3.1 Checking for Empty and Singleton Types
- 1. We match kind using match and not with match! as kind is an inductive type of Ltac2. match! is necessary to match constr and goals.
- 2. We really need the shallow embedding: we cannot match the type of a term as we did for X → Y. Indeed, we can match X → Y with match! as we know there are exactly 2 arguments, so the syntax is fully specified. In constrasts, an application like an inductive type could have arbitrary many arguments, and we can hence not match it with match!
Ltac2 decompose_app (t : constr) :=
match Unsafe.kind t with
| Unsafe.App f cl ⇒ (f, cl)
| _ ⇒ (t,[| |])
end.
Getting the inductive data associated to an inductive block is similar.
We first use decompose_app to recover the left side of the application,
we then convert to the syntax to check if it is an inductive, in which case
we recover the inductive definition with Ind.data : inductive → data.
A subtlety to understand is that Unsafe.kind converts the syntax without
reducing terms. So if we want (fun _ ⇒ True) 0 to be considered as an
inductive, we need to reduce it first to a head normal form with
Std.eval_hnf : constr → constr.
Import Ind.
Ltac2 get_inductive_body (t : constr) : data option :=
let (x, _) := decompose_app (Std.eval_hnf t) in
match Unsafe.kind (Std.eval_hnf x) with
| (Unsafe.Ind ind _) ⇒ Some (Ind.data ind)
| _ ⇒ None
end.
We are ready to check if a type is empty or not, which is now fairly easy.
Given the definition of an inductive type, it suffices to get the number
of constructor with nconstructors : data → int, and check it is zero.
Ltac2 is_empty_inductive (t : constr) : bool :=
match get_inductive_body t with
| Some ind_body ⇒ Int.equal (Ind.nconstructors ind_body) 0
| None ⇒ false
end.
We can check an inductive type is a singleton similarly, except to one small issue.
The primitive to access the arguments of a constructor is only available in
Rocq 9.1 or above. So for now, we will only check that it has only one constructor.
Though this is not perfect and forces us to wrap destruct ($np ltac2:(constructor 1)
in solve, it still rules out most inductives, like nat.
Ltac2 is_singleton_type (t : constr) : bool :=
match get_inductive_body t with
| Some ind_body ⇒ Int.equal (Ind.nconstructors ind_body) 1
| None ⇒ false
end.
3.2 Checking for Empty and Singleton Hypotheses
Ltac2 match_P_empty () :=
match! goal with
| [ p : ?t |- _ ] ⇒
let p := Control.hyp p in
if is_empty_inductive t then destruct $p else fail
| [ |- _ ] ⇒ Control.zero (Contradiction_Failed (Some (fprintf "No such contradiction")))
end.
Goal False → False.
intros. match_P_empty ().
Qed.
Goal True → False.
intros. Fail match_P_empty ().
Abort.
Checking for the negation of an inductive type is a little bit more involved
as we need to check the type of np is of the form ?X → False.
Ltac2 match_nP_singleton () :=
match! goal with
| [ np : ?t2 |- _ ] ⇒
lazy_match! (Std.eval_hnf t2) with
| ?x → ?y ⇒
if Bool.and (is_singleton_type x) (Constr.equal (Std.eval_hnf y) 'False)
then
let np := Control.hyp np in
solve [destruct ($np ltac2:(constructor 1))]
else printf "%t is not a singeleton or %t is not False" x y ; fail
| _ ⇒ printf "%t is not product" t2; fail
end
| [ |- _ ] ⇒ Control.zero (Contradiction_Failed (Some (fprintf "No such contradiction")))
end.
Goal ¬True → False.
intros. match_nP_singleton ().
Qed.
Goal ~(0 = 0) → False.
intros. match_nP_singleton ().
Qed.
Goal ~(0 = 1) → False.
intros. Fail match_nP_singleton ().
Abort.
4. Putting it All Together
Ltac2 contradiction_empty () :=
intros;
match! goal with
| [np : ?t2 |- _ ] ⇒
let np := Control.hyp np in
(* Check if t2 is empty *)
if is_empty_inductive t2 then destruct $np else
(* If it is not, check if t2 is a negation ~P *)
lazy_match! (Std.eval_hnf t2) with
| ?x → ?y ⇒
if Constr.equal (Std.eval_hnf y) 'False then
(* If so check if x is empty *)
if is_singleton_type x
then solve [destruct ($np ltac2:(constructor 1))]
(* If not, check for a hypothesis P *)
else (match! goal with
| [p : ?t1 |- _ ] ⇒
if Constr.equal (Std.eval_hnf x) t1
then let p := Control.hyp p in destruct ($np $p)
else fail
| [ |- _ ] ⇒ fail
end)
else fail
| _ ⇒ fail
end
| [ |- _ ] ⇒ fail
end.
We also need to write a contradiction when it takes an argument.
Ltac2 contradiction_arg (t : constr) :=
lazy_match! (Std.eval_hnf (type t)) with
| ?x → ?y ⇒
if Constr.equal (Std.eval_hnf y) 'False
then match! goal with
| [p : ?t1 |- _ ] ⇒
if Constr.equal (Std.eval_hnf x) t1
then let p := Control.hyp p in destruct ($t $p)
else fail
| [ |- _ ] ⇒ assert (f : False) > [apply $t | destruct f]
end
else fail
| _ ⇒ Control.zero (Contradiction_Failed (Some (fprintf "%t is not a negation" t)))
end.
Goal ∀ P, P → ¬P → 0 = 1.
intros P p np. contradiction_arg 'np.
Qed.
Goal ∀ P, ¬P → 0 = 1.
intros P np. contradiction_arg 'np.
Abort.
Goal ∀ P, P → 0 = 1.
intros P p. Fail contradiction_arg 'p.
Abort.
Finally, we define a wrapper for it :
Ltac2 contradiction0 (t : constr option) :=
match t with
| None ⇒ contradiction_empty ()
| Some x ⇒ contradiction_arg x
end.
We can now use contradiction0 directly writing None and Some, and
the quoting constructor by hand.
Goal ∀ P Q, P → ¬Q → ¬P → False.
contradiction0 None.
Qed.
Goal ∀ P, P → ¬P → 0 = 1.
intros P p np. contradiction0 (Some 'np).
Qed.
Alternatively, we can define a notation to do deal insert None, Some and
the quoting for us, but be aware it may complicate parsing.
Ltac2 Notation "contradiction" t(opt(constr)) := contradiction0 t.
Goal ∀ P Q, P → ¬Q → ¬P → False.
contradiction.
Qed.
Goal ∀ P, P → ¬P → 0 = 1.
intros P p np. contradiction np.
Qed.
Finally, just to be sure everything is alright, let us check contradiction
on all the examples we have seen so far.
(* pairs of hypotheses *)
Goal ∀ P Q, P → ¬Q → ¬P → False.
contradiction.
Qed.
Goal ∀ P, P → (P → nat) → False.
Fail contradiction.
Abort.
Goal ∀ P, P → (P → False) → False.
contradiction.
Qed.
Goal ∀ P, P → ((fun _ ⇒ ¬P) 0) → False.
contradiction.
Qed.
Goal ∀ P Q, P → ¬Q → False.
intros. eassert (X4 : _) by admit.
Fail contradiction.
Abort.
(* empty hypotheses *)
Goal False → False.
contradiction.
Qed.
Goal True → False.
Fail contradiction.
Abort.
(* Negation of a singleton *)
Goal ¬True → False.
contradiction.
Qed.
Goal ~(0 = 0) → False.
contradiction.
Qed.
Goal ~(0 = 1) → False.
Fail contradiction.
Abort.
(* negation given as an argument *)
Goal ∀ P, P → ¬P → 0 = 1.
intros P p np. contradiction np.
Qed.
Goal ∀ P, ¬P → 0 = 1.
intros P np. contradiction np.
Abort.
Goal ∀ P, P → 0 = 1.
intros P p. Fail contradiction p.
Abort.