☰

A "Hands-on" Introduction to
Pure Logic Programming (Pure Prolog)

Course Notes (lecture version)


These are the notes accompanying the slides A "Hands-on" Introduction to Pure Logic Programming (2_logic_programming.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.

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!

  • Code needs to be loaded before running, by pressing on the question mark — code box should be checked ✔.
  • Under each program there are editable boxes with a right-pointing triangle (▶) that allow interaction with the code (once loaded!) by issuing queries.
  • Expected answers are printed in plain text underneath each query box, so you can read the notes straight through without running anything.
  • When available, pressing the northeast pointing arrow (↗) will load the code in a separate Prolog playground window.

Syntax

This is a hands-on introduction to pure logic programming. There are many ways to start; we take the most direct one — give the syntax, explain the operational semantics in the shortest way possible, and get to programming as soon as we can.

Slide 2 — Terms: variables, constants and structures

We use the Prolog notation conventions, which are used by essentially all logic programming systems.

Variables start with an uppercase character or an underscore, and may contain underscores and digits: X, Im4u, A_little_garden, _, _x, _22.

This is the main thing to get into your head: if it starts with a capital, it is a variable. If it starts with a small letter, it is a constant.

If you want a variable whose name would otherwise start with a small letter, put an underscore in front — _x. And _ on its own is a special one: the anonymous variable. You may have met it in other languages; a lot of them have it, and it came originally from Prolog. It is a variable that is different from every other variable. Use it when you need a variable in a position but it will not share with anything else and you do not want to name it.

Constants have a lowercase first character and may contain underscores: a, dog, a_big_cat. Numbers are constants too — 23, and floating point numbers later. If you want a constant that starts with a capital, you cannot write it directly, because that would be a variable; you quote it: 'Hungry man'.

And a great many symbols are constants as well. Pretty much anything that does not start with an underscore or a capital is a constant. For example [], the empty list, is a constant — no different from a, just two characters.

Structures are a functor — the structure name, which looks like a constant — followed by a fixed number of arguments in parentheses, separated by commas:

date(monday, Month, 1994)
The functor is date and there are three arguments: monday, Month, 1994. Think of it as a piece of memory that starts with date and then holds three things. The arguments in turn can be variables, constants, or again structures — so these nest as deeply as you like.

Arity is the number of arguments. date(monday, Month, 1994) has arity 3. Functors are usually written name/arity, so this one is date/3; the arity matters in many contexts.

And now a nice consequence: you can think of constants as functors of arity zero, with no arguments. That blends the two ideas rather elegantly — a constant is just a structure that happens to have no arguments. So the constant a is the functor a/0.

Slide 3 — More on terms

Variables, constants and structures are, taken together, called terms. They are the terms of a first-order language — the same thing as in logic; in logic there is a thing called terms, and this is it. But they are also the data structures of the logic program. Every data structure you use in a logic program is a term, and there is nothing else. Variables, constants, structures. With that you build everything: the numbers, the lists, absolutely everything.

Some examples, and one that is not allowed:

term what it is
dada constant, i.e. the functor dad/0
time(min, sec)a structure, functor time/2
tiger(Hobbes)a structure, functor tiger/1
Tee(Alf, rob)ILLEGAL
A_good_timea variable

Why is Tee(Alf, rob) illegal? Because the functor starts with a capital letter, so it would be a variable — and a variable in functor position takes us out of first-order logic into higher-order logic. (In Ciao you actually can do that, because Ciao supports higher order; but that is a different story, and it comes much later.)

Operators

One thing you can do — and we will not spend time on how just yet — is declare a functor to be a prefix, postfix or infix operator. That lets you drop the parentheses.

Take +. It is a constant, so it can be a functor, +/2. Declared as an infix operator, you may write

a + b        which is exactly the term        '+'(a,b)
The quotes may or may not be needed depending on context. Likewise, if - is declared prefix then - b is '-'(b); if < is declared infix then a < b is '<'(a,b). And if you declared father infix, you could write john father mary for father(john, mary).

The operator only changes how the term is written and read. It is worth proving this to yourself at the top level rather than taking it on faith.

?- X = a+b.
Expected answer:

X = a+b ?
Now — is a a constant or a variable? A constant. And a+b is simply the structure a+b. Watch:

?- X = +(a,b).
Expected answer:

X = a+b ?
The same answer. You typed the canonical form and the system printed the operator form, because + is declared infix and printing uses the operator declarations to make the output easier to read.

The same thing with write/1, which is perhaps clearer because no variable is involved:

?- write(+(a,b)), nl.
Expected answer:

a+b
?- write(foo(a,b)), nl.
Expected answer:

foo(a,b)
foo is not declared as an operator, so it is printed the ordinary way. And reading works the same way round — write(a+b) is fine, but:

?- write(a foo b).
Expected answer:

{SYNTAX ERROR: (lns 2-1) , or ) expected in arguments
write( a
** here **
foo b ) .
}
A syntax error, exactly where foo appears, because foo is not an infix operator.

We will assume from here on that the usual operator definitions — +, - and the rest of the standard ones — are always preloaded.

That is the whole of the syntax of data: understanding the data structures of the language and how we write them down. It is not a big deal, but it is worth understanding perfectly.

Slide 4 — Rules and facts (clauses)

Now the program part. Programs are made of rules and facts.

A rule has this shape:

p₀(t₁, ..., tₙ) :- p₁(...), p₂(...), ..., pₘ(...).
The :- is an arrow backwards. Everything in it looks like a term: p₀ is a functor — though with a special name, a predicate symbol — and the ts are its arguments, which may be variables, constants or structures. The predicate symbols themselves cannot be variables; they have to be constants.

The vocabulary:

  • p₀(t₁, ..., tₙ) is the head of the rule.
  • Each pᵢ(...) to the right of the arrow is a literal.
  • The set of all those literals is the body.
  • The :- is the neck.
And here is where we start connecting with programming: those literals are also called procedure calls. Literal is the name in logic, procedure call the name in programming — and we will use both nomenclatures throughout, because the whole subject lives in the overlap.

A fact is a special kind of rule: one with an empty body. When the body is empty we do not write the arrow either — we could write it with nothing after it, but that is a waste of time.

meal(soup, beef, coffee).                    % a fact

meal(First, Second, Third) :-                % a rule
    appetizer(First),
    main_dish(Second),
    dessert(Third).
Facts are things that are true. Notice that each of these looks like a structure — like a term.

Facts and rules together are called clauses. The piece of program above has two clauses: a fact and a rule.

Slide 5 — Predicates, programs and queries

A predicate — a procedure definition, in programming terms — is a set of clauses whose heads have the same name and arity.

pet(X) :- animal(X), barks(X).
pet(X) :- animal(X), meows(X).
pet(X) :- animal(X), roars(X), small(X).

animal(tim).
animal(spot).
animal(hobbes).
How many predicates are there here? The tempting answer is three, and it is wrong: there are two. The first three clauses all have predicate name pet and arity one, so they are one predicate. The last three are facts — but facts are clauses too — with predicate name animal and arity one, so they are a second.

Two predicates means two procedures, like f and g in an imperative language. pet/1 is a procedure with three cases; it is a bit like a case statement, but it is a procedure, and you call it by name with arguments, exactly as you would elsewhere.

One thing that catches people: a different number of arguments is a different procedure. foo/1 and foo/2 are two unrelated predicates that happen to share a name.

A program is a set of predicates. The program above is one program, composed of two predicates, each of which has three clauses — pet/1 with one fact and two rules if you fill in the third clause differently, animal/1 with three facts.

This terminology is worth learning properly, because it is used constantly from here on.

Queries

The last element. A query is a clause with no head — just the arrow and a body:

:- pet(X).
It is a question put to the program. Most systems write it with a question mark, ?-, to make that even clearer — and that is what the Ciao prompt is:

?- pet(X).
That prompt is not arbitrary. It is not a $ or a > chosen because it looked good; it has a logical meaning. You are asking a question by writing a body with no head, and a body without a head is something to be proved.

For those who have seen resolution theorem proving: to prove something you first negate it and then derive a contradiction. The negation is why the goal sits on that side of the arrow. The question mark is the negated conclusion.

Slide 6 — The declarative meaning of facts and rules

Now: what does all this mean? You write a program like the one above — what does it say?

It means two things, and here is something we will meet all the time: there is the logical view and the procedural or program view. This is at the same time a piece of logic and a program that runs. From the point of view of logic, what it says is called the declarative meaning.

Facts state things that are true. Remember that a fact has an empty body, and an empty body is like putting true there — it is the same thing. true is true; you do not have to prove it. So a fact says p if nothing: it is true by itself, with no conditions. When we write animal(spot) we are saying "spot is an animal".

In rules, the commas are conjunction, and :- is classical implication. So

p :- p₁, ..., pₘ.        means        p ← p₁ ∧ ... ∧ pₘ
If p₁ and p₂ and ... and pₘ are true, then p is true. Or, reading it backwards, p is true if all of them are.

So pet(X) :- animal(X), barks(X). reads as "X is a pet if it is an animal and it barks". That is exactly what it means from the logical point of view.

Variable scope, which is important and easy to get wrong. Variables are local to clauses — exactly as variables in an imperative language are local to procedures. In

pet(X) :- animal(X), barks(X).
pet(X) :- animal(X), meows(X).
the X in the first clause is not the same X as in the second. They are two different variables that happen to be written the same way.

And every time a clause is used, you can imagine getting fresh copies of its variables — just as calling a procedure gives you a new activation record with fresh locals. Those who have seen resolution before will recognize this as the renaming you apply to a clause before using it, so that the variables of one application do not get confused with those of another.

Slide 7 — The declarative meaning of predicates and queries

A predicate is a set of facts and rules, and what those different clauses do is provide different ways to define p — different alternatives. So the clauses of a predicate are read as a disjunction: p holds if the first clause's body holds, or the second's, or the third's.

X is a pet if it is an animal and it barks; or X is a pet if it is an animal and it meows. Two alternative ways of being a pet.

And the same reading applies to a table of facts. animal(tim). animal(spot). animal(hobbes). says that there are three ways of being an animal — or, equivalently, that there are three individuals who are animals. Alternatives, again.

A query asks whether something follows from the program — and, if it does, for which values of its variables.

Slide 8 — "Execution" and semantics

Here is a complete logic program. Two ways of being a pet, three animals, and three noises:

:- module(_,_,[]).

pet(X) :- animal(X), barks(X).
pet(X) :- animal(X), meows(X).

animal(tim).
animal(spot).
animal(hobbes).

barks(spot).
meows(tim).
roars(hobbes).
Executing a logic program, given a program and a query, is attempting to find an answer to the query, and giving values for the variables in it.

So if we ask pet(X) — does there exist an X that is a pet? who is a pet? — the system tries to find a substitution for X that makes pet(X) true.

Just by looking, intuitively: an animal that barks — animal(spot) and barks(spot), so spot is certainly an answer. An animal that meows — animal(tim) and meows(tim), so tim is another.

