Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Declarative Programming

Part VI checked programs before running them, but how a Bridger program computes has not changed since Part V: a function receives arguments and works out a result from them, step by step. The program below answers a question about a family. The family is a list of pairs (p, c), each saying that p is a parent of c. parents_of(family, c) keeps the pairs whose second component is c and returns their first components. ancestors(family, who) collects who’s parents and, after each parent, that parent’s ancestors, in a list held in a ref cell:

fn parents_of(family: [(String, String)], child: String) -> [String] =
    map(filter(family, |p| p.1 == child), |p| p.0);

fn ancestors(family: [(String, String)], who: String) -> [String] {
    ref found = [];
    for p in parents_of(family, who) {
        found := deref found ++ [p] ++ ancestors(family, p);
    }
    deref found
}

fn main() {
    let family = [("tom", "bob"), ("tom", "liz"), ("bob", "ann"),
                  ("bob", "pat"), ("pat", "jim")];
    println(ancestors(family, "jim"));
}

It prints ["pat", "bob", "tom"]: jim’s parent, that parent’s parent, and so on up.

ancestors answers one question, who a given person’s ancestors are. Asking for tom’s descendants takes a second function, which filters on the first component and recurses on the second. Asking whether tom is an ancestor of jim takes a third, or a search through the first function’s result. The family is the same in all three, and so is what it means to be an ancestor: a parent is an ancestor, and so is a parent’s ancestor. Each function encodes that definition again, walking the pairs in its own direction.

Bridger’s relations let a program state the definition once. In the program below, relation declares a relation and the types of its columns, each add records one fact, and the two rules define ancestor:

relation parent   : (String, String);
relation ancestor : (String, String);

rule ancestor(x, y) :- parent(x, y);
rule ancestor(x, z) :- parent(x, y) and ancestor(y, z);

fn main() {
    add parent("tom", "bob");
    add parent("tom", "liz");
    add parent("bob", "ann");
    add parent("bob", "pat");
    add parent("pat", "jim");

    println(solutions ancestor(?a, "jim"));
    println(solutions ancestor("tom", ?d));
    println(ancestor("tom", "jim"));
}

The rules read as sentences: x is an ancestor of y if x is a parent of y; x is an ancestor of z if x is a parent of some y and y is an ancestor of z. A query names a relation and marks the place an answer goes with a hole, ?a or ?d. solutions lists the values that fill the holes, and a query with no holes is a yes-or-no question. The program prints:

["bob", "pat", "tom"]
["ann", "bob", "jim", "liz", "pat"]
true

One pair of rules answers all three questions. No line of the program chooses a person to visit first or a direction to walk the pairs in.

Replacing the functions with rules raises these questions:

  • What does a rule mean, if it does not say how to compute anything? The rules state when a fact holds. Which facts does that make true, and how is it decided?
  • Why are these five people tom’s descendants, and no others? A relation that named everyone as an ancestor of everyone would satisfy both rules too. What picks out the answer the program printed?
  • How do facts and rules fit inside a language of expressions and a store? In the program above, ordinary code in main adds the facts, and the answers come back as ordinary values.

Programming by stating what holds, and leaving a system to work out what follows, took shape in the early 1970s, where automated theorem proving met natural-language processing.

Logic as a program

The theorem provers of the 1960s took a set of logical statements and searched for a proof that some further statement followed from them. J. A. Robinson’s resolution principle (1965) gave them a single inference step to search with, together with the matching operation it depends on, the subject of the next chapter. A prover’s search was its own business: the statements said what was true, and the prover decided which inferences to try and in what order.

In 1974, Robert Kowalski argued that a restricted form of such statements could be read as a program. The restriction is to clauses with a single conclusion and a conjunction of conditions, B if A₁ and … and Aₙ. Alfred Horn had studied that form in 1951, and such clauses are called Horn clauses after him. Kowalski read the clause as a procedure: to establish B, establish A₁, then the rest. Over the same years Alain Colmerauer’s group in Marseille, building a system that answered questions put to it in French, wrote its reasoning as such clauses and built Prolog (1972) to run them. Kowalski’s later “Algorithm = Logic + Control” (1979) set out the division this made possible. A program’s logic states what is true, its control states how the search for consequences proceeds, and the two can be written and changed separately.

