Unification is probably one of the most important concepts in computer science. If you are not aware, unification is basically the process you undergo to find out how two syntactic objects like and can be the same thing. In this case, you replace with . Note that you don’t replace with , as is less general than , since contains the literal .
Daniel created a language called Bergamot that allows you to write expressions and unify them, while being able to see a trace of the unification process. Prolog is a very popular language that is based on unification, and belongs to the class of Logic Programming Languages. There are several projects that implement unification algorithms and give some sort of trace of the process. Though, Daniel’s Bergamot is distinguished among these, as Bergamot produces a specific kind of trace called a proof tree.
Proof trees can come in different forms. The kind of proof trees that Bergamot outputs are widely used in Programming Language Theory, and are the kind that I am interested in. They are generated by recursive application of inference rules (see Bergamot post or my post) of the form
Proof trees are great explanations. But how do you make them? By hand, it is usually not so hard, since humans are generally pretty good at unification. But how does a computer do it? More precisely, how do you make a program that looks at a bunch of rules, along with a conclusion, and produces a proof tree?
While the unification process has been formalized in many places, the process of proof tree construction has not. I really want to formally describe the proof tree generation process in its entirety, but in order for it to make any sense, I need to tell you about how unification works. Here I will try to give a formal(ish) intro to unification. Then later, I will tell you about how you can make proof trees.
Unification
Two terms and are unifiable if we can replace shared variables of and with certain terms, resulting in two equal terms. This replacement information is encoded in an object called a substitution, which is denoted by (and sometimes ). You can think of substitutions as a dictionary that maps variables to terms. The result of replacing ’s variables with their coresponding terms in , is denoted by . Then, if is the case, then is called a unifier for and . In other words, unifies and . Now, to improve the initial definition, two terms and are said to be unifiable if they have a unifier.
The formalization starts with the language of terms for which we are unifying. Terms can be one of two things, (i) a variable , or (ii) a construct of zero or more terms , with the constructor . The language is defined by the grammar
where , , and . It may seem odd that constructors are just names, but this is what allows us to represent an expression from any language as a term.
Consider our and example from above. We can write as , where and . Then for , we have , where and the constructors . For the sake of aesthetics, constructed terms with zero arguments will be written without parenthesis. That is, if , then is written as . In summary,
where and .
When a variable is used in a term , it represents an abstraction over terms that can exist in place of . Another view that I like, is that terms which have one or more variables act as templates for all other terms that may be unified. The set of variables of a term is called the free variables of , and is denoted by . The function from terms to sets of variables can easily be defined,
A term is said to be closed if does not have any variables, that is free.
Substitutions
Substitutions are objects that carry information about replacing variables with terms. Substitutions can be represented in several different ways (we will pick one later), but we can describe them generally by certain rules they must follow.
The set of substitutions is denoted by Subst. Along with the set Subst, we must have two operations,
a method for applying a substitution Subst to a term , by replacing every occurance of a free variable of with its coresponding term mapped by , resulting in the term Term,
and a function SubstSubstSubst, which is way to compose any two substitutions Subst, resulting in a new substitution Subst by applying first, then . (it is convention to write to mean )
Composing substitutions must obey certain rules, that is, for all Term and Subst,
meaning that applying to , then applying is the same as applying to . Substitutions must also distribute through constructed terms, that is,
Partial Functions
Suppose and are sets and is a relation on and . The domain of is the set
dom
A relation is a partial function from to if dom and is a function from to , that is, . When is a partial function, we write .
Partial functions are one such representation for substitutions. We will consider substitutions to be partial functions from variables to terms,
SubstVarTermVarTerm
The operation for applying a substitution (partial function) Subst to a term Term, is defined in a pretty straightforward way,
defif Var and domif otherwise
which means every variable free will be replaced by the corresponding term , provided is a key in , that is dom. Otherwise, will remain unchanged in if dom.
As an example, consider the substitution Subst, and then applying to the term ,
Now we must define the operation SubstSubstSubst that composes substitutions Subst. The substitution Subst is defined by
defif domif domdom
for all domdom. Alternatively, we could have used an equivalent definition to the one above,
defdomdom
To see how this works, consider the two substitutions,
where is written as . We then have
and so
If you like, here is an implementation of in Python,
Unification in general is a nondeterministic process. Two terms and can be unified in many different ways, as in, there may be more than one unifier such that unifies and , that is . Thankfully, there is a deterministic process to find a unifier with minimal effort.
The statement that Term are unified by Subst is written . This can be formalized as a relation on pairs of terms and substitutions, TermTermSubst, and can be describd using inference rules.
Let’s start off with the most basic rule,
UnifySame
which says that the empty substitution Subst unifies and if . This is obvious since if two terms are the same, there is nothing to substitute to make them the same. The next two rules are applied when one of the terms in question is a variable,
freeUnifyVarVarfreeUnifyVar
The UnifyVar rule says that a variable is unified with any term by the substitution that maps to , provided that is not used somewhere in . The right version of this rule is the same, except that cannot be a variable.
This restriction prevents circularity. With this, unification for variables is covered. For constructed terms, we have the rule
UnifyCons
which unifies two constructed terms, composing the substitutions generated by their arguments. With this, we need the rule for constructed terms with no arguments,
UnifyCons
which acts like the base case for unifying constructed terms.
These rules describe a process for finding the most minimal unifier for two terms. The relation described is in fact a partial function from TermTerm to Subst. To see it this way, we will define the operation to mean
and thus,
TermTermSubst is partial because two terms may not be unifiable. By adding an error value to the codomain Subst, we can add rules that turn TermTermSubst into a function, which means
TermTermSubst
and we can know if two terms cannot be unified.
If free, then cannot be unified with , as no simultaneous substitution would yield the same two terms. For this, we have the rules,
freeFailCircularfreeFailCircular
Unification should also not be possible for constructed terms that either have two different constructors Name, or have a different number of arguments,
FailDiffConsFailDiffArgs
If an arguement fails, then the rest should fail,
FailArg
Lastly, failure should propagate up through constructed terms,
FailProp
This last rule relies on FailDiffCons and FailDiffArgs, and ties error checking for constructed terms all together.