Notice what just happened: the declarative semantics already told us the solutions. You did not have to run the program. You only had to look at what the logic implies — at the model.

The operational semantics is the other question: how does the system compute those answers? How does the program run? That distinction — the obvious logical semantics, and the mechanism that computes it — is one we will keep returning to.

The program running

?- barks(X).
Expected answer:

X = spot ? ;
no
spot barks, and nobody else does — the no means there are no more answers.

?- animal(X).
Expected answer:

X = tim ? ;
X = spot ? ;
X = hobbes ? ;
no
Three animals and no more. And now the interesting one — asking about a particular individual rather than for all of them:

?- pet(tim).
Expected answer:

yes
?- pet(hobbes).
Expected answer:

no
?- pet(X).
Expected answer:

X = spot ? ;
X = tim ? ;
no
spot is a pet and tim is a pet, and furthermore nobody else is — exactly what we worked out by reading the logic. That is the program running.

Notice what just happened. The declarative semantics already told us the answers: we did not have to run anything, we only had to look at what the logic implies — what the model of the program is. The operational semantics is a different question: how does the system compute those answers?

Keeping those two apart is the whole discipline. From here on, every topic has both readings.

hobbes roars, and there is no clause saying that an animal that roars is a pet, so hobbes is not one. Add pet(X) :- animal(X), roars(X). to the program above and re-run the query to see the third answer appear.

Slides 9–10 — Running programs, and the top-down operational meaning

Loading a program. Inside Emacs you open the file and load it into a top level. Without Emacs — though learning Emacs is strongly encouraged — you can load a file called 2examples_bfall.pl by typing at the prompt

?- use_module('2examples_bfall').
and then ask your questions. It is no more complicated than that. There is a separate set of slides, Developing Programs with a Logic Programming System, which covers this properly, and it is worth going through at about this point.

Note the declaration at the top of that file:

:- module(_,_,['sr/bfall']).
That prefix matters: it puts the system into pure logic programming mode, which is what this whole part of the course is about. What it does exactly is explained later.

The operational semantics is resolution, which we now describe informally in a form you can follow by hand.

Given a query, take the leftmost literal — a procedure call — and look for a clause whose head matches it. Matching here is not equality: it is unification, which is the subject of the next four slides. If a matching clause is found, replace the call by that clause's body, with the matching substitution applied, and carry on. When nothing is left to prove, the query has succeeded, and the accumulated substitution is the answer.

If a call has several matching clauses, that is a choice point: the alternatives are tried in turn. If a call has no matching clause, we backtrack to the most recent choice point and take the next alternative there.

That is the whole mechanism. What makes it work as programming rather than as proof search is what the next slides are about.

Unification

Slide 11 — Unification: one operation with many uses

We have to know what unification is, because it is doing far more work than it looks.

Unification is the mechanism used to pass parameters, return values, access parts of data structures, give values to variables, create data structures and "modify" them.

That is one operation covering what most languages need half a dozen mechanisms for. And it is one procedure to solve equations on data structures.

Take two equations: X = f(Y) and Y = a. They say X must equal f(Y) and Y must equal a. Unification solves that system: Y is a, which we knew, and for the whole thing to hold together X has to be f(a).

?- X = f(Y), Y = a.
Expected answer:

X = f(a),
Y = a ?
Another one. X = f(Y) and X = f(b) can only be solved by making Y be b:

?- X = f(Y), X = f(b).
Expected answer:

X = f(b),
Y = b ?
That = is unification. It is what does all the work.

Like many equation-solving procedures, it works by isolating variables and instantiating them with their values.

Slide 12 — Unification, more formally

The objective of unifying two terms or literals A and B is to ask whether they can be made syntactically identical by giving minimal values to their variables.

Said again: given two terms A and B, how can we make them identical by giving values to the variables in them?

More formally, we want to find a variable substitution — X = a is a variable substitution, and a substitution in general is a set of them — a θ such that applying θ to A and applying θ to B makes the two equal. Or, if no such substitution exists, to fail: to say that this is impossible.

There are two rules in the process:

  1. You can only give values to variables. You cannot change a constant: a is a. But X, being a variable, can become b or c or whatever is needed.
  2. Structures can only be made identical by making their arguments identical — which is obvious once said.
Some examples, with the substitution θ that unification finds:

A B θ Aθ = Bθ
dogdog{}dog
Xa{X = a}a
XY{X = Y}Y
f(X, g(t))f(m(h), g(M)){X = m(h), M = t}f(m(h), g(t))

The first is already identical, so nothing has to be done — the substitution is empty, and applying nothing to either side gives dog. The second gives X the value a; B was already a and stays put, and A, which was X, becomes a. The third makes one variable be the other — a bit trickier, since the variables are confusing at first, but you get the hang of it.

The fourth is the first genuinely non-trivial one, and worth walking through slowly. Start on the left: f against f, fine. First argument: X against m(h). These will never be identical unless X is m(h), so that is the first substitution. Continue: g against g, fine. Inside them, t against M — and since we may replace a variable by a constant or a structure, and nothing else, we set M = t.

Now apply the substitution to both sides and check. A becomes f(m(h), g(t)). B becomes f(m(h), g(t)). Identical. It worked.

?- f(X, g(t)) = f(m(h), g(M)).
Expected answer:

M = t,
X = m(h) ?

When it does not work

Different functors. Change one letter in the example — f(m(h), t(M)) instead of f(m(h), g(M)) — and follow the same steps. f against f, fine. X against m(h), fine. And then g against t. Can we do anything? No. There is no way to make a t be a g or a g be a t; only variables may be substituted. So the two terms are impossible to unify.

?- f(X, g(t)) = f(m(h), t(M)).
Expected answer:

no
Structures with a different name and/or arity cannot be unified. That is the first way unification fails.

And a second, quite different way. Try to unify f(X,X) with f(Y,l(Y)). First arguments: X against Y, so X = Y, fine. Second arguments: X against l(Y) — but X is already Y, so what we are really being asked for is

Y = l(Y)
which means Y = l(Y) = l(l(Y)) = l(l(l(Y))) = ... — an infinite term.

This case has a name: the occurs check. In classical first-order logic it is not allowed. In modern logic programming systems it often is, and what you get is a cyclic term: a data structure containing a pointer back into itself. Ciao allows it, and so do many current Prologs — but it is a question you have to ask of any system you use, because classical logic says no. Slide 15 comes back to this.

Slide 13 — The most general unifier

When a unifier exists there may be many. X = Y could be solved by {X = Y}, but also by {X = a, Y = a}, or {X = f(b), Y = f(b)}, and so on.

Take the pair from the previous slide, f(X, g(t)) against f(m(H), g(M)). One solution is {X = m(a), H = a, M = t}: apply it and both sides become f(m(a), g(t)). Identical — so it is a correct unifier. But look at what it did: it invented a value for H that nothing in the problem required.

The other solution is {X = m(H), M = t}, which leaves H alone entirely. Both make the terms equal; they are quite different substitutions.

All correct unifiers are correct, but we want the most general unifier — the one that does the minimum substitution. Where there are two variables, bind one to the other; where there is a term, bind the variable to that term; and invent nothing.

That is what "minimal values" meant on the previous slide, and it matters, because committing early to a value that was not forced would lose solutions.

Slide 14 — The unification algorithm

The algorithm walks the two terms in parallel. Constants must be identical. A variable is bound to whatever it meets. Two structures must have the same functor and arity, and are then unified argument by argument. Anything else fails.

Slide 15 — The unification algorithm: worked examples

Five unifications to work through by hand first, following the algorithm, and then check.

?- p(X,f(b)) = p(a,Y).
Expected answer:

X = a,
Y = f(b) ?
Straightforward: X meets a, and Y meets f(b).

?- p(X,f(Y)) = p(a,g(b)).
Expected answer:

no
X = a is fine, but then f(Y) meets g(b): different functors, so nothing can make them identical. Fail.

?- p(X,X) = p(f(Z),f(W)).
Expected answer:

X = f(W),
Z = W ?
Here X occurs twice. Unifying the first arguments makes X be f(Z); unifying the second then forces f(Z) and f(W) to be identical, hence Z = W. The answer is a constraint between Z and W — neither of them has a value.

?- p(X,f(Y)) = p(Z,X).
Expected answer:

X = f(Y),
Z = f(Y) ?
X = Z from the first argument; then f(Y) meets X, which is Z, so both become f(Y).

?- p(X,f(X)) = p(Z,Z).
Expected answer:

X = f(X),
Z = f(X) ?
The last one is the interesting one. X = Z from the first argument; then f(X) meets Z, i.e. f(Z) meets Z. Look at X = f(X): there is no finite term satisfying it — it would have to be f(f(f(...))) forever. What Prolog has built is a cyclic term.

The mathematically correct answer here is fail, and the test that would catch it is the occurs check: before binding a variable to a term, check that the variable does not occur inside that term.

Standard Prolog omits it, because it costs time on every single unification and almost never matters. It is available when you want it:

:- module(_,_,[]).
:- use_module(library(iso_misc)).

check(X, Z) :- unify_with_occurs_check(p(X,f(X)), p(Z,Z)).
?- check(X, Z).
Expected answer:

no
Which is the answer logic wants. Can you explain the difference?

Execution, search, and control

Slide 16 — A schematic interpreter (SLD-resolution)

SLD resolution is the operational semantics of logic programs — it is how they run, and you need to know how it works.

Input and output. The input is a logic program P and a query Q. The output is Qμ, where μ is an answer substitution — values for the variables that appear in the query, which Prolog prints as A = something, B = something. You get that if Q is provable from P, that is, if Q is a logical consequence of P. Otherwise you get failure, which may be a finite failure or an infinite one.

The algorithm. There is one intermediate data structure, called the resolvent, R. Initialize it to the query Q. Then, while R is not empty:

  1. Take the leftmost literal in R; call it A.
  2. Choose a clause from the program whose head unifies with A. That is where the unification algorithm of the previous slides is used: we need a substitution θ making the head and A identical.
  3. When we take a clause we are careful to rename it — fresh names for its variables each time. If the clause has a variable X, it becomes X1 the first time we use the clause, X2 the second, and so on. This is so that repeated applications, especially of recursive clauses, do not get confused with each other.
  4. Replace A in R by the body of the chosen clause, and apply θ to the whole resolvent.
If at step 2 no clause unifies, that branch fails, and we must go back and try a different choice.

When R becomes empty, we have a proof, and the composition of all the θs, restricted to the query's variables, is the answer.

Two choices are hidden in that algorithm, and they have names:

  • which literal to take from the resolvent — the computation rule;
  • which matching clause to try — the search rule.
Step 1 fixes the computation rule as "leftmost", which is Prolog's. The search rule is what the next several slides are about, and it is where the interesting differences live.

Notice how simple this deduction procedure is. It has exactly one rule — the resolution rule — of taking a clause, putting its body into the resolvent, and applying the substitution. That one step is all we need to prove any theorem.

The algorithm, run by hand