Databases arrived at relations from a different direction. Edgar Codd’s relational model (1970) stored data as relations, sets of rows of equal length, and asked queries by describing the rows wanted, leaving the system to find them. The query languages built on it could join, select, and project, but no query among them could compute an ancestor relation of unbounded depth. Alfred Aho and Jeffrey Ullman showed (1979) that the relational algebra cannot express transitive closure, the very construction ancestor performs, and proposed adding a fixpoint operator to supply it.

The two lines met in work on deductive databases, gathered in Hervé Gallaire and Jack Minker’s Logic and Data Bases (1978): stored relations as in Codd’s model, plus Horn-clause rules that derive further relations from them. The fragment of Prolog that suits this use keeps the rules but restricts their arguments to constants and variables, so a rule can relate values it is given and never constructs new ones. By the mid-1980s that fragment was called Datalog; Ceri, Gottlob, and Tanca’s survey (1989) collected what was known about it.

Relations, facts, and rules

A relation is a set of tuples, all with the same number of components. parent is a relation with two columns, both strings, and the program above gave it five tuples:

parentchild
tombob
tomliz
bobann
bobpat
patjim

Each tuple is a fact: parent("bob", "pat") says that bob is a parent of pat. Because a relation is a set, its facts have no order, and recording the same fact twice leaves the relation as it was. A query’s answer is a set as well: in a family where two lines of descent meet at one person, ancestors would list that person once for each line, and a query lists them once.

A rule derives facts from facts. It has a head, the atom to the left of :-, and a body, the conjunction of atoms to the right; read logically, the head holds whenever the body does. Each variable in a rule stands for every value at once, so the second ancestor rule says: for all x, y, and z, if parent(x, y) and ancestor(y, z) then ancestor(x, z). The variable y appears in the body and not in the head, and the reading is the same as “if x is a parent of some y, and that y is an ancestor of z”. A rule is a Horn clause, with one conclusion and a conjunction of conditions.

A rule has the shape of the inference rules used since Program semantics: premises above a horizontal bar, the conclusion below it. Written that way, the two ancestor rules are:

and a fact is a rule with no premises. A fact holds when it has a derivation, a tree of rule instances whose leaves are facts. This is the tree for ancestor("tom", "jim"):

Each step instantiates a rule: the bottom step is with x as tom, y as bob, and z as jim. An evaluation derivation in Part II showed that an expression has a value. This one shows that a fact follows from the facts recorded and the rules.

The facts a program means

The relational program’s meaning is the set of facts that have derivations. For the family, that is the five parent facts and nine ancestor facts: each of the five parent pairs, together with tom–ann, tom–pat, tom–jim, and bob–jim.

The same set can be described without derivations, and the description answers the question of why the answer is exactly this set. Call a set of facts closed under the rules when every rule whose body holds in the set has its head in the set too. Many sets are closed. The set that contains every parent fact and every possible ancestor pair of the six people is closed, since it has every head a rule could ask for. It also holds ancestor("jim", "tom"), which no derivation produces. The meaning is the least closed set: the one that contains the recorded facts, is closed under the rules, and is contained in every other set with those two properties.

The set of derived facts is that least closed set, and the argument has two halves. First, take any closed set that contains the recorded facts. A derivation starts from recorded facts, which are in the set, and each step applies a rule to facts already in the set, which a closed set answers with the rule’s head. So every derived fact is in every such set. Second, the set of derived facts is itself closed: if every premise of a rule instance has a derivation, placing the rule beneath those derivations gives one for its head. The derived facts form a closed set contained in every other, which makes them the least.

Writing for a program’s rules and for its recorded facts, this least set is written , as in the language reference. It is the meaning of the rules and facts together. Changing a rule or a fact can change it. The order of the rules, the order of the conditions in a body, and the order in which facts were recorded cannot. How an interpreter computes , and why that computation always ends, is the subject of Evaluating relations.

Queries

A query asks about . A query ancestor(?a, "jim") has one hole, and a solution is a value for the hole that makes the query a member of the set: ?a as bob, as pat, or as tom. ancestor("tom", ?d) puts the hole in the other column and finds five solutions. A query with no holes, ancestor("tom", "jim"), has one solution with nothing to fill in when the fact is in the set and none when it is not, which makes it a yes-or-no question. The holes go wherever the question needs them, because is a set of facts, and a set has no direction to walk it in.

