McCarthy's nondeterministic operator
amb [24, 4, 32] is as old as
Lisp itself, although it is present in no Lisp.
amb takes zero or more expressions, and makes a
nondeterministic (or ``ambiguous'') choice among them,
preferring those choices that cause the program to
converge meaningfully. Here we will explore an
embedding of amb in Scheme that makes a depth-first
selection of the ambiguous choices, and uses Scheme's
control operator call/cc to backtrack for alternate
choices. The result is an elegant backtracking
strategy that can be used for searching problem spaces
directly in Scheme without recourse to an extended
language. The embedding recalls the continuation
strategies used to implement Prolog-style logic
programming [16, 7], but is sparer because the
operator provided is much like a Scheme boolean
operator, does not require special contexts for its
use, and does not rely on linguistic infrastructure
such as logic variables and unification.
An accessible description of amb and many example
uses are found in the premier Scheme textbook
SICP [1]. Informally,
amb takes zero or more expressions and nondeterministically returns the value of one of
them. Thus,
(amb12)
may evaluate to 1 or 2.
amb called with no expressions has no
value to return, and is considered to fail.
Thus,
(amb)
-->ERROR!!!ambtreeexhausted
(We will examine the wording of the error message later.)
In particular, amb is required to return a value if at
least one its subexpressions converges, ie, doesn't fail.
Thus,
(amb1 (amb))
and
(amb (amb) 1)
both return 1.
Clearly, amb cannot simply be equated to its first
subexpression, since it has to return a non-failing
value, if this is at all possible. However, this is not
all: The bias for convergence is more stringent than a
merely local choice of amb's subexpressions. amb
should furthermore return that convergent value
that makes the entire program converge. In
denotational parlance, amb is an angelic
operator.
For example,
(amb#t#f)
may return either #t or #f, but in the program
(if (amb#t#f)
1
(amb))
the first amb-expression must return #t.
If it returned #f, the if's ``else'' branch would be
chosen, which causes the entire program to fail.
In our implementation of amb, we will favor
amb's subexpressions from left to right. Ie, the
first subexpression is chosen, and if it leads to overall
failure, the second is picked, and so on. ambs occurring
later in the control flow of the program are searched for
alternates before backtracking to previous ambs. In
other words, we perform a depth-first search of the
ambchoice tree, and whenever we brush against
failure, we backtrack to the most recent node of the tree
that offers a further choice. (This is called chronological backtracking.)
We first define a mechanism for setting the base failure
continuation:
When amb fails, it invokes the continuation bound at
the time to amb-fail. This is the continuation invoked
when all the alternates in the amb choice tree have been
tried and were found to fail.
We define amb as a macro that accepts an indefinite
number of subexpressions.
A call to amb first stores away, in
+prev-amb-fail, the amb-fail value that was
current at the time of entry. This is because the
amb-fail variable will be set to different failure
continuations as the various alternates are tried.
We then capture the amb's entry continuation +sk, so
that when one of the alternates evaluates to a non-failing
value, it can immediately exit the amb.
Each alternate alt is tried in sequence (the
implicit-begin sequence of Scheme).
First, we capture the current continuation +fk, wrap it
in a procedure and set amb-fail to that procedure. The
alternate is then evaluated as (+skalt). If alt
evaluates without failure, its return value is fed to the
continuation +sk, which immediately exits the amb
call. If alt fails, it calls amb-fail. The first
duty of amb-fail is to reset amb-fail to the value
it had at the time of entry. It then invokes the failure
continuation +fk, which causes the next alternate, if
any, to be tried.
If all alternates fail, the amb-fail at amb entry,
which we had stored in +prev-amb-fail, is
called.
Thus (number-between16) will first
generate 1. Should that fail, the loop iterates,
producing 2. Should that fail, we get 3, and
so on, until 6. After 6, loop is called with
the number 7, which being more than 6, invokes
(amb), which causes final failure. (Recall that
(amb) by itself guarantees
failure.) At this point, the program containing the
call to
(number-between16) will backtrack to the
chronologically previous amb-call, and try to
satisfy that call in another fashion.
The guaranteed failure of (amb) can be used to program
assertions.
This seems devilishly simple, except that when called as
a program with any number (say 20), it will produce the
uninteresting first solution, ie, 2.
We would certainly like to get all the solutions,
not just the first. In this case, we may want all
the primes below
20. One way is to explicitly call the failure
continuation left after the program has produced its first
solution. Thus,
(amb)
=>3
This leaves yet another failure continuation, which can
be called again for yet another solution:
(amb)
=>5
The problem with this method is that the program is
initially called at the Scheme prompt, and successive
solutions are also obtained by calling amb at the Scheme
prompt. In effect, we are using different programs (we
cannot predict how many!), carrying over information from a
previous program to the next. Instead, we would like to be
able to get these solutions as the return value of a
form that we can call in any context. To this end, we
define the
bag-of macro, which returns all
the successful instantiations of its argument. (If the argument
never succeeds, bag-of returns the empty list.)
Thus, we could say,
bag-of first saves away its entry amb-fail. It
redefines amb-fail to a local continuation +k created within an
if-test. Inside the test, the bag-of argument e
is evaluated.
If e succeeds, its result is collected
into a list called +results, and the local continuation
is called with the value #t. This causes the
if-test to succeed, causing e to be retried at its
next backtrack point. More results for e are obtained this
way, and they are all collected into +results.
Finally, when e fails, it will call the base
amb-fail, which is simply a call to the local
continuation with the value #f. This pushes control
past the if. We restore amb-fail to
its pre-entry value, and return the +results. (The
reverse! is simply to produce the results in the order
in which they were generated.)
The power of depth-first search coupled
with backtracking becomes obvious when applied to solving
logic puzzles. These problems are extraordinarily difficult
to solve procedurally, but can be solved concisely and
declaratively with amb, without taking anything away
from the charm of solving the puzzle.
The Kalotans are a tribe with a peculiar quirk.8 Their males always
tell the truth. Their females never make two consecutive
true statements, or two consecutive untrue statements.
An anthropologist (let's call him Worf) has begun to
study them. Worf does not yet know the Kalotan
language. One day, he meets a Kalotan (heterosexual)
couple and their child Kibi. Worf asks Kibi: ``Are you
a boy?'' Kibi answers in Kalotan, which of course Worf
doesn't understand.
Worf turns to the parents (who know English) for
explanation. One of them says: ``Kibi said: `I am a
boy.' '' The other adds: ``Kibi is a girl. Kibi lied.''
Solve for the sex of the parents and Kibi.
--
The solution consists in introducing a bunch of variables,
allowing them to take a choice of values, and
enumerating the conditions on them as a sequence of
assert expressions.
The variables: parent1,
parent2, and kibi are the sexes of the parents (in
order of appearance) and Kibi; kibi-self-desc is
the sex Kibi claimed to be (in Kalotan); kibi-lied?
is the boolean on whether Kibi's claim was a lie.
A note on the helper procedures: The procedure
distinct? returns true if all the elements in its
argument list are distinct, and false otherwise. The
procedure xor returns true if only one of its two
arguments is true, and false otherwise.
Typing (solve-kalotan-puzzle) will solve the puzzle.
It has been known for some time (but not proven until
1976 [28]) that four colors suffice to
color a terrestrial map -- ie, to color the countries
so that neighbors are distinguished. To actually
assign the colors is still an undertaking, and the
following program shows how nondeterministic
programming can help.
The following program solves the problem of coloring a
map of Western Europe. The problem and a Prolog
solution are given in The Art of
Prolog [30]. (It is instructive to compare
our solution with the book's.)
The procedure choose-color nondeterministically
returns one of four colors:
In our solution, we create for each country a data
structure. The data structure is a 3-element list: The
first element of the list is the country's name; the
second element is its assigned color; and the third
element is the colors of its neighbors. Note we use
the initial of the country for its color
variable.9 Eg, the list for Belgium is
(list'belgiumb (listfhlg)), because -- per
the problem statement -- the neighbors of Belgium are
France, Holland, Luxembourg, and Germany.
Once we create the lists for each country, we state the
(single!) condition they should satisfy, viz, no
country should have the color of its neighbors. In
other words, for every country list, the second element
should not be a member of the third element.
(definecolor-europe
(lambda ()
;choose colors for each country
(let ((p (choose-color)) ;Portugal
(e (choose-color)) ;Spain
(f (choose-color)) ;France
(b (choose-color)) ;Belgium
(h (choose-color)) ;Holland
(g (choose-color)) ;Germany
(l (choose-color)) ;Luxemb
(i (choose-color)) ;Italy
(s (choose-color)) ;Switz
(a (choose-color)) ;Austria
)
;construct the adjacency list for;each country: the 1st element is;the name of the country; the 2nd;element is its color; the 3rd;element is the list of its;neighbors' colors
(let ((portugal
(list'portugalp
(liste)))
(spain
(list'spaine
(listfp)))
(france
(list'francef
(listeisbgl)))
(belgium
(list'belgiumb
(listfhlg)))
(holland
(list'hollandh
(listbg)))
(germany
(list'germanyg
(listfashbl)))
(luxembourg
(list'luxembourgl
(listfbg)))
(italy
(list'italyi
(listfas)))
(switzerland
(list'switzerlands
(listfiag)))
(austria
(list'austriaa
(listisg))))
(let ((countries
(listportugalspainfrancebelgiumhollandgermanyluxembourgitalyswitzerlandaustria)))
;the color of a country;should not be the color of;any of its neighbors
(for-each
(lambda (c)
(assert
(not (memq (cadrc)
(caddrc)))))
countries)
;output the color;assignment
(for-each
(lambda (c)
(display (carc))
(display" ")
(display (cadrc))
(newline))
countries))))))
Type (color-europe) to get a color assignment.
7 SICP names this procedure
require. We use the identifier assert in order to
avoid confusion with the popular if informal use of
the identifier require for something else, viz, an
operator that loads code modules on a per-need basis.