The only way to make this concrete is to do it. Take the pets program, with the clauses numbered:

C1: pet(X) :- animal(X), barks(X).
C2: pet(X) :- animal(X), meows(X).
C3: animal(spot).      C6: barks(spot).
C4: animal(tim).       C7: meows(tim).
C5: animal(hobbes).    C8: roars(hobbes).
and the query ?- pet(P).

Start. R = pet(P), Q = pet(P).

Step 1. There is only one literal in R, so no choice about which to take. Look for a clause whose head matches pet(P): both C1 and C2 do, so this is a choice point — mark it with a star. Say we take C2. Rename it first, since this is our first use: pet(X1) :- animal(X1), meows(X1). Unifying pet(P) with pet(X1) gives θ = {P = X1}. Apply θ to the query, giving pet(X1), and replace the selected literal by the clause body:

Q = pet(X1)        R = animal(X1), meows(X1)        C2*   {P = X1}
Step 2. Leftmost literal is animal(X1). Three clauses match — another choice point. Take C4, animal(tim). It has no variables, so no renaming is needed. θ = {X1 = tim}. It is a fact, so its body is empty and nothing is added; the literal simply disappears:

Q = pet(tim)       R = meows(tim)                   C4*   {X1 = tim}
This is how the resolvent changes size. Matching a rule makes it bigger — the body goes in. Matching a fact makes it smaller — the literal disappears and nothing replaces it. A proof finishes when the shrinking wins.

Step 3. meows(tim) matches only C7, so there is no choice point here — no star. The two are already identical, so θ is empty, and C7 is a fact:

Q = pet(tim)       R = (empty)                      C7    {}
R is empty. We have proved the query, and the answer is P = tim.

That is resolution, done by hand. And it is exactly what the system does:

:- module(_,_,[]).

pet(X) :- animal(X), barks(X).
pet(X) :- animal(X), meows(X).

animal(tim).
animal(spot).
animal(hobbes).

barks(spot).
meows(tim).
roars(hobbes).
?- pet(P).
Expected answer:

P = spot ? ;
P = tim ? ;
no
The system's first answer is spot, not tim — it made a different choice at the starred points. Typing ; sends it back to the most recent star to take the next alternative, and the second answer is the one we found by hand.

Slide 17 — The choices made in standard Prolog

The schematic interpreter left two things open: which literal to take from the resolvent, and which matching clause to use. A real system has to decide. Standard Prolog makes the simplest possible choice in each case — always the first one — and that is the whole of its execution model:

Input: a logic program P, a query Q. Output: μ, an answer substitution, if Q is provable from P; failure otherwise.

  1. Make a copy Q' of Q.
  2. Initialize the resolvent R to { Q }.
  3. While R is non-empty:
    1. Take the leftmost literal A in R.
    2. Take the first clause A' :- B₁, …, Bₙ (renamed) from P whose head has the same predicate symbol as A.
      • If there is a solution θ to A = A' (unification): replace A in R by B₁, …, Bₙ, and apply θ to both R and Q.
      • Otherwise take the next clause and repeat.
    3. If there are no more clauses, go back to the most recent pending choice.
    4. If there are no pending choices left, output failure.
  4. When R is empty, output the solution μ to Q = Q'.
  5. On request, explore the most recent pending branch for more solutions.
Three words carry all the weight: leftmost, first, and most recent. The first fixes the computation rule, the second and third fix the search rule to depth-first with backtracking. Change any of them and you have a different system — which is exactly what sr/bfall does.

Slides 19–21 — Alternative execution paths, and the "normal programming" view

Running the same query can proceed in more than one way, because at each call there may be several clauses whose heads unify. Following different choices gives different execution paths — some reaching a solution, some failing.

The slides trace two of them side by side for ?- pet(X)., showing at each step the query, the resolvent, the clause chosen and the substitution. The first trace takes clause animal(tim) and reaches barks(tim), which has no matching clause: failure.

The starred entries in those tables are the choice points — the calls where other clauses were still applicable. Failure is not the end of the story: we go back to the last choice point and try the next alternative. Taking animal(spot) instead, barks(spot) does match, and the query succeeds with X = spot.

Typing ; at the ? prompt asks the system to go back and explore yet another branch.

Continuations — where backtracking actually lives. This is worth knowing, because it explains why Prolog is not slow.

When you call a procedure in any language, you push an activation record — at minimum a return address — onto a stack. That is the forward continuation: when I finish, where do I go? In a clause p :- q, r. the forward continuation of the call to q is "the point just before r".

Prolog has that, and it has a second one. Suppose p has another clause below this one. Then while we are inside q there are two ways out: succeed, and go forward to r; or fail, and go back to p's next clause. So on entering a procedure that has alternatives, Prolog also pushes a backward continuation onto a different stack — the choice-point stack. That, plus a few details, is all backtracking is.

So an imperative program transliterated into Prolog pushes only onto the return stack, exactly like its imperative original. You pay for search only when you do search.

This was David Warren's insight when he built the first Prolog compiler. Until then, implementations represented proof trees and resolution trees explicitly — faithful to the theorem-proving theory, and enormously slow. Warren saw that this is no different from C, except that every now and then it backtracks, and reused the whole machinery of imperative language implementation, adding only what was needed for backtracking. That is why Prolog is a programming language and not a theorem prover.

Slide 22 — Continuations, and why Prolog is not slow

This slide is worth dwelling on, because it explains why a logic programming system can be as fast as a conventional one.

When you call a procedure in any language you push an activation record — at minimum a return address. That is the forward continuation: when I finish, where do I go? In

p(X,Y) :- q(X,Z), r(Z,Y).
the forward continuation of the call to q is "the point just before r".

Prolog has that, and a second one. If p has another clause below this one, then while we are inside q there are two ways out: succeed and go forward to r, or fail and go back to p's next clause. So on entering a procedure that has alternatives, Prolog also pushes a backward continuation onto a different stack — the choice-point stack. That, plus some detail, is all backtracking is.

So an imperative program transliterated into Prolog pushes only onto the return stack, exactly like its imperative original. You pay for search only when you actually do search.

This was David Warren's insight when he built the first Prolog compiler. Until then, implementations represented proof trees and resolution trees explicitly — faithful to the theorem-proving theory, and enormously slow. Warren saw that this is no different from C, except that every now and then it backtracks, and reused the machinery of imperative language implementation, adding only what backtracking needs. That is why Prolog is a programming language and not a theorem prover.

Slides 26–28 — Depth-first, breadth-first, and choosing between them

Depth-first search goes down the leftmost branch as far as it can, and backtracks on failure. It is cheap: it needs only a stack, and it is what Prolog does. But if an infinite branch lies to the left of a solution, depth-first search goes down it and never comes back. So depth-first is incomplete: it can fail to find a solution that exists.

Breadth-first search explores the tree level by level: one step down this branch, one step down that one, then the next level everywhere, and so on. Since every solution is at finite depth, it will reach every one of them before falling down an infinite branch — breadth-first is complete.

The price is in time and, above all, memory: to be at any point in the exploration you must have the whole tree above you stored somehow, however compressed. Depth-first needs one branch; this needs the whole frontier.

Because breadth-first is complete, it always gives the right answers. In this first part of the course we will run everything in breadth-first mode, so that we can write programs the way Green would have wanted: write the specification, and if there are answers, find them. It may be inefficient; it will not be wrong.

Depth-first is what Prolog does by default, and for a good reason: it is efficient, and Prolog is meant to be a programming language. There are ways of reordering clauses so that infinite branches move to the right and depth-first finds the solutions first — but that requires understanding what is going on and thinking about it. That is the second part of the course. Here we can ignore it.

Ciao lets you choose, per module:

:- module(_, _, [sr/bfall]).     % breadth-first: complete, slower, more memory
:- module(_, _, []).             % default: Prolog's depth-first
What that declaration is doing. Ciao code is divided into modules, and a module declaration has three arguments:

  • the module name — _ means "take it from the file name", so a file foo.pl becomes module foo;
  • the exported predicates — _ means "export everything", which is the easiest thing while learning;
  • a list of packages.
Packages are Ciao's extension mechanism. At its core Ciao is a Prolog system, and around that core sit many extensions, both syntactic (so you can write more things) and semantic (so more things go on when you run). sr/bfall is a semantic one: it says run every predicate in this module breadth-first.

In practice you often want only some predicates to run breadth-first, and there are finer-grained ways to say so; bfall is the maximally simple choice — everything, no exceptions.

Ciao has other search rules too, iterative deepening among them: go to a fixed depth on one branch, then to the same depth on the next, increasing the bound each round. It is essentially breadth-first but far cheaper in memory. For simplicity everything here uses sr/bfall.

This is the first appearance of algorithm = logic + control as something you actually operate. The program does not change; the control does, and with it what the program can find. The sr/bfall you met in the introduction deck was exactly this.

Try it on the pets program: switch its declaration between [sr/bfall] and [], reload, and ask pet(X) again. You get exactly the same answers — because that program is finite and has no infinite branches. To get an infinite branch you need recursion and data structures that can grow without bound. It is the complicated cases where the choice matters.

Slides 29–30 — Control of search: clause and goal order

Two orderings are under your control, and both change how much work gets done.

The order of goals in a body. Consider:

:- module(_,_,[]).
:- use_module(library(system),[pause/1]).

a(X,Y) :- p(X), q(X,Y).     % p first, then q
b(X,Y) :- q(X,Y), p(X).     % q first, then p

p(4).
p(5).

q(1, a) :- lots_of_computing.
q(2, b) :- lots_of_computing.
q(4, c) :- lots_of_computing.
q(4, d) :- lots_of_computing.

lots_of_computing :- pause(3).
a/2 and b/2 are logically identical — the conjunction is the same. But a/2 calls p(X) first, binding X to 4, so the call to q is q(4,Y) and only the matching clauses are tried. b/2 calls q first with X unbound, so it works through q(1,a) and q(2,b) — each costing three seconds of lots_of_computing — before reaching one that p accepts.

Same logic, same answers, very different amount of work. This is the generate-and-test versus constrain-and-generate distinction that the CLP deck later builds a whole chapter on.

Working through the goal-order example by hand

Take ?- p(X), q(X,Y). first. p gives X = 4. Now q(4,Y): the first clause wants X = 1 and fails immediately, before any computing; the second wants X = 2 and fails as well. So we go straight to the third clause and do the work once. Ask for a second solution and the fourth clause gives it. One expensive computation for the first solution.

Now ?- q(X,Y), p(X). We call q with X free. First clause: X = 1, Y = a, do the work — then return, call p(1), and fail. The whole computation was wasted. Second clause: X = 2, do the work, fail again. Third clause: X = 4, do the work, and p(4) finally succeeds. Three expensive computations instead of one.

Same logic, same answers, three times the work — and at the limit, the difference between terminating and not terminating.

And the best order depends on the mode. If the second argument arrives already bound — say the call is q(X,d), p(X) — then putting q first is now the better order, because Y = d cuts straight to the last clause and does one computation. Under the other order you would work through X = 4 twice. There is no order that is best in general; it depends on what is bound when you get there.