Stored and derived relations

The family program splits its relations two ways. parent holds facts recorded one at a time; it is defined by listing its members, its extension, and the recorded facts are called the extensional database, or EDB. ancestor holds facts derived by rules; it is defined by a condition its members meet, its intension, and the relations defined by rules are called the intensional database, or IDB. The terms come from the deductive-database work above.

The rules for ancestor mention no person. Recording a different family’s parent facts and asking the same queries gives that family’s ancestors, with no change to the rules.

Bridger’s relations

Bridger takes Datalog’s restriction and embeds the result in the rest of the language. Relations and rules are top-level definitions; facts arrive from ordinary code with add; and the answers to queries come back as ordinary values. The grammar gives the forms.

Declaring relations

A declaration names a relation and the types of its columns:

relation parent : (String, String);

The types are checked like any other. add parent("tom", 3) is rejected before the program runs, with type mismatch: expected String, found Int at the 3. Answering a query compares column values for equality, so a column’s type must be one that admits equality, which rules out a function type. Rule bodies are typed by the same checker, and Rule bodies and safety gives their rules.

Rules and program facts

A rule is a top-level definition, so its position in the file does not matter, as for every other definition. Lowercase names in a rule are its logic variables; each term, an argument of an atom in a rule, is a logic variable or a literal and nothing else. A relation with several rules holds the facts derived by any of them, so two rules for one relation read as an “or”.

Because a term is only a variable or a literal, a rule cannot compute a new value. The grammar has no place for n + 1 as a term, and this rule is a parse error at the +:

rule nat(n + 1) :- nat(n);

A rule with no body is a fact that belongs to the program. These two give the program a family to start from, with no add needed:

rule parent("tom", "bob");
rule parent("bob", "ann");

Adding and clearing facts

The arguments of add are ordinary expressions, unlike a rule’s terms: add R(e₁, …, eₙ) evaluates each eᵢ, which may be n + 1 or a function call, and records the fact made of the resulting values. clear R removes the facts add recorded for R. A program’s own facts, from body-less rules, are part of , and clear leaves them. With the two program facts above, this main:

fn main() {
    add parent("ann", "sue");
    println(solutions ancestor(?a, "sue"));
    clear parent;
    println(solutions ancestor(?a, "sue"));
    println(solutions ancestor(?a, "ann"));
}

prints ["ann", "bob", "tom"], then [], then ["bob", "tom"]: once the added fact is gone, sue has no ancestors, and ann keeps the two that the program’s own facts give her.

The recorded facts are the of : the relation database, which add and clear change and every query reads. It becomes a third component of the evaluation judgment, beside the store, going in and coming out of every rule:

Every rule from earlier chapters passes through unchanged, threading it in evaluation order as it threads the store. add and clear are the rules that change it:

E-AddFact takes a union, so adding a fact already present changes nothing. Neither rule removes a single fact: between one clear and the next, only grows, and the facts a query can see are the facts recorded so far. A query reads and never changes it.

The relation database is kept apart from the store for that reason. A store cell holds one value at a time, and := replaces it, so what a cell held earlier is gone. The facts in accumulate, and the meaning of a query is a fixed set, , that the query’s own evaluation cannot alter.

Asking queries

The family program used a query in each of the three places one may appear. solutions q is a list of the hole values of every solution, a single value for each solution when the query has one hole and a tuple when it has more. A hole whose value is not needed can be left unnamed, as a bare ?: ancestor("bob", ?) asks whether bob has any descendant. A query where a Bool is expected is true when it has a solution. And for q { … } runs its body once for each solution with the holes bound as variables:

for ancestor(?a, "ann") {
    println(a ++ " is an ancestor of ann");
}

prints bob’s line and then tom’s. The solutions come in a fixed canonical order, sorted by their hole values, which is why the family program printed ["bob", "pat", "tom"] where the ancestors function printed ["pat", "bob", "tom"]. A query’s meaning is a set, and the canonical order gives that set one listing that does not depend on the order the facts were added or derived. The rules for the three forms are in Querying relations.

Building it

The query forms all need , which Evaluating relations computes. What can be built now is the relation database and the two forms that change it, add and clear. The provided runtime keeps as a RelationDb on the interpreter, beside the store. Like the store, it is mutated in place, so its two operations are the step from to :

