I present an Agda eDSL for writing Hilbert style proofs for logic .
This blog consists of two main parts. The first part presents the language and some examples. The second offers some insight into the key parts of the implementation. The latter targets Agda beginner/intermediate users or any programmer interested in dependent types.
The code is available here. It has been implemented using Agda 2.6.0 with the standard library (at this specific commit).
Some introductory concepts
What is an eDSL? The acronym eDSL stands for Embedded Domain Specific Language. It refers to a small language (a set of functions and data types) embedded in another language (in this case Agda) that has been designed to solve a problem in a very specific domain, in this case, Hilbert style proofs.
What is a Hilbert style proof? First we need to know what a Hilbert calculus is. A Hilbert calculus is a set of axioms plus a set of rules. The axioms are formulas that we accept as primitive theorems and the rules allow us to syntactically combine existing theorems to create new ones. Then, a Hilbert style proof is a sequence of formulas where each formula is either an axiom of the calculus or the result of applying a rule to previous formulas in the sequence. The last formula in the sequence is the theorem of the proof. It is best understood by seeing a concrete example, if you scroll down you will find one.
The eDSL and logic
As a preface, let me remark that that the chosen logic, , is not of special importance here, it just lays the ground for some concrete examples. In fact, it would be interesting to implement a logic agnostic eDSL in the future.
Letβs begin by defining the syntax of modal formulas:
Of course, we are missing important logical connectives like , but it is better to have a small core language and define the other connectives in terms of the primitive ones, for instance:
The axioms of are the following:
The above are axiom schemes (or templates), so are formula metavariables which can be substituted for any modal formula.
And the rules are the following:
- Modus Ponens: If is a theorem and is a theorem, then is a theorem.
- Necessitation: If is a theorem then is a theorem.
- Identity: If then . Note that may not be a theorem. See clarification at the end of this section.
Finally! we can construct our first Hilbert style proof. Letβs prove .
And now⦠how does the same proof look in our eDSL as Agda code? Here it is:
β‘β¨ΟβΟβ© : β {Ξ£ Ο} β Ξ£ β’ β‘ (Ο β Ο)
β‘β¨ΟβΟβ© {Ξ£} {Ο} =
begin[ 0 ] (Ο β ((Ο β Ο) β Ο)) β ((Ο β (Ο β Ο)) β (Ο β Ο)) By K2
[ 1 ] Ο β ((Ο β Ο) β Ο) By K1
[ 2 ] Ο β (Ο β Ο) By K1
[ 3 ] (Ο β (Ο β Ο)) β (Ο β Ο) ByMP 0 , 1
[ 4 ] Ο β Ο ByMP 3 , 2
[ 5 ] β‘ (Ο β Ο) ByNec 4
β
DEFAULT
Wow, they look really similar indeed! So what? Well, we can write Hilbert styles proofs in a paper-like syntax with the key advantage that Agdaβs type checker will make sure that the proof is constructed according to the rules, that is, that there are no mistakes in it. For instance, proofs where an axiom is instantiated incorrectly, or an application of modus ponens is incorrect will fail to type check and we will get an error from the compiler telling us where the misstep is. An additional free perk that we get is that we can easily export the proofs to rich html or latex.
Let me underline that the code above is actual Agda code. This might be surprising for readers not accustomed to Agdaβs mixfix operators, which give great syntactical versatility.
It is worth noting that the By
step does not only accept axioms, but any theorem previously proved. So, if we were to prove another theorem and we wanted to use as an intermediate step we could do so. Also, since we proved for any , we are free to substitute it for any formula. Example:
...
[ 1 ] β‘ (Ο β Ο) By β‘β¨ΟβΟβ©
[ 2 ] β‘ ((Ο β§ Ο) β (Ο β§ Ο)) By β‘β¨ΟβΟβ©
...
DEFAULT
What about premises? Well, as you may have already noticed in the previous proof, we wrote , where is our set of premises, and we are free to use any formula in as if it was a theorem within the proof. As an example, letβs prove .
ΟβΟβ·Οβ·Ξ£β’Ο : β {Ο Ο Ξ£} β Ο β Ο β· Ο β· Ξ£ β’ Ο
ΟβΟβ·Οβ·Ξ£β’Ο {Ο} {Ο} =
begin[ 0 ] Ο By premise 1
[ 1 ] Ο β Ο By premise 0
[ 2 ] Ο ByMP 0 , 1
β
DEFAULT
Clarification: Notice that a formula is a theorem of the logic if and only if . So in case we have with nonempty it may be that is not a theorem of . It is worth noting that when we define ΟβΟ : β {Ξ£ Ο} : Ξ£ β’ Ο β Ο
, then the term ΟβΟ
is really a proof that is a theorem of since is a parameter of the proof and in particular it can be empty.
This concludes the overview of the language features.
Implementation tour
We start by defining the syntax of formulas. We define variables to be natural numbers but this is not really relevant.
Var : Set
Var = Nat
infixr 20 _β_
data Fm : Set where
_! : Var β Fm
_β_ : Fm β Fm β Fm
β‘ : Fm β Fm
β₯ : Fm
DEFAULT
Now, if we wanted, we could expand our language by defining more function symbols. For instance, we could define negation thus:
Β¬ : Fm β Fm
Β¬ Ο = Ο β β₯
DEFAULT
We could do the same for and any other operator.
Letβs define a data type that represents a proof in Hilbert calculus. As expected, it will have a constructor for each axiom scheme and rule.
infix 0 _β’_
data _β’_ (Ξ£ : List Fm) : Fm β Set where
K1 : β {Ο Ο : Fm} β Ξ£ β’ Ο β (Ο β Ο)
K2 : β {Ο Ο ΞΎ : Fm} β Ξ£ β’ (Ο β (Ο β ΞΎ)) β ((Ο β Ο) β (Ο β ΞΎ))
K3 : β {Ο Ο : Fm} β Ξ£ β’ (Β¬ Ο β Β¬ Ο) β (Ο β Ο)
distribution : {Ο Ο : Fm} β Ξ£ β’ β‘ (Ο β Ο) β (β‘ Ο β β‘ Ο)
necessitation : β {Ο : Fm} β Ξ£ β’ Ο β Ξ£ β’ β‘ Ο
mp : β {Ο Ο : Fm} β Ξ£ β’ (Ο β Ο) β Ξ£ β’ Ο β Ξ£ β’ Ο
premise : β {Ο : Fm} β Member Ο Ξ£ β Ξ£ β’ Ο
DEFAULT
We can now write our first proof. The following proof corresponds to the proof that is a theorem of shown above.
ΟβΟ : β {Ξ£ Ο} β Ξ£ β’ (Ο β Ο)
ΟβΟ {Ξ£} {Ο} = mp (mp (K2 {Ξ£} {Ο} {Ο β Ο} {Ο}) (K1 {Ξ£} {Ο} {Ο β Ο})) (K1 {Ξ£} {Ο} {Ο})
DEFAULT
It is horrible to read and too verbose. We can make it a little more succinct by taking advantage of Agdaβs implicit argument inference:
ΟβΟ : β {Ξ£ Ο} β Ξ£ β’ Ο β Ο
ΟβΟ {Ξ£} {Ο} = mp (mp K2 K1) (K1 {Ο = Ο})
DEFAULT
Much better but still hard for a human to visualize what are the intermediate steps. Computer programmers could think of this as machine code; something very useful for its simplicity but hard for humans to use. So, as we do in programming, we will design a human-friendly language and a compiler that translates nice looking proofs into (letβs call them) primitive Hilbert style proofs. Of course, everything will be guaranteed to work from the types, so no need to execute any code, just type check it (as is always the case when using Agda as a proof checker).
A sketch of the eDSL
We want a language as close to the paper-like syntax showed above as possible. We will refer to proofs in this language as nice proofs and proofs of the type _β’_
as primitive proofs. We will refer to the process of translating nice proofs to primitive proofs as compilation.
First we need to think about the type of a nice proof. Well, it needs to keep track of the set of premises, the formula that entails and the length of it, so the following type is what we want.
data HilbertProof : List Fm β Fm β Nat β Set
DEFAULT
Then the compile function will be of the following type.
compile : β {n Ξ£ Ο} β HilbertProof Ξ£ Ο n β Ξ£ β’ Ο
DEFAULT
We can now think about which constructors we need for a nice proof.
By
. To reference an axiom or another theorem.MP
. To apply modus ponens to previous formulas in the proof.Nec
. To apply necessitation to a previous formula in the proof.
We will refer to these constructors as instructions.
Instructions are numbered with consecutive naturals starting at 0. This number is used to reference its corresponding formula in subsequent steps. There must be a special instruction that does not expect instructions before it. We call this instruction Begin
and it works the same as By
with the exception that it can only be used as the first instruction.
Highlights
We face the following challenges.
- Numbering. We need to make sure that the user labels the instructions properly.
- Proper references. We need to make sure that the numbers referencing other instructions are in range of the size of the proof and that they reference the appropriate formulas.
- Compilation. We need to translate nice proofs into primitive proofs.
Numbering
The numbering certainly looks easier to tackle, so letβs start by this one. Instead of trying to solve our problem directly, I propose an alternative problem that captures the same challenge but with no unnecessary noise around it.
Exercise: Boring list
Define a list type such that the first number must be a zero and each subsequent number must be equal to the head plus one.
Solution
We do so with the help of singleton types.
data SingleNat : Nat β Set where
single : (n : Nat) β SingleNat n
data BoringList : Nat β Set where
[_] : SingleNat 0 β BoringList 0
_β·_ : β {n} β SingleNat (suc n) β BoringList n β BoringList (suc n)
example : BoringList 2
example = single 2 β· (single 1 β· [ single 0 ])
DEFAULT
Well, yeah, this kind of works, but we still have to write single
before each natural number, canβt we do better? Yes, indeed! using literal overloading. If we define a Number
instance for SingleNat
then we will be able to write singleton naturals with plain natural numbers.
instance
NumNatSing : β {n} β Number (SingleNat n)
NumNatSing {n} .Number.Constraint m = n β‘ m
NumNatSing .Number.fromNat m β¦ refl β¦ = single m
DEFAULT
Then the example becomes:
example : BoringList 2
example = 2 β· (1 β· [ 0 ])
DEFAULT
Perfect! thatβs what we needed.
With the previous solution in hand, it is easy to imagine how we can incorporate the same idea into our language.
Proper references
Letβs focus on the case of modus ponens (necessitation works analogously). Assuming the modus ponens instruction entails , it should receive one reference to and another reference to . As we mentioned, these reference will be written as natural numbers by the user, but as we need to make sure that they are in range and reference the correct formulas, we need an extra proof object ensuring that. More precisely, assuming we defined the function lookup-maybe : β {Ξ£ ΞΆ Ο} β HilbertProof Ξ£ ΞΆ Ο β Nat β Maybe Fm
with the expected implementation we would require both proof objects lookup-maybe HP i β‘ just (Ο β Ο)
and lookup-maybe HP j β‘ just Ο
.
But in the case that the references are correct, these proof objects will always be trivially solved by normalization and reflexivity, hence the code would look like this:
[ 3 ] (Ο β (Ο β Ο)) β Ο β Ο ByMP 0 β¨ refl β© , 1 β¨ refl β©
DEFAULT
And we really want to avoid to have the explicit proof object as it makes the syntax unpleasant. Could we use literal overloading for references to previous formulas as well? Yes!
data HilbertRef {Ξ£ Ο n} (H : HilbertProof Ξ£ Ο n) (Ο : Fm) : Set where
ref : (i : Nat) β lookup-may H i β‘ just Ο β HilbertRef H Ο
instance
NumRef : β {Ο Ξ£ n} {H : HilbertProof Ξ£ Ο n} {Ο} β Number (HilbertRef H Ο)
NumRef {Ο} {Ξ£} {n} {H} {Ο} .Number.Constraint i = lookup-may H i β‘ just Ο
NumRef {Ο} {Ξ£} {n} {H} .Number.fromNat i β¦ x β¦ = ref i x
DEFAULT
Now we can write:
[ 3 ] (Ο β (Ο β Ο)) β Ο β Ο ByMP 0 , 1
DEFAULT
And then each proof object is implicitly embedded in the natural literal 0, 1
.
Compilation
It is hard to highlight a specific part over the others for the compilation process. Compilation is mostly about fiddling with types and proof objects to get the desired translation. If you are interested in this part I suggest you look at the code directly.
Conclusion
I hope that you enjoyed the reading. Any comments and suggestions are welcome.
I am not an expert Agda programmer, so in case you look at the full code, take it with a pinch of salt :)