I’ve been meaning to learn Prolog for more than ten years. I saw Jon Millen use it brilliantly back at MITRE, with meaningful simulations of cryptographic protocols in just a few dozen lines—but while I could read each line, the insight that allowed their selection and composition escaped me. I’ve used Datalog for plenty; it’s great for exactly the problems for which extensibility and federation make SQL such a pain. Prolog adds all sorts of operational concerns around space usage and termination. Two recent events nudged me to finally learn Prolog: first, a friend posted a Mastermind-style puzzle. Lots of folks solved it, but the conversation got into how many of the clues were necessary. I used the top hit from Googling for “Mastermind Prolog” to play with a solution, but it felt awkward and stiff—lots of boilerplate, way too many words compared to a CLP(FD) solver in Clojure or similar. A week later, Hacker News pointed to a recent ebook on modern Prolog: The Power of Prolog. I knew a little about the old way of doing arithmetic and logic problems in Prolog, with `N is X+Y`

and such; even skimming this book told me that new ways are much better.

I’ve read the first half of that book, and while I definitely still don’t understand DCGs yet, I think I can improve on that old mastermind program.

First, here’s the problem as posed by Jeff:

To start, we can write:

```
jcb(Answer) :-
% 682; 1 right & in place
mastermind([6,8,2],Answer,1,0),
% 614; 1 right but wrong place
mastermind([6,1,4],Answer,0,1),
% 206; 2 digits right but wrong place
mastermind([2,0,6],Answer,0,2).
% 738; all wrong
mastermind([7,3,8],Answer,0,0),
% 380; one right but wrong place
mastermind([3,8,0],Answer,0,1).
```

This is a succint and straightforward translation of the problem: given a guess and some unknown `Answer`

, the mastermind gives us some number of black and some number of white pegs.

We can write the mastermind program something like this:

```
mastermind(Guess,Answer,Black,White) :-
layout(Guess),
layout(Answer),
all(Guess,digit),
all(Answer,digit),
count_blacks(Guess, Answer, Black),
count_whites(Guess, Answer, N),
White is N - Black.
layout(X) :- X=[_,_,_].
digit(0).
digit(1).
digit(2).
digit(3).
digit(4).
digit(5).
digit(6).
digit(7).
digit(8).
digit(9).
% check if all elements of a list fulfill certain criteria
all([],_).
all([H|T],Function) :- call(Function,H),all(T,Function).
```

Now this isn’t so pretty. Having to list out `color(red). color(blue).`

wouldn’t feel so terrible, but having to list out digits instead of saying `digit(X) :- integer(X0, X >= 0, X<=9.`

seems ridiculous. Having to write my own `all/2`

also seems ridiculous. And the version I got from the Web went on in this style, even to having lots of cuts—something tells me that’s not right! So let’s get to rewriting.

First, we can use an excellent library for Constraint Logic Programming over Finite Domains. And since we know we’ll eventually want to treat the puzzle constraints as data, let’s make that conversion now:

```
?- use_module(library(clpfd)).
% Jeff’s specific problem
jcb_rules([mastermind([6,8,2],1,0),
mastermind([6,1,4],0,1),
mastermind([2,0,6],0,2),
mastermind([7,3,8],0,0),
mastermind([3,8,0],0,1)]).
jcb(Answer,RuleNumbers) :- maplist(jcb_helper(Answer),RuleNumbers).
jcb_helper(Answer,RuleNumber) :- jcb_rules(Rules),
nth0(RuleNumber,Rules,Rule),
call(Rule,Answer).
jcb(Answer) :- jcb(Answer,[0,1,2,3,4]).
```

We can still address the original problem with `jcb(A).`

, and indeed that’s a bunch of what I repeated while debugging as I transformed the program.

The core `mastermind`

program has only a couple changes: `Answer`

moves to the last argument, for easier use with `call`

and such. The last line and the `digits`

predicate change to use CLP(FD) constraints.

```
% How to play Mastermind
mastermind(Guess,Black,White,Answer) :-
layout(Guess),
layout(Answer),
digits(Guess),
digits(Answer),
count_blacks(Guess, Answer, Black),
count_whites(Guess, Answer, N),
N #= White + Black.
layout([_,_,_]).
digits(X) :- X ins 0..9.
```

Already I like this better: it’s shorter *and* it is more useful, because the program runs in multiple directions!

Now let’s look into how `count_blacks`

and `count_whites`

work. The first is a manual iteration over a guess and an answer. In Haskell I’d write this as something like `countBlacks = length . filter id . zipWith (==)`