impl RelationDb {
    fn add(&mut self, relation: &str, tuple: Tuple) -> bool; // E-AddFact
    fn clear(&mut self, relation: &str);                     // E-Clear
}

The relation forms reach eval_expr as the Relation variant of Expr, holding a Rel:

enum Rel {
    Add(Query),        // add R(a, …)
    Clear(Name),       // clear R
    Solutions(Query),  // solutions R(a, …)
    Query(Query),      // a bare query R(a, …)
}

struct Query { name: Name, args: Vec<QArg> }   // R(a, …)

enum QArg {
    Expr(Expr),          // a ground argument
    Hole(Option<Name>),  // a hole: ?x (named) or ? (anonymous)
}

A relation’s name is resolved like any other name. The initial environment binds each declared relation to a Value::Relation(name), so add and clear look the name up in the environment; a name bound to anything else is stuck, with NotARelation. add evaluates its arguments left to right as E-AddFact does. Because Add holds a Query, its arguments are QArgs, and one of them may be a hole. A hole has no value to record: the type checker rejects it before the program runs, and the evaluator, given one anyway, is stuck with HoleInAdd. When the number of arguments differs from the relation’s declared number of columns, the evaluator is stuck with ArityMismatch, the same error a function call with the wrong number of arguments gives. The Add and Clear cases each yield Value::Unit. The Solutions, Query, and ForQuery cases wait until can be computed.

The design space

Rules that build values

Prolog’s terms can be compound: a rule may build a structure from its variables, and a structure may appear inside a fact. Numbers in the style of Peano are two rules, one for zero and one for a successor:

nat(z).
nat(s(N)) :- nat(N).

The second rule derives nat(s(z)) from nat(z), then nat(s(s(z))), and so on without end, so the set of facts these rules mean is infinite. Lists, trees, and every other data structure in Prolog are compound terms of this kind, which is what makes Prolog a general-purpose language.

Datalog gives them up. Without compound terms, the only values a rule can put in a head are values already present in the facts, and finitely many facts contain finitely many values. A relation with two columns over six people can hold at most thirty-six facts, and so is finite for every program. Bridger makes the same choice by limiting a term to a variable or a literal, which is why rule nat(n + 1) :- nat(n); does not parse: it would derive a new integer from each one it had. A program that needs a new value, such as the result of arithmetic, computes it in ordinary Bridger code, which can loop over a query’s solutions and add facts holding the results.

Search order

Prolog runs a query by searching from the query backwards, trying the clauses for a relation in the order they are written and the conditions of a body from left to right. With the conditions of the second rule swapped, the logic is unchanged:

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

Asked for tom’s descendants, Prolog finds each of them, and then, asked for one more, enters the second clause again, whose first condition is again an ancestor query with tom as its first argument. The search recurses until it runs out of stack. A query with no solution, such as whether jim is an ancestor of tom, never answers at all. In Kowalski’s terms, the logic is the same and the control has changed, and a Prolog programmer orders clauses and conditions so the search terminates.

The same swap in Bridger, rule ancestor(x, z) :- ancestor(x, y) and parent(y, z);, prints exactly what the original printed. A query’s answer is , which the order of rules and conditions does not affect, and Bridger computes the whole set, which is finite. The cost is that it computes all of it: to answer whether tom is an ancestor of jim, it derives all nine ancestor facts, where a search backwards from the query visits only the people on the way up from jim. Datalog systems recover much of that saving by rewriting a program’s rules, given a query, so that computing the whole set derives only the facts relevant to that query.

Where the facts come from

SQL, the query language of most relational databases, has been able since its 1999 standard to define a relation by recursion. This query, run against a parent table holding the family, returns bob, pat, and tom:

WITH RECURSIVE ancestor(anc, des) AS (
    SELECT parent, child FROM parent
  UNION
    SELECT p.parent, a.des
    FROM parent p JOIN ancestor a ON p.child = a.anc
)
SELECT anc FROM ancestor WHERE des = 'jim';

The UNION combines a base case and a recursive step, the two ancestor rules in another syntax. In SQL the facts live in a database, filled and changed by statements outside the query.