Clause order

The same reasoning applies one level up. Reordering the clauses of q/2 changes the order in which solutions come out: with the clauses as written, X=4,Y=c comes first and X=4,Y=d second; reverse them and you get d first.

If you want all the solutions, that is only a change of order — you must explore the whole tree either way. But if you want one, it can matter enormously. Imagine the solution sits on a short branch: at the left you find it at once and stop; at the right you grind through the whole useless part of the tree first.

And there is a third mechanism, which pure logic programming does not have: the pruning operators, the cut, which removes parts of the tree outright. That is the Prolog deck's subject.

Slide 31 — The role of unification in execution

Unification is doing three jobs at once during execution, and it is worth separating them:

  • passing arguments in — the caller's terms meet the head's terms;
  • returning results out — a variable in the call gets bound by the head;
  • selecting a clause — a head that does not unify is simply not applicable.
In a conventional language those are three different mechanisms — parameter passing, return values, and dispatch. Here they are one operation, running in whichever direction the data happens to flow.

A worked example

:- module(_,_,[]).

animal(dog(barry)).
named(dog(Name), Name).
?- animal(A), named(A, Name).
Expected answer:

A = dog(barry),
Name = barry ?
Now say what happened in programming terms. Calling animal(A) creates a data structure, dog(barry) — an object with barry inside it — and makes A point at it. Then named(A,Name) is called with A pointing at dog(barry), and the pattern in the head, dog(Name), matches against it: it reaches inside the structure and binds Name to barry. That Name is the same variable as the second argument, so the value is passed back out, and comes out as the answer.

No get, no put, no accessor, no index, no navigation code. Building the structure, passing it to a procedure, reaching inside it and returning a piece of it — all four are unification.

You can build lists, trees, and any data structure you like on this basis. The whole data structures course can be done with unification and first-order terms. That is the beauty of the approach.

Slide 32 — "Modes"

Something very interesting follows from all this: argument positions are not fixed as input or output.

In a functional language the arguments are all inputs and the return value is the output — a bunch of inputs and one output. Here it is not like that. Is the argument of pet/1 an input or an output? It depends. Ask pet(spot) and it is an input: you are handing spot to the procedure and asking it to check. Ask pet(X) and it is an output: you are asking the procedure to produce a value. Same argument, same procedure.

So arguments are bidirectional, and which role they play depends on their instantiation state at the moment of the call.

:- module(_,_,[]).

add(0, Y, Y).
add(s(X), Y, s(Z)) :- add(X, Y, Z).
?- add(s(0), s(s(0)), Z).
Expected answer:

Z = s(s(s(0))) ?
?- add(s(0), Y, s(s(s(0)))).
Expected answer:

Y = s(s(0)) ?
The same clauses ran forwards and backwards: first as addition, then as subtraction. Nothing in the program declares which argument is the input — because in a relation there is no such thing.

That combination of inputs and outputs has a name: the mode of a call. A three-argument predicate might be called in mode input-input-output, or input-output-input, or output-input-input, and so on. We write modes with + and -:

add(+X, +Y, -Z)     X and Y are given; Z is where the answer comes back
We will use this notation from time to time. Note that it describes a call, not the predicate — the same predicate can be used in several modes.

Slide 34 — Pure logic programs: an overview

Everything so far has been pure: no cut, no assert, no arithmetic evaluation, no negation. What we have is Horn clauses plus unification plus a search rule — and that is enough to program with, as the rest of this deck shows.

Programming with data

Slides 35–36 — Database programming

The simplest use of logic programming is as a deductive database: facts, plus rules that derive new facts from them.

:- module(_,_,[sr/bfall]).

father_of(john, peter).
father_of(john, mary).
father_of(peter, michael).
mother_of(mary, david).
mother_of(jill, john).

grandfather_of(L,M) :-
    father_of(L,N),
    father_of(N,M).
grandfather_of(X,Y) :-
    father_of(X,Z),
    mother_of(Z,Y).
The facts are the table; grandfather_of/2 is a view derived from it — and note that it takes two clauses, because a grandfather may be reached through a father or through a mother. Your grandfather can be your grandfather on your father's side or on your mother's side, and the two clauses say exactly that.

Existential variables. Look at N in the first rule: it appears in the body only, never in the head. A variable like that is much easier to read as an existential:

L is the grandfather of M if there exists an N such that L is the father of N and N is the father of M.

Or, in plainer words: L is the grandfather of M if L is the father of some N, and that N is the father of M.

Queries first. Facts already in the program are easy:

?- father_of(john, peter).
Expected answer:

yes
And what is not in the program is answered no:

?- father_of(john, david).
Expected answer:

no
That no deserves a name. The system does not know that John is not David's father; it knows only that it cannot prove that he is, and it answers no on that basis. This is the closed world assumption, familiar from artificial intelligence: what is not derivable is taken to be false.

?- father_of(john, X).
Expected answer:

X = peter ? ;
X = mary ? ;
no
?- grandfather_of(X, Y).
Expected answer:

X = john, Y = michael ? ;
X = john, Y = david ? ;
no
Two grandfather relationships in the database, found without either argument being given.

Both directions work equally well. Ask who is the grandfather of michael and you get john — via john, peter, michael — and no one else:

?- grandfather_of(X, michael).
Expected answer:

X = john ? ;
no
Ask instead who john is the grandfather of, and there are two answers, one down each rule: john, peter, michael and john, mary, david.

?- grandfather_of(john, X).
Expected answer:

X = michael ? ;
X = david ? ;
no
A small exercise from the slide, worth doing before reading on: write the rules for grandmother_of(X,Y). There are two of them, for the same reason there were two above.

A second example makes the point that "database" need not mean people and dates. Here the facts are the topology of a circuit — which resistors and transistors connect which nodes — and the rules are a little bit of circuit theory, saying what an inverter, a NAND gate and an AND gate look like in terms of that topology:

:- module(_,_,[sr/bfall]).

resistor(power,n1).
resistor(power,n2).

transistor(n2,ground,n1).
transistor(n3,n4,n2).
transistor(n5,ground,n4).

inverter(Input,Output) :-
   transistor(Input,ground,Output),
   resistor(power,Output).

nand_gate(Input1,Input2,Output) :-
   transistor(Input1,X,Output),
   transistor(Input2,ground,X),
   resistor(power,Output).

and_gate(Input1,Input2,Output) :-
   nand_gate(Input1,Input2,X),
   inverter(X, Output).
?- and_gate(In1, In2, Out).
Expected answer:

In1 = n3,
In2 = n5,
Out = n1 ?
The program has found a gate inside the circuit: it recognized that this particular arrangement of five components is an AND gate, and told us which nodes are its inputs and its output. Nobody wrote a search for gates — the rules say what a gate is, and the search comes free.

Exercises

Some exercises on the classic family database — opens in the Ciao playground, the same link as the button on the slide.

Slides 37–40 — Structured data, data abstraction, and =

Before the general point, the motivation. Suppose we want to record that the Computational Logic course is taught on Wednesdays from 18:30 to 20:30, by a given lecturer, in a given room. We could just use ten arguments:

course(complog,wed,18,30,20,30,'M.','Hermenegildo',new,5102).
and then ask when it is with

?- course(complog,Day,StartH,StartM,FinishH,FinishM,C,D,E,F).
which works, and is horrible. There is no structure at all.

Alternatively, structure it:

:- module(_,_,[]).

course(complog, Time, Lecturer, Location) :-
    Time     = t(wed, 18:30, 20:30),
    Lecturer = lect('M.','Hermenegildo'),
    Location = loc(new, 5102).
The time is now a data structure with three fields — day, start, finish — and note the : being used as an infix operator: 18:30 is really the term :(18,30), but written the way you would want to write it.

Those = goals are pure unification, and writing them is exactly equivalent to putting the structures directly into the head:

course(complog, t(wed,18:30,20:30), lect('M.','Hermenegildo'), loc(new,5102)).
X = Y behaves as if defined by the single fact '='(X,X). — one clause, whose two arguments are the same variable. That is the whole definition of equality here.

?- course(complog, Time, A, B).
Expected answer:

A = lect('M.','Hermenegildo'),
B = loc(new,5102),
Time = t(wed,18:30,20:30) ?
And a small trick: naming a variable with a leading underscore tells the top level not to print it. Better still, use the anonymous variable and do not name it at all:

?- course(complog, Time, _, _).
Expected answer:

Time = t(wed,18:30,20:30) ?
Notice what was involved in building loc(new,5102): no type declaration, no new, no allocation. The structure is created on the fly, by unification.

Now the general point. Terms are the only data structure, and they are enough. A structure groups related things: date(monday, Month, 1994) is a record with three fields; person(name(john,smith), age(23)) nests records inside records.

Two habits are worth forming early.

Use the anonymous variable for fields you do not care about. If you want the year out of a date, write date(_, _, Year) rather than inventing names you never use. Each _ is a different fresh variable, which is exactly what you want here.

Access fields by unification, not by taking things apart. To get at the year you do not call an accessor; you unify the whole term with a pattern and let the year fall out. That is what "data abstraction" means in this setting: the pattern is the accessor.

The = predicate deserves a note. X = Y is not assignment and not a test — it is a call to the unification algorithm, exactly as on slide 11. It succeeds if the two can be made identical, and binds whatever it must to achieve that.

Terms as data structures with pointers (slide 39). A term in memory is a functor cell followed by its arguments, and an argument that is a structure is a pointer to it. So f(g(a),X) is a cell for f/2, a pointer to a g/1 cell, and an unbound cell for X. Unification with f(Y,b) makes Y point at the g(a) structure — no copying — and fills in the X cell with b.

That is why unification is cheap, and why "modifying" a data structure is in quotes: you never overwrite anything, you bind variables that were left unbound.

The slide draws the heap after

main :-
    X = f(K,g(K)),
    Y = a,
    Z = g(L),
    W = h(b,L),
    % heap memory at this point --->
    p(X,Y),
    q(Y,Z),
    r(W).

Note the aliasing: K occurs twice in X, so both places point at the same cell, and L is shared between Z and W. Calling p(X,Y) passes pointers to these structures, not copies. Which is the summary: terms are data structures with pointers, and logical variables are declarative pointers — they can only be assigned once.

Slides 41–42 — Logic programs and the relational database model

The correspondence with relational databases is close enough to be worth stating precisely:

relational model logic programming
relation / table predicate defined by facts
tuple / row a fact
attribute / column an argument position
view predicate defined by rules
selection a constant in the head
projection fewer arguments, or _
join a shared variable

The last one is the pretty one. In grandfather_of(L,M) :- father_of(L,N), father_of(N,M). the shared N is the join: the two tables are joined on the column where the variable is repeated. You do not write a join operator; you write the same variable twice.

Slide 43 — Deductive databases, Datalog and ASP

Which gives the name: a deductive database is one where, besides the stored facts, there are rules deriving further facts. Logic programming is a deductive database language in which the rules may also be recursive — which relational algebra cannot express — and that is the subject of the next section.

