Logic Programming with Racklog: Two Classic Problems Revisited
In the previous chapter we used OPS5, a forward-chaining production rule system, to solve two classic problems: analyzing a poker hand and planning how a monkey can reach bananas hanging from a ceiling. In this chapter we solve the same two problems with Racklog, a Prolog-style logic programming library embedded in Racket. Keeping the problems identical lets us focus on what actually changed: the reasoning strategy.
Forward Chaining vs. Backward Chaining
A production system like OPS5 reasons forward. Facts sit in a
working memory. Rules continuously pattern-match against those facts,
and whenever a rule’s conditions are satisfied it fires, adding or
modifying facts. The program never states a goal explicitly; the
desired outcome emerges when the match-fire loop has transformed the
initial facts into the final ones. You saw this in the monkey example:
rules mb1 through mb18 fired one after another, narrating the
monkey’s actions until the goal fact was marked satisfied.
Logic programming reasons backward. We define relations (facts and rules that describe what is true) and then pose a query. The system works backward from the query, trying to prove it by unifying it against the heads of rules and recursively proving their bodies, backtracking whenever a choice fails. The query itself names the goal, and the answer is the set of variable bindings that make the query true.
In forward chaining we say “whenever you see this situation, do this.” In backward chaining we say “here is what it means to reach the goal; find me a way.” For the monkey problem, the difference is vivid: OPS5 performs the plan step by step, while Racklog searches for a plan and returns it as the answer to a question.
Racklog is not a separate language you shell out to; it is an ordinary
Racket library. Logic variables, unification, and backtracking are
implemented as Racket macros and procedures, so a logic program lives
inside a normal #lang racket file and can call, and be called by,
regular Racket code.
Problem One: Analyzing a Card Hand
Recall the draw poker example. The hand we analyze is exactly the one we gave OPS5:
- 10 of hearts
- 10 of diamonds
- 10 of clubs
- 4 of diamonds
We want to find every pair and every three of a kind. In OPS5 we wrote
productions that watched working memory and asserted pair and three
facts. In Racklog we do something simpler: the hand itself is a
relation, and “pair” and “three of a kind” are derived relations
defined by rules over it.
A Racklog relation is defined with %rel, which takes a list of logic
variables followed by clauses. Each clause has a head (the argument
pattern) and an optional body of goals that must all succeed. One
syntactic detail matters: clause heads are ordinary Racket expressions,
so constant symbols are quoted ('heart) and compound terms are built
with list.
The code
Here is the complete file draw.rkt:
1 #lang racket
2
3 ;; Card hand analysis in Racklog (logic programming / backward chaining).
4 ;; Same hand and queries as ../OPS5_in_Racket/draw.ops (production rules /
5 ;; forward chaining): find pairs and three of a kind.
6 ;;
7 ;; Run: racket draw.rkt
8
9 (require racklog)
10
11 ;; The hand, as facts: %card(Suit, Rank)
12 (define %card
13 (%rel ()
14 [('heart 10)]
15 [('diamond 10)]
16 [('club 10)]
17 [('diamond 4)]))
18
19 ;; Suit ordering, so each combination is reported only once
20 ;; (club 10 + diamond 10, but not also diamond 10 + club 10).
21 (define %suit<
22 (%rel ()
23 [('club 'diamond)] [('club 'heart)] [('club 'spade)]
24 [('diamond 'heart)] [('diamond 'spade)]
25 [('heart 'spade)]))
26
27 ;; %pair(Suit1, Suit2, Rank)
28 (define %pair
29 (%rel (s1 s2 n)
30 [(s1 s2 n)
31 (%card s1 n)
32 (%card s2 n)
33 (%suit< s1 s2)]))
34
35 ;; %three-of-a-kind(Suit1, Suit2, Suit3, Rank)
36 (define %three-of-a-kind
37 (%rel (s1 s2 s3 n)
38 [(s1 s2 s3 n)
39 (%card s1 n)
40 (%card s2 n)
41 (%card s3 n)
42 (%suit< s1 s2)
43 (%suit< s2 s3)]))
44
45 (printf "Card hand analysis -- Racklog version")
46 (printf " (compare with ../OPS5_in_Racket/draw.ops)\n\n")
47 (printf "Hand: 10 of hearts, 10 of diamonds, 10 of clubs, 4 of diamonds.~n~n")
48
49 (for ([p (%find-all (s1 s2 n) (%pair s1 s2 n))])
50 (printf "pair: ~a of ~a and ~a of ~a~n"
51 (cdr (assq 'n p)) (cdr (assq 's1 p))
52 (cdr (assq 'n p)) (cdr (assq 's2 p))))
53
54 (for ([t (%find-all (s1 s2 s3 n) (%three-of-a-kind s1 s2 s3 n))])
55 (printf "three of a kind: ~a of ~a, ~a, and ~a~n"
56 (cdr (assq 'n t)) (cdr (assq 's1 t))
57 (cdr (assq 's2 t)) (cdr (assq 's3 t))))
The %card relation is pure data: four facts, one per card. The
interesting part is %pair. Its single clause says: (s1 s2 n) is a
pair if %card holds for suit s1 at rank n, %card holds for a
different suit s2 at the same rank n, and s1 sorts before s2.
Because the logic variable n appears in both %card goals,
unification forces both cards to share a rank. That single shared
variable does the work that the OPS5 rule expressed with the pattern
(card <suit> <num>) matched twice against working memory.
The %suit< relation deserves a second look. The OPS5 version used
negated conditions like -(pair <suit2> <suit> <num>) to avoid
reporting the same pair twice in different orders. In logic programming
we get the same effect declaratively: by requiring s1 to come before
s2 in a fixed suit ordering, each unordered combination is generated
exactly once, and no bookkeeping facts are needed.
The two %find-all forms at the bottom are the bridge back to ordinary
Racket. %find-all runs a query, collects every solution, and returns
a plain Racket list of association lists, which we then print with an
everyday for loop. %which (used in the next example) returns just
the first solution, and %more would backtrack to the next one.
Running it
1 $ racket draw.rkt
2 Card hand analysis -- Racklog version (compare with ../OPS5_in_Racket/draw.ops)
3
4 Hand: 10 of hearts, 10 of diamonds, 10 of clubs, 4 of diamonds.
5
6 pair: 10 of diamond and 10 of heart
7 pair: 10 of club and 10 of heart
8 pair: 10 of club and 10 of diamond
9 three of a kind: 10 of club, diamond, and heart
Interpreting the output
Three cards share the rank 10, and three cards taken two at a time give exactly three pairs, which is exactly what the program reports. The lone 4 of diamonds participates in nothing and correctly never appears in the output. The single three of a kind uses all three 10s.
Compare the shape of the two solutions. The OPS5 program was about
events: rules firing, facts appearing in working memory. This program
contains no events at all: %pair is a timeless statement about what
“pair” means, and the three lines of output are simply every way the
statement can be made true for this hand. The cost of that elegance is
that control is implicit: the order in which solutions are found
depends on clause order and Racklog’s depth-first search, something we
will see matter more in the next example.
Problem Two: The Monkey and the Bananas
The monkey problem is a planning problem, and it is where the two paradigms diverge most. Recall the scenario: a monkey is on a couch at position 5-7; bananas hang from the ceiling at 2-2; a light ladder stands on the floor at 9-5. The monkey can walk, carry the ladder, climb it, and grasp the bananas.
To solve this declaratively we model the world as a state, a snapshot of everything that can change:

The bananas never move, so they are not part of the state; they are a fixed fact about the world. A plan is then a sequence of actions that transforms the initial state into any state where the monkey holds the bananas. This is the classic state-space search formulation: states are nodes, actions are edges, and planning is graph search.
In Racklog we express this with two relations. %move relates a state,
an action, and the resulting state. It is the complete “physics” of
the monkey’s world in seven clauses. %canget is the recursive
planner: from a goal state the plan is empty; from any other state the
plan is one legal move followed by a plan from the resulting state,
provided we have not visited that state before (the visited check is
what keeps depth-first search from walking in circles forever).
The code
Here is the complete file monkey.rkt:
1 #lang racket
2
3 ;; Monkey and bananas in Racklog (logic programming / backward chaining).
4 ;; Same problem as ../OPS5_in_Racket/monkey.ops (production rules / forward
5 ;; chaining): a monkey on the couch at 5-7 wants bananas on the ceiling at
6 ;; 2-2, using a light ladder on the floor at 9-5.
7 ;;
8 ;; Run: racket monkey.rkt
9
10 (require racklog)
11
12 ;; State: (state MonkeyAt MonkeyOn Holds LadderAt)
13 ;; The bananas never move, so they are not part of the state.
14
15 (define %location (%rel () [('5-7)] [('2-2)] [('9-5)]))
16
17 ;; %move: (StateBefore Action StateAfter)
18 (define %move
19 (%rel (p q h l)
20 ;; on the ladder under the bananas: grab them
21 [((list 'state '2-2 'ladder 'nothing '2-2)
22 (list 'grasp 'bananas)
23 (list 'state '2-2 'ladder 'bananas '2-2))]
24 ;; at the ladder with free hands: climb it
25 [((list 'state p 'floor 'nothing p)
26 (list 'climb 'ladder)
27 (list 'state p 'ladder 'nothing p))]
28 ;; the ladder is light: pick it up
29 [((list 'state p 'floor 'nothing p)
30 (list 'pick-up 'ladder)
31 (list 'state p 'floor 'ladder p))]
32 ;; put the ladder back down
33 [((list 'state p 'floor 'ladder p)
34 (list 'drop 'ladder)
35 (list 'state p 'floor 'nothing p))]
36 ;; carry the ladder somewhere else
37 [((list 'state p 'floor 'ladder p)
38 (list 'carry 'ladder 'to q)
39 (list 'state q 'floor 'ladder q))
40 (%location q) (%/= p q)]
41 ;; walk somewhere else
42 [((list 'state p 'floor 'nothing l)
43 (list 'walk p 'to q)
44 (list 'state q 'floor 'nothing l))
45 (%location q) (%/= p q)]
46 ;; jump down from whatever we are on
47 [((list 'state p h 'nothing l)
48 (list 'jump-down-from h)
49 (list 'state p 'floor 'nothing l))
50 (%/= h 'floor)]))
51
52 ;; %canget: (State VisitedStates Plan) -- Plan is a list of actions.
53 ;; Note: plain depth-first search, so the plan found is not guaranteed
54 ;; shortest; add iterative deepening if that ever matters here.
55 (define %canget
56 (%rel (s s2 action plan visited at on l-at)
57 ;; goal state: monkey holds the bananas
58 [((list 'state at on 'bananas l-at) visited '())]
59 [(s visited (cons action plan))
60 (%move s action s2)
61 (%not (%member s2 visited))
62 (%canget s2 (cons s visited) plan)]))
63
64 (define start '(state 5-7 couch nothing 9-5))
65
66 (printf "Monkey and bananas -- Racklog version")
67 (printf " (compare with ../OPS5_in_Racket/monkey.ops)\n\n")
68 (printf "Start: monkey on the couch at 5-7, ladder on the floor at 9-5,~n")
69 (printf " bananas on the ceiling at 2-2.~n~n")
70
71 (define answer (%which (plan) (%canget start (list start) plan)))
72
73 (if answer
74 (for ([step (cdr (assq 'plan answer))]
75 [i (in-naturals 1)])
76 (printf "~a. ~a~n" i step))
77 (printf "no plan found~n"))
Read %move the way you would read a physics textbook: each clause is
one law. “If the monkey is at the ladder, on the floor, holding
nothing, then climbing the ladder leaves the monkey on the ladder.”
Notice how much of each clause is just the same logic variable
appearing in the before-state and the after-state. The shared p in
the climb clause is what guarantees the monkey and the ladder are in
the same place, with no explicit equality test.
Read %canget as a two-line definition of “solvable”:
- A state where the monkey holds the bananas is solvable with the empty plan.
- Any other state is solvable if some move leads to an unvisited state that is itself solvable. The plan is that move prepended to the plan for the new state.
That recursive clause is the entire search algorithm. %not and
%member (both built into Racklog) implement loop detection. When we
finally pose the query with %which, Racklog’s backtracking search
does the rest, and the logic variable plan comes back bound to the
answer.
Running it
1 $ racket monkey.rkt
2 Monkey and bananas -- Racklog version (compare with ../OPS5_in_Racket/monkey.ops)
3
4 Start: monkey on the couch at 5-7, ladder on the floor at 9-5,
5 bananas on the ceiling at 2-2.
6
7 1. (jump-down-from couch)
8 2. (walk 5-7 to 2-2)
9 3. (walk 2-2 to 9-5)
10 4. (pick-up ladder)
11 5. (carry ladder to 5-7)
12 6. (carry ladder to 2-2)
13 7. (drop ladder)
14 8. (climb ladder)
15 9. (grasp bananas)
Interpreting the output
The plan is correct: jump off the couch, fetch the ladder, carry it under the bananas, climb, grasp. But look at steps 2–3 and 5–6: the monkey walks to 2-2 first (presumably to gaze longingly at the bananas), and later carries the ladder via 5-7. This is a valid plan, not the shortest one.
That detour is a direct consequence of the search strategy, and seeing
it is worth more than a prettier answer. Racklog explores depth-first
in clause order: at each state it tries grasp, then climb, then
pick-up, then drop, then carry, then walk, then jump-down,
and within walk it tries destinations in the order %location lists
them. The first complete plan it stumbles into is the one printed. OPS5
had the opposite character: its conflict-resolution strategy picked
which rule to fire next, and the OPS5 author shaped the plan by writing
rules in a careful order; control lived in the rule set rather than in
a search procedure.
If shortest plans mattered, the fix would be iterative deepening (try
all plans of length 1, then 2, then 3), which guarantees the first
solution found is minimal. For this tiny state space (three locations,
a handful of on/holds combinations) plain depth-first search
answers instantly, so we keep the simpler program.
Wrapping Up
Same problems, two tools, two philosophies:
| OPS5 (previous chapter) | Racklog (this chapter) | |
|---|---|---|
| Reasoning | forward chaining | backward chaining |
| Program is | rules over working memory | relations and queries |
| Goal is | implicit, emerges from firing | explicit, named in the query |
| Monkey output | narration of actions as they fire | a plan returned as data |
| Control lives in | rule order and conflict resolution | clause order and backtracking |
Neither is “better.” Production rules shine when a system must react
continuously to arriving facts: monitoring, diagnosis, agents. Logic
programming shines when the question itself is the program: parsing,
planning, constraint solving, anything naturally phrased as “find an
x$ such that …”. Racklog’s particular charm is that the whole thing
is just Racket, so when the logic ends (printing a plan, formatting a
hand) you are back in an ordinary general-purpose language with no
seams.
Exercises
- Add a
%two-pairrelation todraw.rktthat finds two pairs at different ranks. Test it by adding a 4 of clubs to the hand. - In
monkey.rkt, add a second ladder at 5-7. Does the plan change? Why or why not? - Replace
%whichwith%find-allinmonkey.rktto enumerate every plan. Before running it, predict whether the result is finite. - Rewrite the
%suit<relation using ranks (club = 1, diamond = 2, …) and Racklog’s%isand%<predicates. Which version do you find clearer?