Standalone Datalog systems such as Soufflé read their facts from input files and write derived relations out, and they declare a type for each column, as Bridger does. One of their main uses is program analysis: the facts describe a program’s statements, and the rules derive facts about how its values flow. Prolog keeps facts and rules in one database that a program can change while it runs, adding clauses with assert and removing them with retract, in the middle of a search that reads them.

Bridger puts the facts in the hands of the host language. Imperative or functional code computes them and adds them; the rules are fixed when the program is written; and a query’s answer arrives as a list, a Bool, or a loop variable for the host code to keep working with. Facts accumulate between clears, so a query is always asked of a fixed set.

Further reading

Datalog: Ceri, Gottlob, and Tanca’s “What You Always Wanted to Know About Datalog (and Never Dared to Ask)” (1989) defines the language and its meaning and surveys the evaluation methods known at the time. Abiteboul, Hull, and Vianu’s Foundations of Databases (1995), which the authors make freely available, covers Datalog, its evaluation, and its extensions with negation in its chapters 12–15, alongside the relational model those chapters build on.

Datalog in use: Green, Huang, Loo, and Zhou’s “Datalog and Recursive Query Processing” (2013) covers evaluation techniques and the applications that revived Datalog after 2000, among them networking, program analysis, and security. Jordan, Scholz, and Subotić’s “Soufflé” (2016) describes a Datalog engine built for analysing large programs.

Logic programming: Sterling and Shapiro’s The Art of Prolog (2nd ed., MIT Press, 1994) teaches Prolog in full, with compound terms, its search order, and control constructs such as the cut.

The relational model: Codd’s “A Relational Model of Data for Large Shared Data Banks” (1970) introduces relations as the structure of a database and the operations on them.

Concept checks

Add ancestor("liz", "jim") to the fourteen facts the family program means. Is the new set closed under both rules? Why is it not the program's meaning?

It is closed. The only rule that could use the new fact is , with ancestor("liz", "jim") as its second premise, and it needs a parent of liz: tom. Its head, ancestor("tom", "jim"), is already in the set. So the set contains the recorded facts and is closed, but it is not the least such set: the set without ancestor("liz", "jim") is closed too and smaller. No derivation ends in ancestor("liz", "jim"), and the meaning holds only facts that have one.

Write a rule for grandparent(x, z) over parent. Which pairs does it give for the family, and is grandparent part of the EDB or the IDB?
rule grandparent(x, z) :- parent(x, y) and parent(y, z);

solutions grandparent(?g, ?c) is [("bob", "jim"), ("tom", "ann"), ("tom", "pat")], in canonical order. The rule is not recursive: a grandparent is exactly two parent steps up. grandparent is defined by a rule, so it is part of the IDB; parent, whose facts are added, is the EDB.

Swapping the conditions of the second ancestor rule changes nothing in Bridger and sends Prolog into unbounded recursion. What does each language take the order of conditions to mean?

To Prolog, the order is control: conditions are tried left to right, so ancestor(X, Y), parent(Y, Z) starts by asking a new ancestor query with the same first argument, which starts the same way, without end. To Bridger, the order means nothing: a body is a conjunction, a query’s answer is the least closed set , and a conjunction is the same in either order. Bridger pays for that by computing the whole of ; Prolog’s search, when it terminates, visits only what the query leads it to.

Bridger rejects rule nat(n + 1) :- nat(n); at parse time. What guarantee would accepting it give up?

That the values in the program’s facts and rules limit how many facts can be derived, and so how long computing takes. With rule nat(0); as a program fact, the rule would derive nat(1), then nat(2), and so on, a new fact each round. Bridger’s integers wrap at , so the rounds would end only after about of them, and solutions nat(?n) would not return in any practical sense. With terms limited to variables and literals, every value in a derived fact already appears in the program’s facts or rules, and a finite set of values can be arranged into only finitely many facts.

ancestors returned ["pat", "bob", "tom"], while solutions ancestor(?a, "jim") returned ["bob", "pat", "tom"]. Why does Bridger sort solutions rather than list them in the order they were found?

The order a function returns is the order its code visits the pairs. A query has no code of its own: its answer is the set , and the order in which facts are derived depends on how an interpreter computes that set, and on the order the facts were added, neither of which changes the set. Sorting by hole values gives every correct interpreter the same list for the same facts, so the order a program prints in, and anything it computes from that order, depends on the facts alone.