The field has grown languages of its own, and they are deliberately less powerful than Prolog. They take a subset of definite programs and restrict the syntax:

  • Datalog allows no functors, and therefore no data structures: the only terms are the constants already in the database. Nor does it allow existential variables — a rule concludes only about individuals its body already mentions.
  • Execution is bottom-up instead of Prolog's top-down search: start from the facts and apply the rules, with the T_P operator that the theory part of the course introduces, until nothing new is derived; then keep the part of that model the query asks about.
The restrictions are the point. With no functors, no rule can build a term that was not there to begin with, so there are finitely many facts to derive and the bottom-up computation always stops. The system is decidable, as a database is: both answers and failures come in finite time, where Prolog may loop. The price is that it is not Turing complete — a query language, not a programming language.

What the restrictions buy is reasoning. These systems support much stronger notions of negation, under the stable model semantics, which is the basis of Answer Set Programming (ASP): you state what holds and what cannot hold, and the system returns the models that satisfy it all. For knowledge representation and reasoning that is a very effective way to write a problem down.

The two worlds are closer than this makes them sound. s(CASP) executes ASP programs the way Prolog executes a query — top-down and goal-directed, with no grounding step — so it answers one query at a time, works on non-ground programs, and can show why an answer holds.

Recursion

Slide 45 — Recursive programming

The rule that makes the whole thing a programming language rather than a query language is that a predicate may call itself.

:- module(_,_,[]).

parent(X,Y) :- father_of(X,Y).
parent(X,Y) :- mother_of(X,Y).

ancestor(X,Y) :- parent(X,Y).
ancestor(X,Y) :- parent(X,Z), ancestor(Z,Y).

father_of(john, peter).
father_of(john, mary).
father_of(peter, michael).
mother_of(mary, david).
mother_of(jill, john).
Read it as the definition it is: X is an ancestor of Y if X is a parent of Y, or if X is a parent of someone who is an ancestor of Y. Two clauses: the base case and the recursive case. That shape — one clause that stops, one that reduces the problem — recurs in every recursive predicate in this deck.

?- ancestor(john, X).
Expected answer:

X = peter ? ;
X = mary ? ;
X = michael ? ;
X = david ? ;
no
All four descendants of john, at any depth — which is exactly what relational algebra cannot do.

Exercises

Exercises: related, cousin, and same generation — opens in the Ciao playground, the same link as the button on the slide.

Slides 46–47 — What about "not equal"?

Sooner or later you want to say that two things are different — "a cousin is someone with a common grandparent who is not yourself". In full Prolog there is \=/2 for that. But we are in the pure part of the language, and \= is not pure: it is negation as failure in disguise, and it gives wrong answers on non-ground terms.

The pure answer is to define the complement as a relation, positively. For naturals in Peano form:

:- module(_,_,[]).

person_num(john,    0).
person_num(peter,   s(0)).
person_num(mary,    s(s(0))).
person_num(michael, s(s(s(0)))).
person_num(david,   s(s(s(s(0))))).
person_num(jill,    s(s(s(s(s(0)))))).

diffnat(0, s(_)).                     % 0 differs from any s(...)
diffnat(s(_), 0).                     % any s(...) differs from 0
diffnat(s(X), s(Y)) :- diffnat(X,Y).  % same depth: look further in

different_person(X,Y) :-
    person_num(X,NX),
    person_num(Y,NY),
    diffnat(NX,NY).
Read diffnat/2 as a definition of difference rather than as a test: two Peano numbers differ if one is zero and the other is not, or if both are successors whose predecessors differ. Three clauses, no negation anywhere.

?- different_person(john, mary).
Expected answer:

yes
?- different_person(john, john).
Expected answer:

no
The general lesson: in pure logic programming, define the complement as a type rather than negating a test. nonzero/1, notnat/1, different/2 — each is a positive definition of what it means not to be something.

And note the other half: to say two things are equal, you need no test at all. Use the same variable twice and unification does it.

Slides 48–49 — Types, and recursive types

A type is a possibly infinite set of terms. A type definition is a program that defines that set.

Keep those two apart: the type is the set, the definition is the program.

Take weekday. The set of terms is 'Monday', 'Tuesday', … — in quotes, because they start with a capital and are constants, not variables. The definition is seven facts.

Now a structured type. A date is a weekday together with a day of the month, so the set of terms we want is date('Monday',23), date('Tuesday',24) and so on — and the definition is one clause using two other types:

:- module(_,_,[sr/bfall]).

weekday('Monday').    weekday('Tuesday').   weekday('Wednesday').
weekday('Thursday').  weekday('Friday').    weekday('Saturday').
weekday('Sunday').

day_of_month(1).  day_of_month(2).  day_of_month(31).

date(date(W,D)) :- weekday(W), day_of_month(D).
?- date(date('Monday',31)).
Expected answer:

yes
?- date(date('Monday',23)).
Expected answer:

no
no — because only 1, 2 and 31 were declared as days of the month in this abbreviated version. And date(D) with D free generates dates: Monday the 1st, Monday the 2nd, Monday the 31st, Tuesday the 1st, and so on.

These types are part of the program. There is no separate type language, no separate declaration syntax. They are written in the same language, and therefore they run: a type definition is at once a definition, a checker, and a generator.

Recursive types

date/1 is not recursive — it is a structure with two fields. But a type definition may call itself, and that is what lets a finite program describe infinitely many terms. The first example is the natural numbers.

A question that comes up every year: what is s, and do I have to define it? You do not have to do anything at all. s is just a functor — writing it makes it one, and it behaves as one, building terms. s(0) is a data structure whose only field holds 0; s(s(0)) is a data structure whose only field holds that.

They are not functions. They are not called, not interpreted, they do not run. nat/1 on the other hand is a predicate: it is called, it runs, it does things.

:- module(_,_,[sr/bfall]).

nat_num(0).
nat_num(s(X)) :- nat_num(X).

% Integers: a natural, possibly with a minus in front.
pint( X) :- nat_num(X).
pint(-X) :- nat_num(X).
Two clauses: one unit clause and one recursive clause with a single body literal. This is about the minimal recursive predicate there is.

?- nat_num(s(s(s(0)))).
Expected answer:

yes
And the same definition generates the naturals if you call nat_num(X) with X free.

What does it cost?

This is worth doing once, carefully, because the reasoning transfers to every recursive predicate that follows.

Call nat_num(s(s(s(0)))). It does not match nat_num(0). It matches nat_num(s(X)) with X = s(s(0)), and calls nat_num(s(s(0))). That matches again with X = s(0), then again with X = 0, and finally nat_num(0) matches the base case. Done.

How many steps? As many as there are ss — one per level, plus the base case. So in this mode — argument fully instantiated — the predicate is linear, and it is a loop: walk down the structure checking that everything is an s and that a 0 sits at the bottom. You could not do it in less: to be sure the term really is a natural number you have to look at all of it.

The other direction is quite different. Ask nat_num(X) with X free. How many steps to the first answer? One — it matches the base case and returns X = 0.

Then type ;. The system backtracks to the choice it left behind, takes the second clause instead (with the variable renamed to X1), binds X to s(X1), calls nat_num(X1), which matches the base case — and answers s(0). One step.

So generating solutions costs one step per solution, not n steps for the nth. Different mode, different cost; and in both cases the cost is something you can work out, exactly as in an ordinary program.

One base case, one step case. The same two-clause shape as ancestor/2, and as everything that follows.

Slides 50–53 — Arithmetic, the pure way

With nat/1 in hand we can define arithmetic itself — no built-ins, no evaluation, just relations.

:- module(_,_,[sr/bfall]).

nat(0).
nat(s(X)) :- nat(X).

less_or_equal(0,X) :- nat(X).
less_or_equal(s(X),s(Y)) :- less_or_equal(X,Y).

plus(0,Y,Y) :- nat(Y).
plus(s(X),Y,s(Z)) :- plus(X,Y,Z).

less_or_equal

Two clauses again. Zero is less than or equal to any natural number; and if X is less than or equal to Y, then adding one to both leaves the relation unchanged. That is enough.

And note that it is a predicate, a relation — not a function — which is why it can be called in so many ways:

?- less_or_equal(s(0), s(s(0))).
Expected answer:

yes
?- less_or_equal(X, 0).
Expected answer:

X = 0 ? ;
no
Zero, and nothing else — correct. Used the other way it becomes a generator: ask for everything less than or equal to two and you get exactly three answers.

?- less_or_equal(X, s(s(0))).
Expected answer:

X = 0 ? ;
X = s(0) ? ;
X = s(s(0)) ? ;
no
You can even leave both arguments free and ask for the relation itself, less_or_equal(X,Y). That set is infinite, so it never finishes — but because we are running breadth-first the enumeration is a good one, hitting all the cases in order: (0,0), (0,1), (1,1), (0,2), (1,2), (2,2), … Under depth-first it would go down one branch forever.

plus

plus/3 is a relation between three numbers, not a function of two. Forwards:

?- plus(s(0), s(s(0)), Z).
Expected answer:

Z = s(s(s(0))) ?
And with the result given instead, it enumerates every way of reaching it:

?- plus(X, Y, s(s(0))).
Expected answer:

X = 0,       Y = s(s(0)) ? ;
X = s(0),    Y = s(0) ? ;
X = s(s(0)), Y = 0 ? ;
no
Three ways to make 2, and then no — it knows it has found them all. One definition of addition is simultaneously addition, subtraction, and the enumeration of partitions.

Subtraction proper:

?- plus(X, s(0), s(s(s(0)))).
Expected answer:

X = s(s(0)) ?
And a question with no answer gets no, correctly — there is no number that added to 2 gives 1:

?- plus(X, s(s(0)), s(0)).
Expected answer:

no
The same thing can often be defined in more than one way. Addition could equally have been

plus(X,0,X) :- nat(X).
plus(X,s(Y),s(Z)) :- plus(X,Y,Z).
— recursing on the second argument instead of the first. It computes the same relation, and some find it easier to read.

What you should not do is put both definitions in the same program. The meaning would be the same — the same set is computed — but you would get several proof trees for the same query and each solution computed more than once. Superfluous definitions are inefficient.

Which is the beginning of a theme: the art of logic programming is finding formulations that are both compact and computationally efficient. A logic program is beautiful when it corresponds almost directly to the definition of what you are trying to construct and is an efficient program. Sometimes you cannot have both; often you can, and it is very satisfying when it happens.

The slides suggest writing times/3 (multiplication is repeated addition), exp/3 (exponentiation is repeated multiplication), factorial/2, minimum/3 and mod/3 for yourself. mod/3 is worth stating precisely before programming it: Z is X mod Y when X = Y*Q + Z for some Q, with Z less than Y. Write the mathematics down first and the program tends to follow.

Note the module header: sr/bfall, breadth-first. Under depth-first, plus(X,Y,s(s(0))) finds its answers and then goes looking for more down an infinite branch. The logic is complete; the default control is not.

