These are the notes accompanying the slides A Motivational Introduction (1_intro.pdf) of the Computational Logic course.
This is the lecture version: it follows the slides in order, but keeps much more of what was actually said in class — the asides, the reasons, the second explanations, and the things that were only ever said out loud. Where the slide gives a bullet, the lecture usually gave a paragraph and an example; those are here.
How to use this interactive document:
The code boxes, marked by a question mark (?) in the right top corner, allow interaction. Feel free to edit any program and make all the queries you want!
This first set of slides is a motivational introduction. The idea is to justify a little why people do logic programming — or even programming and logic, and how the two are related.
That whole area of computer science, or of mathematical logic, is called computational logic, and it is all about the relation between logic and programming. A great many things you may have heard of live there: programming, algorithms, logic, lambda calculus, logic and AI, logic programming, knowledge representation, functional programming, constraints, the logic of programming, declarative programming. All of these are places where logic and programming, or logic and computer science, intertwine.
Logic and AI, for instance, has a lot to do with knowledge representation: representing knowledge and reasoning about it. Logic and programming — logic programming, functional programming, constraints — is about writing programs using logic. And verification is about proving that properties hold of programs, using logic.
Two halves. All of these divide roughly into two classes, and it matters which one this course is about.
The first is the logic of computation: applying logic to computational systems. An example is applying Boolean logic to logical circuits — those of you who come from the hardware world know that digital circuits are modeled with propositional logic, which gives you a mathematics in which to talk about the circuit. That is reasoning about computational objects with logic, and its classical application in computer science is verification: you have a property of a program and you want to prove it.
The second is different. It is not using logic to model computation or to reason about it — it is using logic to write computations, as a programming language. That is the part we are going to see: the direct use of logic as a programming tool.
Consider a simple programming task:
"Compute the squares of the natural numbers that are less than or equal to 5"In our age, AI is capable of programming anything. So ask it, and you get a program:
Now consider this alternative, written by a human programmer:
#include <stdio.h> main() { int number, square; number = 0; while (number <= 5) { square = number * number; printf("%d\n", square); number = number + 1; } }Read it: it sets number to zero; while number is at most five, it multiplies number by itself, puts that in square, prints square, increments, and goes around again.
The two programs are semantically identical except for a small detail. One prints
1 4 9 16 25and the other prints
0 1 4 9 16 25Which one is right? Hold on to the question. We will be able to answer it precisely by the end of the deck, and the answer will not come from staring harder at either program.
Is this program correct? You do not have an answer to that, because you would immediately ask: correct with respect to what? What should it be correct with respect to? What is the specification?
So the first thing you need, in order to know whether a program is correct, is some kind of specification — some formalism that gives a description of the problem. What were we trying to solve? The program certainly does things, but there is no description of what is expected. There might be a comment, perhaps; there isn't one here.
So, two things are needed. First, we have to know what we want the program to do — that is the specification. Second, we want to be able to reason about whether the program corresponds to it. That second thing is the correctness problem, the proof of correctness.
It is not easy to determine correctness. Test it? How many tests? And what are the correct outputs anyway — was 0 an expected output? When you hand a program to a computer and it gives you an answer, is that answer what you expect? That is not easy to know.
Why this went from a minor worry to a major one. At the beginning this was a small concern in computer science, and it has slowly become a very large one, in the sense that nobody wants programs to fail. Initially it was easy to justify only for critical applications: you do not want the program controlling the plane you are flying in to fail, and you certainly do not want the program in the pacemaker in your heart, giving impulses to your heart, to fail. Once deployed there is no debugging and no second chance.
But nowadays people expect programs to work well in completely ordinary applications too. Nobody likes the computer freezing, or the screens of death you used to get. Fortunately that happens less and less — and it happens less thanks to the application of the techniques we are going to discuss, thanks to the fact that correctness has become an important problem.
And because of society's digital transformation, practically all software is critical in practice even when it is not safety-critical: prestige, economic loss, e-commerce, blockchain, banking, online administration, social media.
Software should carry a warranty, like anything else. Programs are not different from other artifacts that humans develop — a screw, a car, anything where you want a warranty, some certification that the thing works and does not do what it is not expected to. This is also expected of software, among other reasons because most objects you buy nowadays, from a cell phone to a car, are full of software. In fact most of the functionality of devices comes from software, not from the hardware. So the device will not work properly if the software does not, and the software has to be certified too.
And consider what certification means elsewhere. When you buy something as simple as a screw, it is certified to many standards: the metal it is made from is made to standards, it is certified to have certain tensile properties and resistance to heat, the thread conforms to a standard. Everything conforms to standards. Software also has to conform to standards.
So this is the problem being addressed. How do you manage to have components, parts of software, with assured quality — such that you are able to give a warranty about the software? Can I get software with some kind of warranty, so that even though I do not trust the person who is sending me the software, I can still trust the software, because there is a warranty or a proof attached? How can I trust software that comes from an untrusted party? These are very important questions, and they are all the program correctness problem.
"Compute the squares of the natural numbers which are less than or equal to 5."It would seem ideal at first sight. But it is verbose, vague, ambiguous, and it needs context — a great deal of assumed information the reader is expected to supply. Philosophers and mathematicians pointed all this out a long time ago.
Notice that our sentence has already failed us once. It is exactly this sentence that leaves open whether 0 belongs in the output.
So a more suitable formalism is needed, for two distinct jobs: to provide specifications, that is, to describe problems; and to reason about the correctness of programs, that is, of their implementations.
Classical logic:
Aristotle likes cookies, and Plato is a friend of anyone who likes cookiesSymbolic logic is a shorthand for classical logic — plus a great many useful results:implies that
Plato is a friend of Aristotle
a₁: likes(aristotle, cookies) a₂: ∀X likes(X, cookies) → friend(plato, X) t₁: friend(plato, aristotle) T[a₁, a₂] ⊢ t₁Which raises the two questions that drive the rest of the deck:
Trial and error?
You run the program on some inputs and look at what comes out. The question mark at the bottom is the point: you learn about the inputs you tried, and nothing about the ones you did not.
Formal proof!
Here the specification and the semantics of the language both feed a proof, and the proof answers yes or no — about all inputs at once. That is the whole reason logic is worth the trouble.
"Compute the squares of the natural numbers that are less than or equal to 5"If we want to say that we generate the squares of the natural numbers, we first have to say what the natural numbers are. So let us formalize the problem starting from scratch — as a game, to specify things fully.
Start from the most basic numbering system in the world, the one where one is |, two is ||, three is |||. That is the most primitive numbering system there is — sticks, marks put one after another — and it is really Peano arithmetic already.
In mathematical logic you first have to invent a symbol for zero. Then one is represented by a zero followed by a stick, written s(0); two by two sticks, s(s(0)); three by three, s(s(s(0))).
0 ≡ 0 1 ≡ s(0) 2 ≡ s(s(0)) 3 ≡ s(s(s(0))) ... 0 ≡ 0 1 ≡ | 2 ≡ || 3 ≡ ||| ...This is just a structure — a piece of mathematical structure, a term in first-order logic that represents the three. And for it you need only one constructor, s, and a zero symbol. With s and 0 you can construct all the numbers. It is not the most efficient representation, but it is the most natural.
nat(0) ∧ nat(s(0)) ∧ nat(s(s(0))) ∧ ...But at some point you are going to write something like "et cetera". Why? Because you cannot write the infinite list. You cannot write all the infinite numbers down.
Which is where Peano's great idea comes in. Instead, write:
Zero is a natural number, and for all X, if X is a natural number then s(X) is also a natural number. Those are the axioms of the natural numbers. In these two rules is contained the infinity of the natural numbers. All of them are defined there.
Facts are obvious: zero is natural, fine. The rule is not obvious, because in that one rule an infinite number of facts is contained. Every fact you would have written in the infinite conjunction — that zero is natural, that one is, that two is — is contained in the rule and follows from it.
The set of all those facts is the model of the two axioms: the infinite set of facts that they imply.
le(0,0) le(0,s(0)) le(0,s(s(0))) ... le(s(0),s(0)) le(s(0),s(s(0))) le(s(0),s(s(s(0)))) ... le(s(s(0)),s(s(0))) le(s(s(0)),s(s(s(0)))) le(s(s(0)),s(s(s(s(0))))) ... ...The whole first row says "0 is less or equal than any natural":
To generalize vertically, look down any column: if x ≤ y then x+1 ≤ y+1, that is
Two formulas, and the infinite table is captured — one for the base row, one for the step that generates each row from the one above.
which needs times, and "X times Y is adding Y to itself X times", so it needs add as well.
Addition, by exactly the same recipe. Write the table, generalize the first row to 0 + x = x, generalize the columns to if x + y = z then (x+1) + y = z+1:
Multiplication goes the same way, with addition doing the work in the step.
In words: X is an output for the program if there exists a natural number Y such that X is the square of Y and Y is less than or equal to 5.
Now read that against the question left open on slide 5. Is 0 an output? nat(0) holds, le(0,5) holds, nat_square(0,0) holds — so yes, on this specification 0 is an output, and the C program is the correct one. The point is not which answer is right. The point is that the question now has an answer, one that can be checked mechanically. That is what a specification buys you.
Go back to the two halves. One is applying logic to computation; the other is using logic to program. What we have been doing in the box above is using logic to prove the correctness of computations. What we actually want to do in this course is different: to see whether we can improve the process of writing programs, with logic. Can we use logic not only to specify — to represent the problem — but also to implement the solution?
All real programming languages are Turing complete and so have the same theoretical expressive power. But that means a program can be written in any of them — not that it is equally compact or equally easy to write in one as in another. It can be done; it is not equally easy. So we want to improve the process, and for that the programming language matters a great deal.
Some relevant context: this was the 1960s, and great techniques and results were being obtained for automated theorem proving.
To answer the question we have to go back in time, to advances of the 1950s and 60s. This slide is Green's dream — Cordell Green, who came up with the idea.
Suppose we had a mechanical proof method, a deduction procedure: something that can make proofs automatically. This was the heyday of automatic theorem proving and resolution, when these methods were being invented and starting to become practical. Green said: if we had that, then we could look at problem solving and computing and programming in a completely different way.
You only wrote the specification, and you make the specification run. You get results directly from the specification. So you would not need a correctness proof at all. Of course the results are correct: they come from the specification. There was never a program in between for them to be wrong about.
Now, this was a dream: I have a problem, I write the specification, I throw it into the computer, I ask questions, and all the answers are correct. To what extent is the dream doable? At the time they were seeing the tip of the iceberg — that automated theorem provers were possible, and that this could be done at least in some cases. So the real question is: when can we do it? That is what the rest of the deck is about.
What a theorem prover is supposed to be, for our purposes: you give it a specification, a piece of logic, and then you ask a question — is this true, is this false, or for which values is it true — and it answers correctly. If it is a complete deduction procedure, it always answers. That is what we are assuming.
Then the following becomes possible.
| If we ask | We should obtain |
|---|---|
| nat(s(0)) ? | yes |
| ∃X add(s(0),s(s(0)),X) ? | X = s(s(s(0))) |
| ∃X add(s(0),X,s(s(s(0)))) ? | X = s(s(0)) |
| ∃X nat(X) ? | X = 0 ∨ X = s(0) ∨ X = s(s(0)) ∨ ... |
| ∃X ∃Y add(X,Y,s(0)) ? | (X = 0 ∧ Y = s(0)) ∨ (X = s(0) ∧ Y = 0) |
| ∃X nat_square(s(s(0)),X) ? | X = s(s(s(s(0)))) |
| ∃X nat_square(X,s(s(s(s(0))))) ? | X = s(s(0)) |
| ∃X ∃Y nat_square(X,Y) ? | (X = 0 ∧ Y = 0) ∨ (X = s(0) ∧ Y = s(0)) ∨ (X = s(s(0)) ∧ Y = s(s(s(s(0))))) ∨ ... |
| ∃X output(X) ? | X = 0 ∨ X = s(0) ∨ X = s⁴(0) ∨ X = s⁹(0) ∨ X = s¹⁶(0) ∨ X = s²⁵(0) |
Go down that table slowly, because each row is doing something different, and together they are the argument of the whole deck.
Row 1: answering something we never wrote. Is one a natural number? Yes. But understand what has happened: I never wrote that one is a natural number. I wrote that zero is, and then I wrote a rule. So what I am asking is a consequence — something in the model, deducible from the two axioms. In this case the deduction is easy: does s(0) match nat(0)? No. Does it match the rule? Yes, if X is 0 — and nat(0) is a fact. So one is a natural number. The system should be able to say yes to something I did not write, because it is a logical consequence of what I did write.
Rows 2 and 3: addition, and why the prover must be constructive. I defined addition with a base case and a general case. In neither of them does it say that 1 + 2 is 3. But it is a logical consequence — it is in the model, in the table we abstracted. So the system should deduce it and produce it as output.
Which tells us something about what kind of prover we want: a constructive one. Not a prover that merely says yes or no, but one that says yes, and here are the values. When I write ∃X in the query, the query is a theorem I want proven — there exists an X which is the result of adding 1 and 2 — and I do not just want to be told that such an X exists. I want to be told that an example of that X is 3.
Row 3 is where it starts to pay. Is there an X which, added to 1, gives 3? Yes, and it is 2. So —
Subtraction is a derivative of addition; it is just putting the arguments differently. It need not be in my axioms at all.
Rows 4 and 5: several answers, and infinitely many. Ask whether there exists a natural number, and the system says: yes — in fact infinitely many. X = 0, X = s(0), X = s(s(0)), and so on, producing them until you stop it. All of them are in the model; all are deducible from the axioms.
Row 5 asks which numbers add to 1, and there are exactly two answers: X = 0, Y = s(0) and X = s(0), Y = 0. Two answers, each giving values for several variables at once.
Rows 6, 7 and 8: squares, and square roots for free. What is the square of 2? Multiply it by itself; the deduction gives 4. And then, exactly as with subtraction, you can also ask: is there a number which, squared, gives 4? Yes — 2. The system is doing square roots, and nobody wrote a square root.
Row 9: the problem we started with. ∃X output(X) — the postcondition of slide 12, asked as a question — yields 0, 1, 4, 9, 16, 25.
Go back to the picture. In the traditional model we write the specs, write a program, and then prove that the program implements the specs. Here: I represent the problem — only the specification — I give it to the deduction system, and I run it. I ask questions and get the answers I want. On this model, writing the specification is sufficient.
It can get very close, though, and getting close is exactly what this course is about.
Does such a proof procedure actually exist? There are many candidates: truth tables, natural deduction, resolution, Prawitz/Bibel, tableaux, bottom-up fixpoint, rewriting, narrowing.
And which logic can we use? The more expressive the logic, the harder it is to have a mechanical procedure for it. That tension is the whole subject of the next two slides.
We would like to maximize expressive power. Compare propositions with first-order formulas. Propositional logic is useful:
"spot is a dog" p "dogs have tails" qbut limited: we can say that p ∧ q is true and still not conclude that Spot has a tail — because nothing in p and q connects the dog in the first to the dogs in the second. They are opaque symbols. Predicate logic extends the expressive power:
dog(spot) ∀X dog(X) → has_tail(X)and now deduction gives us has_tail(spot).
But expressive power is only half of what we need. We also need an effective mechanical proof procedure for whatever logic we choose. Hence the comparison.
Higher-order predicate logic — X(spot), "X is some relationship for spot":
So first-order predicate logic is the sweet spot — enough power to express any program, and a proof procedure that actually works. That is the choice this course rests on.
Code the problem as definite (Horn) clauses:
nat(0) ¬nat(X) ∨ nat(s(X)) ¬nat(X) ∨ add(0, X, X) ¬add(X,Y,Z) ∨ add(s(X),Y,s(Z)) ¬nat(X) ∨ mult(0, X, 0) ¬mult(X,Y,W) ∨ ¬add(W,Y,Z) ∨ mult(s(X),Y,Z) ¬nat(X) ∨ ¬nat(Y) ∨ ¬mult(X,X,Y) ∨ nat_square(X,Y)Query nat(s(0)). To refute, add ¬nat(s(0)):
| resolve | with | unifier | giving |
|---|---|---|---|
| ¬nat(s(0)) | ¬nat(X) ∨ nat(s(X)) | {X/0} | ¬nat(0) |
| ¬nat(0) | nat(0) | {} | □ |
Answer: yes.
Query ∃X ∃Y add(X,Y,s(0)). To refute, add ¬add(X,Y,s(0)):
| resolve | with | unifier | giving |
|---|---|---|---|
| ¬add(X,Y,s(0)) | ¬nat(X) ∨ add(0,X,X) | {X=0, Y=s(0)} | ¬nat(s(0)) |
and ¬nat(s(0)) resolves as before. Answer: X = 0, Y = s(0). Taking the other clause instead gives the alternative solution, X = s(0), Y = 0.
This is the mechanical proof method Green's dream assumed, made concrete — and it is the one that actually works.
The translation is almost nothing. We put the arrows backwards. Instead of writing "∀X, nat(X) implies nat(s(X))", we write it the other way round: the :- is the arrow, pointing right to left. So nat(s(X)) :- nat(X) — read :- as "if" — says s(X) is a natural number if X is a natural number.
The same for the rest. le(0,X) :- nat(X) says 0 is less or equal than any X provided X is a natural number. le(s(X),s(Y)) :- le(X,Y) you can read either way: X+1 ≤ Y+1 if X ≤ Y, or if X ≤ Y then X+1 ≤ Y+1. The commas are the ∧s, and :- is the arrow backwards.
:- module(_,_,[sr/bfall]). % A natural is 0 or s(X) where X is a natural nat(0). nat(s(X)) :- nat(X). % We define less or equal le(0,X) :- nat(X). le(s(X),s(Y)) :- le(X,Y). % Definition of addition add(0,Y,Y) :- nat(Y). add(s(X),Y,s(Z)) :- add(X,Y,Z). % Definition of multiplication mult(0,Y,0) :- nat(Y). mult(s(X),Y,Z) :- add(W,Y,Z), mult(X,Y,W). % Definition of the square of a natural nat_square(X,Y) :- nat(X), nat(Y), mult(X,X,Y). % What we want the output of our program to be output(X) :- nat(Y), le(Y,s(s(s(s(s(0)))))), nat_square(Y,X).
In the lecture this is passed over quickly ("we'll explain what that is; anyway, this thing here, but I'm not explaining that now"), and it is worth flagging as the first appearance of the theme that occupies most of the course: the logic is fine, and the control needs attention.
?- nat(0).Expected answer:
yesNow the more interesting one. This I did not say. I said zero was a natural number, and then gave a rule from which all the others can be deduced:
?- nat(s(0)).Expected answer:
yesThe system is doing logical deductions to answer that.
Addition, forwards:
?- add(s(0), s(s(0)), X).Expected answer:
X = s(s(s(0))) ?And backwards — what is 3 − 1? Or, put as a question about addition: is there an X which, added to 1, gives 3?
?- add(s(0), X, s(s(s(0)))).Expected answer:
X = s(s(0)) ?Subtraction, from a definition of addition.
Several answers. Ask what the naturals are. Typing ; asks for another answer:
?- nat(X).Expected answer:
X = 0 ? ; X = s(0) ? ; X = s(s(0)) ? ; X = s(s(s(0))) ? ;This is an infinite set of solutions, and the system computes them incrementally so you can stop at any point — press Enter instead of ; and it stops.
All the ways of adding to 1:
?- add(X, Y, s(0)).Expected answer:
X = 0, Y = s(0) ? ; X = s(0), Y = 0 ? ; noBoth answers, and then no — meaning there are no more. Those are the only two possibilities.
Squares:
?- nat_square(s(s(0)), X).Expected answer:
X = s(s(s(s(0)))) ?And square roots — is there a number which, squared, gives 4?
?- nat_square(X, s(s(s(s(0))))).Expected answer:
X = s(s(0)) ?Why does that work? Because what we wrote was the definition of squares, on top of the definition of multiplication, on top of the logical definition of addition. Everything we are asking here is the question is this a theorem in the theory we wrote? — and it is. And because the deduction procedure is constructive, it does not merely say yes; it gives values for the variables. So we get examples.
And with both unbound, the squares as pairs:
?- nat_square(X, Y).Expected answer:
X = 0, Y = 0 ? ; X = s(0), Y = s(0) ? ; X = s(s(0)), Y = s(s(s(s(0)))) ? ; X = s(s(s(0))), Y = s(s(s(s(s(s(s(s(s(0))))))))) ? ;And finally, the specification itself, run as a program:
?- output(X).Expected answer:
X = 0 ? ; X = s(0) ? ; X = s(s(s(s(0)))) ? ; X = s(s(s(s(s(s(s(s(s(0))))))))) ? ;0, 1, 4, 9 — the squares. Nobody wrote a program to compute squares. What was written is the postcondition from slide 12, transcribed, and it ran.
And notice the first answer: 0. The specification says nat(Y), le(Y,5), and 0 is a natural not greater than 5, so 0 is an output. That settles the question left open on slide 5 — the human C program was right and the AI's was wrong, and we can now say why, with reference to something other than taste.
Many applications, often in combination with other languages:
Why, though? A quote from the Watson developers is more persuasive than any argument from elegance, because this is a team that had already built the alternative:
"Prior to our decision to use Prolog for this task, we had implemented custom pattern-matching frameworks over parsers. These frameworks ended up replicating some of the features of Prolog but lacked the full feature set of Prolog or the efficiency of a good Prolog implementation. Using Prolog for this task has significantly improved our productivity in developing new pattern-matching rules and has delivered the execution efficiency necessary to be competitive in a Jeopardy! game."Watson is the question-answering system from IBM's DeepQA project. It won first place and $1 million on Jeopardy! against the champions Brad Rutter and Ken Jennings — the first system to beat humans at a genuinely difficult game, not chess but one involving common knowledge and wordplay.— Adam Lally et al., Question analysis: How Watson reads a clue, IBM J. Res. Dev. 56(3)
Note both halves of the quote. They had reinvented Prolog badly — and then found the real thing was also faster. The productivity argument and the efficiency argument point the same way, which is not what people usually expect of a declarative language.
The 1970s: the procedural interpretation.
Two people, Bob Kowalski and Alain Colmerauer, came up with the idea of giving a procedural interpretation to Horn clause logic.
That is, to look at something like the definition of multiplication — two lines of logic — and say: I see a procedure. I see something that runs. I see an algorithm in there. We will learn to do exactly that: to look at such a definition and make it run, and watch how it runs. In the end you just run a debugger, and it is not that different from programming in C. But there is a big mental step from looking at two phrases of logic to deducing that what you are looking at is a set of instructions.
The trick, when you see a clause A :- B₁, ..., Bₙ, is to read it as: to solve A, to execute A, execute B₁ and then B₂ and then ... and then Bₙ. Look at the phrases of logic as procedures.
Which gives Kowalski's equation:
Look at a clause. It is a piece of logic that states a relation: A is true if B is true, and so on. But if you read it left to right, that is the control; if you give it an order, the same thing is also a set of instructions. That duality is what makes it interesting — and beautiful.
Everything in this course that looks like a performance trick is really a change of control leaving the logic alone; the sr/bfall on slide 22 was the first example.
And the rest is history. Colmerauer, in France, built the first practical system and gave it the name Prolog, from Programmation et Logique. The interpreter was written in Fortran.
Then David Warren wrote the first Prolog compiler. Up to that point this was a great idea that ran extremely slowly. Warren's compiler was itself written in Prolog, which was astonishing — nobody thought you could write a compiler in such a language — and it turned out to be as fast as Lisp, at the time the fastest and most mature symbolic language. That was a small revolution, because it made the whole thing practical.
The 1980s and 1990s.