Building a Meta-Interpreter

Course 2 · Ch 6
Building a Meta-Interpreter
A Prolog interpreter written in Prolog — the real payoff of genuine homoiconicity

haskell2-8 closed the Haskell track with a capstone interpreter: a hand-built Expr a GADT representing a small custom expression language as data, plus an evalM function pattern-matching over it. That interpreter needed real, deliberate design — Haskell had to invent a data representation of "a program" from scratch, entirely separate from actual Haskell syntax. This chapter builds a Prolog interpreter in Prolog, and it comes out startlingly short — for a reason worth naming precisely.

What "Meta-Interpreter" Means

A meta-interpreter is a program that executes other programs — an interpreter written in the same language it interprets. Here, that means writing a Prolog predicate, solve/1, that itself carries out the job Prolog's own engine normally does: given a goal, prove it by finding and running matching clauses.

Homoiconicity — Code Is Data

A clause body like parent(X, Y), parent(Y, Z) isn't secretly encoded as something else internally — it is the ordinary term ','(parent(X,Y), parent(Y,Z)), built from the same ,/2 functor that appears anywhere else a comma joins two things. Prolog source code is, structurally, just Prolog terms — the same terms every other chapter in this course has manipulated with unification, findall, and pattern matching. This is homoiconicity: code and data share one representation, with no translation step required to go from "a program" to "a value my own language can inspect."

The Core Meta-Interpreter

solve(true) :- !. solve((A, B)) :- !, solve(A), solve(B). solve(A) :- clause(A, Body), solve(Body).

Three clauses, cut-guarded so each case commits once matched, exactly the way prolog1-8 taught you to read cut. clause(Head, Body) is a built-in that looks up a user-defined clause matching Head, unifying Body with its body — true for an ordinary fact, backtracking over every matching clause if there's more than one.

Tracing solve/1 By Hand

parent(tom, bob). parent(tom, liz). parent(bob, ann). parent(liz, jim). grandparent(X, Z) :- parent(X, Y), parent(Y, Z). ?- solve(grandparent(tom, Z)). Z = ann ; Z = jim.

solve(grandparent(tom, Z)) falls through to the third clause: clause(grandparent(tom, Z), Body) unifies Body with (parent(tom, Y), parent(Y, Z)). Recursing into solve/1 on that body hits the second clause instead — a conjunction — splitting into solve(parent(tom, Y)) followed by solve(parent(Y, Z)). The first call looks up parent(tom, Y) via clause/2 again, finds Body = true for Y = bob (or, on backtracking, Y = liz), and solve(true) succeeds via the first clause. The second call repeats the same process for parent(Y, Z), producing Z = ann or Z = jim depending on which Y was chosen. Nothing here is special-cased for grandparent/2 specifically — solve/1 genuinely re-derives the answer the exact same way Prolog's own engine would, using only clause/2 lookups and ordinary unification.

The Real Comparison — haskell2-8's Interpreter vs. This One

haskell2-8's Expr a GADT needed dedicated constructors for every piece of its toy language — IntLit, BoolLit, Add, Div, If — and its evalM function had to pattern-match each one out by hand, because Haskell source code and Haskell data are two entirely separate things; representing "a program" as a value required inventing a whole new type just for that purpose. solve/1 needed no such invention. It interprets actual Prolog — real conjunctions, real user-defined clauses, not a small toy subset reinvented for the exercise — because a Prolog goal already is exactly the kind of term Prolog itself is built to unify, inspect, and recurse over. Three clauses were enough precisely because nothing needed translating first.

Extending the Interpreter — Disjunction and Negation

solve(true) :- !. solve((A, B)) :- !, solve(A), solve(B). solve((A ; B)) :- !, (solve(A) ; solve(B)). solve(\+ A) :- !, \+ solve(A). solve(A) :- clause(A, Body), solve(Body).

Two new clauses, inserted before the generic fallback so their own cuts get the chance to commit first. The disjunction clause simply solves either branch. The negation clause is the most telling one — it reuses the host language's own \+ directly on the result of a recursive solve call, rather than reimplementing prolog2-3's negation-as-failure logic from scratch. solve/1 stays a thin, faithful layer over the real Prolog engine, not a full reimplementation of one.

Representation of "a program"What had to be built
haskell2-8's Expr a interpretera hand-designed GADT, separate from real Haskell syntaxa new data type, plus a pattern-matching evaluator over it
This chapter's solve/1ordinary Prolog terms — real clauses, unchangedthree to five clauses, reusing clause/2 and \+ directly
Each clause's own cut is doing real work
Without the cut in solve(true) :- !., a later solve(A) :- clause(A, Body), ... call could still be tried for A = true on backtracking, attempting a pointless (and likely failing) clause(true, Body) lookup — the same "commit once the right case is identified" pattern prolog1-8 taught, now put to genuine, load-bearing use inside an interpreter's own dispatch logic.
This toy interpreter doesn't handle built-ins like is/2
Calling solve(X is 1 + 1) falls through to the generic clause, which tries clause(X is 1 + 1, Body) — and fails, because is/2 is a built-in predicate with no user-defined clauses for clause/2 to find. A genuinely complete meta-interpreter needs explicit cases recognizing built-ins and calling them directly (e.g. solve(G) :- built_in(G), !, call(G).) rather than routing everything through clause/2 — an honest scope boundary, left out here to keep the core idea visible.

Coding Challenges

Challenge 1

Write the three-clause solve/1 from the chapter along with the parent/2 and grandparent/2 facts and rule, then query solve(parent(tom, bob)) and solve(grandparent(tom, Z)), confirming both produce the results shown in the chapter's trace.

📄 View solution
Challenge 2

Add the disjunction and negation clauses from the chapter to solve/1, define a predicate likes(tom, pizza) and likes(tom, sushi), then query solve((likes(tom, pizza) ; likes(tom, tacos))) and solve(\+ likes(tom, tacos)), confirming both succeed correctly.

📄 View solution
Challenge 3

Write a short comment explaining why solve(X is 1 + 1) fails with this chapter's interpreter, and what a solve/1 clause recognizing built-in predicates specially (rather than routing them through clause/2) would need to look like.

📄 View solution

Chapter 6 Quick Reference

  • solve(true) :- !. — the base case, an empty body means the goal is already proven
  • solve((A, B)) :- !, solve(A), solve(B). — a conjunction is solved by solving each half in turn
  • solve(A) :- clause(A, Body), solve(Body). — the general case, looking up and recursing into a user-defined clause
  • clause(Head, Body) looks up a matching user-defined clause, giving Body = true for a plain fact
  • Homoiconicity — Prolog code already is Prolog data — is why solve/1 needed no invented representation the way haskell2-8's Expr a GADT did
  • Disjunction and negation extend solve/1 by directly reusing Prolog's own ; and \+, staying a thin layer over the real engine
  • This toy interpreter has an honest scope gap: built-in predicates like is/2 have no clause/2 entries and need explicit special-case handling