Multiplication follows from addition, and the naturals themselves from nat/1 — building up arithmetic from nothing but Horn clauses. less_or_equal/2 shows the same pattern for the ordering.

Exercises

Exercises: times, exp, factorial, and minimum — opens in the Ciao playground, the same link as the button on the slide.

Slides 54–57 — Functional syntax

Writing plus(X, Y, Z) everywhere is faithful to the relational reading but verbose when you only want a value. Ciao's functional syntax package lets you write the same predicates as functions, without changing what they are:

:- use_package(functional).
:- fun_eval plus/2.
after which plus(X,Y) in an argument position means "the Z such that plus(X,Y,Z)". The predicate is unchanged; only the notation is.

Two things to know before using it. :- fun_eval is positional — it takes effect from where it appears downwards, so any clause that uses the operators as functions must come after it. And :- use_package(functional) turns on arith(true), which shadows your own +, -, *, /; add :- fun_eval arith(false). if you are defining those yourself.

Ackermann's function is the standard example of something that is easy to define and expensive to compute. Written in a functional language over Peano numbers, it is three equations:

datatype nat = Zero | S of nat

fun ack 0 n = S n
  | ack (S m1) 0 = ack m1 (S 0)
  | ack (S m1) (S n1) = ack m1 (ack (S m1) n1)
A function of two arguments becomes a predicate of three, the extra one holding the result:

:- module(_,_,[]).

ack(0, N, s(N)).
ack(s(M), 0, Val) :-
    ack(M, s(0), Val).
ack(s(M), s(N), Val) :-
    ack(s(M), N, Val1),
    ack(M, Val1, Val).
?- ack(s(s(0)), s(s(s(0))), X).
X = s(s(s(s(s(s(s(s(s(0)))))))))
Nine s's: A(2,3) = 9, which is the answer the equations give.

Being a predicate rather than a function, it also runs in the other directions, which the functional definition cannot do at all:

?- ack(s(s(0)), Y, s(s(s(s(s(s(s(s(s(0)))))))))).
Y = s(s(s(0)))
Which second argument gives 9? — and likewise ?- ack(X, s(s(s(0))), s(s(s(s(s(s(s(s(s(0)))))))))). answers X = s(s(0)).

Slide 56 writes the same three clauses in functional syntax, with s declared as a prefix operator so that the Peano numbers need no parentheses:

:- module(_,_,[]).
:- use_package(functional).
:- op(100,fy,s).

ack(  0,   N) := s N.
ack(s M,   0) := ack(M, s 0).
ack(s M, s N) := ack(M, ack(s M, N) ).
This is the same predicate ack/3 as above — := only says that the last argument is the result — so ?- ack(s(s(0)), s(s(s(0))), X). still answers X = s(s(s(s(s(s(s(s(s(0))))))))).

op/3 in a file is local to its module, so a query typed at the top level needs its own ?- op(100,fy,s). before s s 0 will read. Written with parentheses, as above, it needs nothing.

Slide 57 applies the same notation to a type, in four steps. Starting from nat/1, written with := naming the result argument:

nat := 0.
nat := s(X) :- nat(X).
the ~ operator ("evaluate this and put the result here") removes the auxiliary variable, and inside a predicate's own definition even the ~ can be left out:

nat := 0.
nat := s(~nat).
nat := 0.
nat := s(nat).

with the prefix operator the parentheses go too, and | writes the two clauses as one:

nat := 0.
nat := s nat.
nat := 0 | s nat.

That last line is a type definition as compact as a functional language's datatype, and it means exactly what we started with:

:- module(_,_,[]).
:- use_package(functional).
:- op(100,fy,s).

nat := 0 | s nat.
?- nat(s(s(0))).
yes

Exercises

Exercise: arithmetic operators as functions — opens in the Ciao playground, the same link as the button on the slide.

Lists

Slides 58–59 — Lists as a recursive type

Lists are not built in. They are ordinary terms, and it is worth building one by hand once, so that the sugar never mystifies you.

A list is a binary structure — a pair whose first argument is an element and whose second is the rest of the list. To build it we need two things:

  • a constant for the empty list, so that we know how to stop, exactly as 0 did for nat/1. It is written [] and it is a constant, no more mysterious than a;
  • a functor of arity 2 for the pair. Traditionally this is . — overloaded and best avoided in your own programs, but that is the convention.
So a list of one element is a pair with a on the left and [] on the right; a list of two nests a second pair inside the first:

?- X = '.'(a,'.'(b,[])).
Expected answer:

X = [a,b] ?
You built a nested pair structure, and the system printed it as [a,b] — because it recognized the dot and applied the sugar.

The two layers of sugar

The first is that .(H,T) may be written [H|T]. The bar always separates the left of the pair from the right. The second is that a right-hand side which is itself a list can be flattened with commas. The table:

formal object "cons pair" syntax "element" syntax
.(a,[])[a|[]][a]
.(a,.(b,[]))[a|[b|[]]][a,b]
.(a,.(b,.(c,[])))[a|[b|[c|[]]]][a,b,c]
.(a,X)[a|X][a|X]
.(a,.(b,X))[a|[b|X]][a,b|X]

The last two rows are the important ones. [a|X] is a list whose first element is a and whose tail is unknown — it cannot be written in pure element syntax, and it does not have to be. [a,b|X] starts with a, then b, and then is open. Lists with a variable tail are extremely useful, and they come back in force in the Prolog deck under the name difference lists.

Unification on lists

There is nothing new here — it is the same unification — but it is worth doing a few by hand, because this is how every list predicate works.

?- [a,b,c] = [X|Y].
Expected answer:

X = a,
Y = [b,c] ?
Write it in dots if it is not obvious: .(a,.(b,.(c,[]))) against .(X,Y). Dot matches dot, a matches X, and Y matches everything on the right of the first pair — which is the list [b,c].

?- [a] = [a,b|X].
Expected answer:

no
[a] has one pair; [a,b|X] needs at least two.

And one that catches people. What does X get here?

?- [a,b] = [X|_].
Expected answer:

X = a ?
a, not [a,b] — [X|_] is a pair, so X is the head. Then a longer one, mixing both notations:

?- [a,b,c,d,e,f,g] = [X,Y|Z].
Expected answer:

X = a,
Y = b,
Z = [c,d,e,f,g] ?
Everything we do is resolution and unification, and nothing else. Every operation on lists, trees, arrays and any other data structure is this one =, the unification of first-order logic.

The type definition

Which means the type is written exactly like nat/1:

:- module(_,_,[]).

list([]).
list([_|Y]) :- list(Y).

list_member(X, [X|L]) :- list(L).
list_member(X, [_|T]) :- list_member(X, T).

list_length([], 0).
list_length([_|T], s(N)) :- list_length(T, N).
One base case, one step case — the same shape as everything so far. And once you see that, every list predicate writes itself: say what holds for [], then say what holds for [H|T] given that it holds for T.

list_member/2 says: X is a member of a list whose head is X, or of a list whose tail it is a member of. list_length/2 counts in Peano numbers, because we are still being pure.

Slides 60–61 — Append

The central list predicate, and the best illustration of what a relation buys you:

:- module(_,_,[]).

list([]).
list([_|Y]) :- list(Y).

list_append([], L, L) :- list(L).
list_append([X|Xs], Ys, [X|Zs]) :- list_append(Xs, Ys, Zs).
Two clauses. Appending nothing to L gives L; appending [X|Xs] to Ys gives a list whose head is X and whose tail is Xs appended to Ys.

?- list_append([1,2], [3,4], Z).
Expected answer:

Z = [1,2,3,4] ?
That is the use you expected. But nothing in the program says which arguments are inputs:

?- list_append(X, Y, [1,2]).
Expected answer:

X = [],     Y = [1,2] ? ;
X = [1],    Y = [2] ? ;
X = [1,2],  Y = [] ? ;
no
Every way of splitting [1,2] into two lists — and then no. The same two clauses are concatenation, splitting, prefix-testing and suffix-testing, depending on which arguments you supply.

In between those two extremes there are the intermediate modes, and they are the ones worth trying by hand. Checking a result:

?- list_append([a,b], [1,2], [a,b,1,2]).
Expected answer:

yes
List subtraction, from either side. What must be appended to [a,b] to give [a,b,1,2]?

?- list_append([a,b], Y, [a,b,1,2]).
Expected answer:

Y = [1,2] ?
And the mirror image — what must [1,2] be appended to?

?- list_append(X, [1,2], [a,b,1,2]).
Expected answer:

X = [a,b] ?
This is relational programming, not functional. It is not a function that takes inputs and gives an output; it is a relation — more like a table — and you may supply any of its columns and ask for the rest.

Slide 62 — One way to understand recursive programs

When writing a recursive predicate over a list, ask two questions and write one clause for each:

  1. What holds for the empty list? That is the base case.
  2. Assuming it holds for the tail, what holds for [H|T]? That is the step.
You do not have to think about the recursion unrolling. Assume the recursive call works, and say what to do with its result. Every predicate in this section was written that way.

Slides 63–67 — Reverse, and the cost of getting it wrong

Reversing a list is the classic demonstration that two correct programs can differ enormously in cost.

:- module(_,_,[]).

list([]).
list([_|Y]) :- list(Y).

list_append([], L, L) :- list(L).
list_append([X|Xs], Ys, [X|Zs]) :- list_append(Xs, Ys, Zs).

% naive: reverse the tail, then append the head at the end
reverse_naive([], []).
reverse_naive([X|Xs], Ys) :-
    reverse_naive(Xs, Zs),
    list_append(Zs, [X], Ys).

% accumulator: carry the answer being built
reverse_acc(Xs, Ys) :- reverse_(Xs, [], Ys).

reverse_([], Ys, Ys).
reverse_([X|Xs], Acc, Ys) :-
    NewAcc = [X|Acc],
    reverse_(Xs, NewAcc, Ys).
The naive one is the direct reading of the definition: to reverse [X|Xs], reverse Xs and then put X at the end. Work one step by hand, assuming the recursion works — which is the way to think about recursive programs. Given [1,2,3], head unification gives X = 1 and Xs = [2,3]. Assume reverse_naive([2,3],Zs) works and gives Zs = [3,2]. Then append the singleton [1] to that, and Ys = [3,2,1]. It works. The base case is easy: reversing [] gives [].

Both give the same answer:

?- reverse_naive([1,2,3], Y).
Expected answer:

Y = [3,2,1] ?
?- reverse_acc([1,2,3], Y).
Expected answer:

Y = [3,2,1] ?
And the naive one runs backwards too, which is not obvious:

?- reverse_naive(X, [3,2,1]).
Expected answer:

X = [1,2,3] ?
Ask it for the relation itself, reverse_naive(X,Y) with both arguments free, and something rather nice happens. The answers come out as patterns:

X = [],       Y = []       ? ;
X = [_A],     Y = [_A]     ? ;
X = [_B,_A],  Y = [_A,_B]  ? ;
...
Those are variables, so each answer stands for infinitely many cases. The third one says that any two-element list reverses by swapping its elements — including reverse_naive([f(a),g(b)], Y), which gives Y = [g(b),f(a)]. The program is enumerating the shape of the relation, not just instances of it.