, I suppose—though that would only compute one way. This can compute the number of black pegs from a guess and an answer, or the constraints on an answer from a guess & a number of black pegs, or similarly for constraints on a guess given an answer and black pegs.

```
count_blacks([],[],0).
count_blacks([H1|T1], [H2|T2], Cnt2) :- H1 #= H2,
Cnt2 #= Cnt1+1,
count_blacks(T1,T2,Cnt1).
count_blacks([H1|T1], [H2|T2], Cnt) :- H1 #\= H2,
count_blacks(T1,T2,Cnt).
```

It does the equality and the addition by constraints, which I’d hoped meant the solver could propagate from the puzzle input (number of pegs) to constraints on what the pegs are. In practice it seems to backtrack on those—I haven’t seen an intermediate state offering `A = 3 \/ 5 \/ 7`

.

`count_whites`

has to handle all the reordering and counting. There’s a library function to do that with constraints, `global_cardinality`

. All the stuff with pairs and folds is just to get data in and out of the list-of-pairs format used by `global-cardinality`

. That function also requires that the shape of the cardinality list be specifies, so `numlist`

is there to make it 9 elements long.

```
count_whites(G,A,N) :- numlist(0,9,Ds),
pairs_keys(Gcard,Ds), pairs_keys(Acard,Ds),
pairs_values(Gcard,Gvals), pairs_values(Acard,Avals),
% https://www.swi-prolog.org/pldoc/doc_for?object=global_cardinality/2
global_cardinality(G,Gcard), global_cardinality(A,Acard),
foldl(mins_,Gvals,Avals,0,N).
mins_(Gval,Aval,V0,V) :- V #= V0 + min(Gval,Aval).
```

What’s above is enough to solve the problem in the image! But my friends’ conversation quickly turned to whether some rules were superfluous. Because `library(clpfd)`

has good reflection facilities, we can quickly program something to try subsets of rules, showing us only those that fully solve the problem. This *isn’t* a constraint program; it’s ordinary Prolog-style backtracking search. For five rules, it tries \(2^5=32\) possibilities. It’s slow enough that I notice a momentary pause while it runs, even with only five rules!

First, it’s weird that there’s no powerset library function. Maybe I’m missing it?

```
% What’s the shortest set of constraints that actually solves it?
powerset([], []).
powerset([_|T], P) :- powerset(T,P).
powerset([H|T], [H|P]) :- powerset(T,P).
```

This uses a weird Prolog predicate, `findall`

, to collect all the answers that would be found from backtracking the search above, with all possible rule sets. One of Prolog’s superpowers is that it handles lots of things in a “timelike” way, by backtracking at an interactive prompt. When you want to program over those outputs, you either let the backtracking naturally thread through, or you use `findall`

to collect them into a list.

`no_hanging_goals`

filters for only those that solve the problem—“hanging goals” annotate variables that have constraints but no solution. It’s a bit of a hack with `copy_term`

, but it’s documented at the manual page that you can copy from `X`

to `X`

if you just want to look at the constraint annotations without really copying the term.

```
which_rules(Answers) :-
numlist(0,4,Rules),
powerset(Rules,Rule_Set),
findall(Rule_Set-Answer,(jcb(Answer,Rule_Set),
no_hanging_goals(Answer)),
Answers).
% https://www.swi-prolog.org/pldoc/man?predicate=copy_term/3
no_hanging_goals(X) :- copy_term(X,X,[]).
```

Last, we use `findall`

*again* to collect all the cases of rules that work with `which_rules`

, sort by length, and extract the set with the shortest rules.

```
shortest_rules(Shortest) :- findall(L-R,(which_rules([R-_]),
length(R,L),
L > 0),
X),
keysort(X,Xsort),
group_pairs_by_key(Xsort,[_-Shortest|_]).
```

Indeed, it confirms that the first three rules are the shortest set:

```
bts@Atelier ~/s/mastermind ❯❯❯ swipl revised.pl
Welcome to SWI-Prolog (threaded, 64 bits, version 8.0.3)
SWI-Prolog comes with ABSOLUTELY NO WARRANTY. This is free software.
Please run ?- license. for legal details.
For online help and background, visit http://www.swi-prolog.org
For built-in help, use ?- help(Topic). or ?- apropos(Word).
?- jcb(A).
A = [0, 4, 2] ;
false.
?- shortest_rules(Rs).
Rs = [[0, 1, 2]].
```

You can try it yourself! Grab the source from Github, install SWI Prolog, and let me know what you find.