Why the naive version is slow

reverse_naive/2 calls list_append/3 once per element, and each of those walks the list it is given — so the cost is quadratic in the length. reverse_acc/3 touches each element once, so it is linear.

There is a second, related problem, and it is about the shape of the recursion rather than the count. In reverse_naive/2 the recursive call is not last: after it returns you still have to do the list_append/3. That means the system must remember to come back, which means pushing a frame on the stack for every level of the recursion.

A predicate is tail recursive when the recursive call is the very last goal, with nothing after it. Then the local variables are dead by the time the call is made, the frame can be discarded, and the recursion runs as a loop in constant stack. Prolog and Ciao implement tail recursion well, so this is a real difference, not a theoretical one.

The accumulating parameter

The trick for turning a non-tail-recursive predicate into a tail-recursive one is a standard technique, in functional as well as logic programming: add an extra argument and build the answer in it as you descend.

reverse_acc(Xs,Ys) :- reverse_(Xs,[],Ys).

reverse_([],Ys,Ys).
reverse_([X|Xs],Acc,Ys) :-
    NewAcc = [X|Acc],
    reverse_(Xs,NewAcc,Ys).
The NewAcc = [X|Acc] is written out here for clarity; putting [X|Acc] directly in the call is exactly equivalent.

Follow [1,2,3] down. The accumulator starts as [].

XsAccNewAcc
call 1 [2,3][][1]
call 2 [3][1][2,1]
call 3 [][2,1][3,2,1]
call 4 [][3,2,1]— matches reverse_([],Ys,Ys)

Each step conses the current head onto the front of what has been accumulated. You might object that consing onto the front is not reversing — but notice that the accumulator is being passed downwards, not returned upwards. The element added last ends up at the front, which is exactly the reversal.

And the base case is where it comes back out: reverse_([],Ys,Ys) unifies the finished accumulator with the output argument, and that answer is then handed straight back up through every pending call, unchanged.

The accumulator is worth recognizing as a pattern, because it recurs everywhere: instead of combining the result after the recursive call, pass a partial result into it, and hand it out at the base case. You saw the same device in queens_pl_/3 in the CLP deck, and it returns in the Prolog deck as the tail-recursive compute_length_/3.

Measuring the difference

Quadratic against linear is a claim, so measure it. The harness uses two things from Prolog rather than pure logic programming — which is fine, since we are measuring rather than specifying:

:- use_module(engine(runtime_control),[statistics/2]).
:- use_module(library(lists),[length/2]).

rev_naive(N,T) :-
    length(L,N),
    statistics(walltime, [_,_]),
    reverse_naive(L,_RL),
    statistics(walltime, [_,T]).
statistics(walltime,[_,T]) gives, in its second element, the time since the previous call to statistics/2 — so calling it once to reset, then again after the work, measures the interval.

The other trick is length/2 run backwards. length([1,2,3],N) gives N = 3 as you expect; but length(L,3) creates a list of three fresh variables, [_A,_B,_C]. That is a very convenient way to manufacture a list of arbitrary length for benchmarking — and another instance of a relation being useful in a direction its name does not suggest.

Naive reverse, doubling the input each time (Ciao 1.25, times in milliseconds):

goal time (ms)
rev_naive(100,T)0.043
rev_naive(200,T)0.142
rev_naive(400,T)0.846
rev_naive(800,T)3.274

Double the length, roughly quadruple the time. That is quadratic, plainly.

The accumulator version, over a far wider range:

goal time (ms)
rev_acc(100,T)0.002
rev_acc(1000,T)0.007
rev_acc(10000,T)0.069
rev_acc(100000,T)2.608

Ten times the length, roughly ten times the time — linear. And note the scale: at 800 elements the naive version already costs more than the accumulator version does at 10,000. This is not a small constant factor; it is a different algorithm.

Both programs are correct, and both are short. Logic tells you what a program computes; it does not tell you what it costs. That is the other half of the subject.

Exercises

Exercise: comparing the efficiency of the two reverse/2 definitions — opens in the Ciao playground, the same link as the button on the slide.

Some exercises on lists: prefix, suffix, sublist, ... — opens in the Ciao playground, the same link as the button on the slide.

Trees, symbols and graphs

Slides 68–69 — Binary trees

Same recipe as lists, one dimension up.

For lists we used a pair — a functor of arity 2. For a binary tree we need arity 3: the contents of the node, the left subtree and the right subtree. And we need a constant for the empty tree, the way [] was the empty list; we call it void, though the name is arbitrary.

A binary tree is empty, or a node with an element and two subtrees:

:- module(_,_,[]).

binary_tree(void).
binary_tree(tree(_Element, Left, Right)) :-
    binary_tree(Left),
    binary_tree(Right).

tree_member(X, tree(X, Left, Right)) :- binary_tree(Left), binary_tree(Right).
tree_member(X, tree(_, Left, _Right)) :- tree_member(X, Left).
tree_member(X, tree(_, _Left, Right)) :- tree_member(X, Right).
Note what the type definition does not say: nothing at all about the element. A node may contain anything. What it constrains is the shape — that the two branches must themselves be binary trees.

?- binary_tree(tree(foo, void, tree(bar, void, void))).
Expected answer:

yes
And what is not a tree:

?- binary_tree(tree(a, b, c)).
Expected answer:

no
b and c are not trees — the same mistake as writing [a|b] and expecting a list. And:

?- binary_tree(tree(a, [], [])).
Expected answer:

no
no, because the empty tree here is void, not []. The type is exactly what you defined it to be.

As always, the type generates. binary_tree(T) with T free enumerates trees:

T = void ? ;
T = tree(_,void,void) ? ;
T = tree(_,void,tree(_,void,void)) ? ;
T = tree(_,tree(_,void,void),void) ? ;
...
The _ says the node contents do not matter — anything of that shape is a binary tree. Under sr/bfall this is a genuinely orderly enumeration of the binary trees, by size.

tree_member/2 needs three clauses where the list version needed two, because there are two subtrees to look in rather than one tail. Otherwise it is the same thought: X is in the tree if it is the contents of this node, or if it is somewhere in the left branch, or somewhere in the right.

?- tree_example(_T), tree_member(e, _T).
Expected answer:

no
Asking for all the members of the example tree gives a, b, c and b again — the tree contains b twice, and the program finds both occurrences. Logically the two answers are the same answer; operationally it is quite useful to be told there are two of them.

Traversal turns a tree into a list, and the shape of the clause decides which traversal you get:

:- module(_,_,[]).

list([]).
list([_|Y]) :- list(Y).
list_append([], L, L) :- list(L).
list_append([X|Xs], Ys, [X|Zs]) :- list_append(Xs, Ys, Zs).

pre_order(void, []).
pre_order(tree(X,Left,Right), Elements) :-
    pre_order(Left, ElementsLeft),
    pre_order(Right, ElementsRight),
    list_append([X|ElementsLeft], ElementsRight, Elements).

tree_example(tree(a, tree(b, void, void), tree(c, tree(b, void, void), void))).
?- tree_example(T), pre_order(T, L).
Expected answer:

L = [a,b,c,b],
T = tree(a,tree(b,void,void),tree(c,tree(b,void,void),void)) ?
Move the X from the front of the append to between the two lists and you have in-order; move it to the end and you have post-order. The traversal is the position of one symbol.

Exercises

Exercises: in_order and post_order — opens in the Ciao playground, the same link as the button on the slide.

Slide 70 — Polymorphism

list_member/2 and tree_member/2 do the same job on different types. You can either keep them separate and put a two-clause wrapper on top, or write a single polymorphic predicate whose clauses cover both shapes:

lt_member(X, [X|Y])          :- list(Y).
lt_member(X, [_|T])          :- lt_member(X, T).
lt_member(X, tree(X, L, R))  :- binary_tree(L), binary_tree(R).
lt_member(X, tree(_, L, _))  :- lt_member(X, L).
lt_member(X, tree(_, _, R))  :- lt_member(X, R).
Nothing had to be declared. The clause heads dispatch on the shape of the term, which is what unification does anyway — so polymorphism here costs exactly nothing. It works on a list, and it works on a tree, and you did not write a word of dispatch code.

But call it with both arguments free and something surprising comes back. You would expect it to generate lists with their members, and trees with their members. What you actually get is:

T = [M] ? ;
T = [M,_] ? ;
T = [_,M] ? ;
T = tree(M,void,void) ? ;
T = tree(_,[M],_) ? ;
T = tree(_,_,[M]) ? ;
T = [M,_,_] ? ;
...
Look at the fifth answer: a tree with a list inside it. And that is correct, given what we wrote. By mixing the clauses of the two definitions into one predicate, we allowed the recursion to cross from one type into the other. What we actually defined is a new type — call it the "list-tree" — in which lists and trees may nest inside each other arbitrarily.

Can you keep the types apart? Certainly — but you have to write it. Keep the two original definitions, each recursing into itself, and put a two-clause wrapper on top:

lt_member_separate(X,Y) :- list_member(X,Y).
lt_member_separate(X,Y) :- tree_member(X,Y).
Now once you are inside tree_member/2 you can only be in a tree, and once you are inside list_member/2 you can only be in a list. Generating with both arguments free gives lists and trees, and never a mixture. Two designs, two different types; you choose by choosing what to write.

Slide 71 — Recognizing (and generating) polynomials

An application of trees, because a polynomial is a tree — an expression tree. The definition is a type, written the way we have written every type so far:

  • X is a polynomial in X;
  • a constant is a polynomial in anything;
  • sums, differences and products of polynomials in X are polynomials in X;
  • a polynomial raised to the power of a natural number is one, and so is the quotient of a polynomial by a constant.
:- module(_,_,[sr/bfall]).

polynomial(X,X).
polynomial(Term,_X)       :- pconstant(Term).
polynomial(Term1+Term2,X) :- polynomial(Term1,X), polynomial(Term2,X).
polynomial(Term1-Term2,X) :- polynomial(Term1,X), polynomial(Term2,X).
polynomial(Term1*Term2,X) :- polynomial(Term1,X), polynomial(Term2,X).
polynomial(Term1/Term2,X) :- polynomial(Term1,X), pconstant(Term2).
polynomial(Term1^N,X)     :- polynomial(Term1,X), nat_num(N).

pconstant(X) :- nat_num(X).
pconstant(a).
pconstant(b).
pconstant(c).

nat_num(0).
nat_num(s(X)) :- nat_num(X).
?- polynomial(a * x ^ s(s(0)) + b, x).
Expected answer:

yes
a is a constant here because we declared it one, so a*x² + b is a polynomial in x. And the definition is precise enough to reject things:

?- polynomial(a * x ^ x + b, x).
Expected answer:

no
x^x is not a polynomial — the exponent has to be a natural number, and the clause says so.

As always, the same definition enumerates: polynomial(P,x) produces x, a, b, c, 0, x+x, x-x, and onwards through the polynomials in x.

This is one place where functional syntax pays off handsomely. The whole type fits in three lines:

poly_f(X) := X
    | ~pconst_f
    | ~poly_f(X) + ~poly_f(X)
    | ~poly_f(X) - ~poly_f(X)
    | ~poly_f(X) * ~poly_f(X)
    | ~poly_f(X) / ~pconst_f
    | ~poly_f(X) ^ ~nat_num.

pconst_f := ~nat_num | a | b | c.
That is about the shortest way to write down what a polynomial is — and it remains, simultaneously, a checker, a generator, and a piece of a working program. (Note that the arguments come out reversed: the functional definition is called as poly_f(x, Expression).)

Slide 72 — Symbolic differentiation

Terms are symbolic expressions, so a program that manipulates expressions is just a program over terms. Symbolic differentiation is the classic:

:- module(_,_,[sr/bfall]).

deriv(X,      X, s(0)                 ).
deriv(C,     _X, 0                    ) :- pconstant(C).
deriv(U+V,    X, DU+DV                ) :- deriv(U,X,DU), deriv(V,X,DV).
deriv(U-V,    X, DU-DV                ) :- deriv(U,X,DU), deriv(V,X,DV).
deriv(U*V,    X, DU*V+U*DV            ) :- deriv(U,X,DU), deriv(V,X,DV).
deriv(U/V,    X, (DU*V-U*DV)/V^s(s(0))) :- deriv(U,X,DU), deriv(V,X,DV).
deriv(U^s(N), X, s(N)*U^N*DU          ) :- deriv(U,X,DU), nat_num(N).
deriv(log(U), X, DU/U                 ) :- deriv(U,X,DU).

pconstant(X) :- nat_num(X).
pconstant(a).
pconstant(b).

nat_num(0).
nat_num(s(X)) :- nat_num(X).
Every clause is a differentiation rule copied out of a textbook, with the pattern in the head doing the work of deciding which rule applies. There is no dispatch code, no expression parser — the term is the parse tree.

?- deriv(x*x, x, D).
Expected answer:

D = s(0)*x+x*s(0) ?
The product rule, applied correctly — and left unsimplified, which is the honest output of these rules. Simplifying it is a separate program, and a good exercise.

?- deriv(log(x), x, D).
Expected answer:

D = s(0)/x ?

Exercises

Exercise: simplifying the result of deriv/3 — opens in the Ciao playground, the same link as the button on the slide.

Slides 73–74 — Graphs

A graph is a set of edge/2 facts, and reachability is the two-clause recursion you have now seen many times:

:- module(_,_,[sr/bfall]).

path(X,Y) :- edge(X,Y).
path(X,Y) :- edge(X,Z), path(Z,Y).

edge(a,b).  edge(a,c).  edge(b,d).
edge(c,d).  edge(d,e).  edge(d,h).
?- path(a, e).
Expected answer:

yes
This is where the search rule stops being academic. If the graph has a cycle, path/2 under depth-first search will follow it forever. Under sr/bfall it still finds every finite path, because solutions are at finite depth — which is exactly the completeness result from slide 25, meeting a program you would actually write.

Detecting a cycle — circuit :- path(A,A). — is the natural next question, and it is what the exercises take up.

Exercises

Exercises: extending path/2 and circuit/0 — opens in the Ciao playground, the same link as the button on the slide.

Slide 75 — Automata as graphs

A finite automaton is a graph whose edges are labeled, so it is the same program with one more argument:

:- module(_,_,[sr/bfall]).

accept(S) :- initial(Q), accept_from(S, Q).

accept_from([], Q)      :- final(Q).
accept_from([C|Cs], Q)  :- delta(Q, C, NewQ), accept_from(Cs, NewQ).

initial(q0).
final(q0).

delta(q0, a, q1).
delta(q1, b, q0).
delta(q1, b, q1).
Deterministic finite automata are no trouble in any language. Non-deterministic ones are more awkward, because from some state two transitions leave on the same input and you do not know which to take. Except here — because in logic programming non-determinism is native.

Two things are being represented, and it is worth keeping them apart:

  • The strings are lists of constants: a b b is [a,b,b].
  • The automaton is a graph, and here a graph is most easily a set of facts. initial(q0) and final(q0) mark the states; the three delta/3 facts are the arrows, directions and all.
accept_from/2 then walks the string and the automaton together: the empty string is accepted only in a final state — when you run out of input you had better be where you are supposed to end — and [C|Cs] is accepted from Q if there is a transition on C to some NewQ from which Cs is accepted.

Those three clauses are not this automaton. They are every non-deterministic finite automaton. The facts below them are this particular one.

Where exactly is the non-determinism? In the call to delta/3. Running forwards, Q and C are given and NewQ comes back — but in state q1 reading b there are two transitions, one staying in q1 and one returning to q0. Take the first; if something fails later, backtrack and take the other. Nothing had to be programmed for that: it is what having several clauses for a predicate means.

?- accept([a,b]).
Expected answer:

yes
?- accept([a,b,b,b]).
Expected answer:

yes
?- accept([a,a]).
Expected answer:

no
Note that delta/3 has two clauses for delta(q1, b, _), so this automaton is non-deterministic — and nothing had to be done about that. The search that Prolog does anyway is exactly the search a non-deterministic automaton needs.

Generation is where it gets interesting. Call accept/1 with variables and you are asking a different question: which strings of four symbols does this automaton accept?

?- accept([A,B,C,D]).

A = a, B = b, C = a, D = b ? ;
A = a, B = b, C = b, D = b ? ;
no
The first answer may surprise you: a b a b. Nothing said you could not pass through the initial state twice, so the language is really (a b+)*, not a b*. Ask for all accepted strings and you get [], [a,b], [a,b,b], [a,b,a,b], and onwards.

Running a specification backwards often reveals that you wrote more than you thought you did. That is a good enough reason to do it.

Slide 76 — Adding a stack: pushdown automata

Add one argument — a stack — and the same three lines become a non-deterministic pushdown automaton, which recognizes more than a finite automaton can.

:- module(_,_,[sr/bfall]).

npda_accept(S) :-
    npda_initial(Q),
    npda_accept_from(S, Q, []).

npda_accept_from([], Q, [])  :-
    npda_final(Q).
npda_accept_from([C|Cs], Q, Stack) :-
    npda_delta(Q, C, Stack, NewQ, NStack),
    npda_accept_from(Cs, NewQ, NStack).

% A concrete automaton:
npda_initial(q0).
npda_final(q1).

npda_delta(q0, C,     Stack, q0, [C|Stack]).
npda_delta(q0, C,     Stack, q1, [C|Stack]).
npda_delta(q0,_C,     Stack, q1,    Stack ).
npda_delta(q1, C, [C|Stack], q1,    Stack ).
The stack starts empty, and the base case now demands that we finish in a final state and with the stack empty — everything pushed must have been popped.

The slide asks: what sequence does it recognize? Rather than being told, run it.

?- npda_accept([a,b,b,a]).
Expected answer:

yes
?- npda_accept([a,b,a,b]).
Expected answer:

no
?- npda_accept([a,b,c,b,a]).
Expected answer:

yes
Palindromes. In q0 it pushes each symbol; the second and third transitions are the two ways of crossing the middle — the third skips one symbol, which is what allows an odd-length palindrome; and in q1 it pops, but only if the symbol read matches the top of the stack. That last clause, npda_delta(q1, C, [C|Stack], q1, Stack), uses the same variable C twice: reading and matching against the stack are one unification.

A variant in the example file, npda_accept_alt/1, restricts the symbols to a declared alphabet with a symbol/1 call in each transition. Asking it for all accepted four-symbol sequences over {a,b,c} gives exactly the nine even-length palindromes:

[a,a,a,a], [a,b,b,a], [a,c,c,a],
[b,a,a,b], [b,b,b,b], [b,c,c,b],
[c,a,a,c], [c,b,b,c], [c,c,c,c]

Slides 77–79 — The Towers of Hanoi

Three pegs, a stack of disks on the first, and the rules: move one disk at a time, never place a larger disk on a smaller one, and get them all from peg a to peg b using c.

With one disk you just move it. With two you cannot put the big one down first, so: small one to c, big one to b, small one on top. With three it takes seven moves. The legend says that when the monks of Hanoi finish moving their tower the world will end; apparently they are still at it.

What we want from the program is not just that it can be done, but the list of moves. Each move is a term move(A,B), meaning move the top disk of peg A to peg B.

The general predicate carries more than the number of disks: hanoi(N, Origin, Destination, Help, Moves). It needs Help because the pegs change roles — sometimes you are moving from a to b using c, and sometimes from a to c using b. Reasoning it out:

  • One disk in Origin, wanted in Destination: move it. No help needed, and no rule can forbid it.
  • s(N) disks: the bottom one has to get to Destination, so first move the N above it out of the way onto Help — using Destination as the spare. Then move the bottom disk across. Then move those N from Help to Destination — now using Origin, which is empty, as the spare.
The list of moves is then the first batch, then the single move, then the second batch — which is exactly what the list_append/3 in the clause builds.

:- module(_,_,[]).

list([]).
list([_|Y]) :- list(Y).
list_append([], L, L) :- list(L).
list_append([X|Xs], Ys, [X|Zs]) :- list_append(Xs, Ys, Zs).

hanoi_moves(N, Moves) :- hanoi(N, a, b, c, Moves).

hanoi(s(0), A, B, _, [move(A,B)]).
hanoi(s(N), A, B, C, Moves) :-
    hanoi(N, A, C, B, Moves1),
    hanoi(N, C, B, A, Moves2),
    list_append(Moves1, [move(A,B)|Moves2], Moves).
The classic recursive puzzle, and the program is the statement of the solution: to move s(N) disks from A to B using C, move N from A to C, then move the bottom disk from A to B, then move the N from C to B. Note how the three pegs simply rotate between the recursive calls.

The two pictures are the base case and the general case: one disk moves straight across, and s(N) disks move as three smaller problems in sequence.

?- hanoi_moves(s(s(0)), M).
Expected answer:

M = [move(a,c),move(a,b),move(c,b)] ?
Three moves for two disks. And three disks:

?- hanoi_moves(s(s(s(0))), M).
Expected answer:

M = [move(a,b),move(a,c),move(b,c),move(a,b),move(c,a),move(c,b),move(a,b)] ?
Seven moves — as the picture on the slide shows, and as the formula 2ⁿ−1 says.

The program returns the moves as a list rather than printing them, so the plan is data: you can inspect it, count it with length/2, check it, or feed it to something else. That is worth contrasting with the usual imperative version, which prints as it goes and leaves you nothing.

Slide 80 — Learning to compose recursive programs

Which is the summary of the whole deck. Every program in it was written the same way: decide the type of the data, write the base case, write the step case assuming the recursive call works. The data structure tells you the shape of the program.

Everything here has been pure: no cut, no assert, no arithmetic evaluation, no negation. The next deck adds those — and much of what it has to say is about what they cost.