A Couple of Meta-interpreters in Prolog



Motivation

Informally, an interpreter is a program that evaluates programs. Interpretation is pervasive in computer science both from a theoretical and practical perspective, and many programs are interpreters for domain-specific languages. For example, a program reading settings from a configuration file and adjusting itself accordingly is interpreting this "configuration language".

An interpreter for a language similar or identical to its own implementation language is called meta-interpreter (MI). An interpreter that can interpret itself is called meta-circular. Prolog is exceptionally well-suited for writing MIs: First and most importantly, Prolog programs can be naturally represented as Prolog terms and are easily inspected and manipulated using built-in mechanisms. Second, Prolog's implicit computation strategy and all-solutions predicates can be used in interpreters, allowing for concise specifications. Third, variables from the object-level (the program to be interpreted) can be treated as variables on the meta-level (the interpreter); therefore, an interpreter can delegate handling of the interpreted program's binding environment to the underlying Prolog engine.

Video: Prolog Meta-interpreters


The following simple Prolog program will serve as a running example throughout this text. I state it here using default Prolog syntax, and we will subsequently consider various different representations for it:
natnum(0).
natnum(s(X)) :-
        natnum(X).
    
Prolog can evaluate Prolog code dynamically:
?- Goal = natnum(X), Goal.
   Goal = natnum(0), X = 0
;  Goal = natnum(s(0)), X = s(0)
;  ... .
    
You can make the call explicit using the built-in call/1 predicate, yielding the equivalent query "?- Goal = natnum(X), call(Goal).". The call/n (n > 1) family of predicates lets you append additional arguments to Goal before it is called.

This mechanism ("meta-call") is used in predicates like maplist/3, include/3, if/3 etc., and can be seen as interpretation in its coarsest sense. Implicitly using features of the underlying engine is called absorption. Making features explicit is called reification. By using call/1, we absorb backtracking, unification, handling of conjunctions, the call stack etc. We can make all these features explicit and subsequently adjust and extend them at will.


Vanilla MIs

Accessing Prolog code

The definition of natnum/1 consists of two clauses, the first of which degenerated into a fact that is implicitly treated as if it were stated as
natnum(0) :- true.
    
The Prolog built-in clause/2 allows inspection of the predicate's definition:
?- clause(natnum(Z), Body).
   Z = 0, Body = true
;  Z = s(_A), Body = natnum(_A).
    
Note: Clause inspection requires the predicate to be public. A portable way to declare predicates public is to use the dynamic/1 directive, because dynamic predicates are automatically public.

The two solutions found on backtracking correspond to the two clauses of the predicate's definition.

Another example:
complicated_clause(A) :-
        goal1(A),
        goal2(A),
        goal3(A).

?- clause(complicated_clause(Z), Body).
Body = (goal1(Z), goal2(Z), goal3(Z)).
    
The body of complicated_clause/1 is represented by a compound term with functor ',' and arity 2, whose arguments are either goals or compound terms of the same structure. Here is a Prolog "type" definition of a clause body in the default representation:
body(true).
body((A,B)) :-
        body(A),
        body(B).
body(G) :-          % ambiguous, also matches "true" and "(_,_)"
        goal(G).

goal(_ = _).
goal(call(_)).
% ... etc.
    

We can thus define an interpreter for pure Prolog programs:
mi1(true).
mi1((A,B)) :-
        mi1(A),
        mi1(B).
mi1(Goal) :-
        Goal \= true,
        Goal \= (_,_),
        clause(Goal, Body),
        mi1(Body).
    
This is often called vanilla MI because there's nothing special to it. All MIs that add no ability to pure Prolog are sometimes called vanilla MIs. They serve as skeletons for extensions.

We can use this MI to run our example program:
?- mi1(natnum(X)).
   X = 0
;  X = s(0)
;  X = s(s(0))
;  ... .
    

Using a clean representation

In the preceding MI, we had to guard the third clause from matching true/0 or conjunctions to prevent run-time errors resulting from calling clause/2 with these arguments. This is ugly, and we can make it uglier still by using cuts (!/0) in the first two clauses. That would at least remove unnecessary choice-points, though typically not prevent their creation. Instead of recognising goals by indication of what they are, we had to introduce a "default" case and specify what they aren't. Such representations are called "defaulty". To fix this, we equip goals with the (arbitrary) functor g:

body(true).
body((A,B)) :-
        body(A),
        body(B).
body(g(G)) :-
        goal(G).
    
Such a representation is called clean.

We rewrite our example according to this specification:
natnum_clean(0).
natnum_clean(s(X)) :-
        g(natnum_clean(X)).
    
While a query like "?- natnum_clean(X)." would yield a runtime error (there is no predicate g/1), we can use an MI to interpret the program:
mi2(true).
mi2((A,B)) :-
        mi2(A),
        mi2(B).
mi2(g(G)) :-
        clause(G, Body),
        mi2(Body).
    
In addition to being shorter and cleaner, this version is typically more efficient (no choice-points due to first-argument indexing, and fewer inferences due to fewer tests):
?- integer_natnum(10^6, T), time(mi1(natnum(T))).
   % CPU time: 1.950s, 16_000_015 inferences
   T = s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(...)))))))))))))))))))))
;  ... .


?- integer_natnum(10^6, T), time(mi2(g(natnum_clean(T)))).
   % CPU time: 1.442s, 11_000_010 inferences
   T = s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(s(...)))))))))))))))))))))
;  ... .
    


In the following, mi_clause/2 denotes a predicate that is similar to clause/2, except that it supplies us with an "appropriate" (MI-dependent) representation of clause bodies. For the MI at hand:
mi_clause(G, Body) :-
        clause(G, B),
        defaulty_better(B, Body).

defaulty_better(true, true).
defaulty_better((A,B), (BA,BB)) :-
        defaulty_better(A, BA),
        defaulty_better(B, BB).
defaulty_better(G, g(G)) :-
        G \= true,
        G \= (_,_).
    
This predicate lets us interpret a subset of normal Prolog code with the second MI, and it can also be used for static conversion. Alternatively, mi_clause/2 can be defined by facts. For this MI:
mi_clause(natnum(0), true).
mi_clause(natnum(s(X)), g(natnum(X)).
    

Variants and modifications

The 2 MIs presented so far reify conjunction. Through clause/2, they absorb unification and backtracking. Having made handling of conjunctions explicit, we can reverse the default execution order of goals:
mi3(true).
mi3((A,B)) :-
        mi3(B),        % first B, then A
        mi3(A).
mi3(g(G)) :-
        mi_clause(G, Body),
        mi3(Body).
    
From a logical point of view, this changes nothing (conjunction is commutative). Procedurally, there's a difference: With minor adjustments to mi_clause/2 (adding the fact "defaulty_better(false, false)." and guarding G against the atom false/0), this MI can be used to prove that the query "?- declarative_false." cannot succeed given the predicate's definition:
declarative_false :-
        declarative_false,
        false.
    
This can't be derived with the standard execution order.

We can ask the user before we execute goals (tracing), print out the execution history or restrict access to safe goals by adjusting the third clause:
mi2_safe(g(G)) :-
        (   safe_goal(G) ->
            mi_clause(G, Body),
            mi2_safe(Body)
        ;   throw(cannot_execute(G))
        ).
    


We can obtain a simpler MI by using lists of goals to represent conjunctions. In this representation, true/0 becomes the empty list:
natnum_list(0, []).
natnum_list(s(X), [natnum_list(X)]).
    
Again, the conversion between runnable Prolog code and this representation can be performed automatically:
mi_clause(G, Ls) :-
        clause(G, Body),
        phrase(body_list(Body), Ls).


body_list(true)  --> [].
body_list((A,B)) -->
        body_list(A),
        body_list(B).
body_list(G) -->
        { G \= true },
        { G \= (_,_) },
        [G].
    
An obvious MI for this representation consists of only 2 clauses:
mi_list1([]).
mi_list1([G|Gs]) :-
        mi_clause(G, Body),
        mi_list1(Body),
        mi_list1(Gs).
    
We can make it tail-recursive:
mi_list2([]).
mi_list2([G0|Gs0]) :-
        mi_clause(G0, Body),
        append(Body, Gs0, Gs),
        mi_list2(Gs).
    
The difference:
always_infinite :-
        always_infinite.

?- mi_list1([always_infinite]).
ERROR: Out of local stack

?- mi_list2([always_infinite]).  % loops, constant stack space
    
Using list differences in our clause representation, appending the yet-to-be-proved goals can be fused with expanding a goal:
mi_ldclause(natnum(0), Rest, Rest).
mi_ldclause(natnum(s(X)), [natnum(X)|Rest], Rest).

mi_list3([]). mi_list3([G0|Gs0]) :- mi_ldclause(G0, Remaining, Gs0), mi_list3(Remaining).
Example query:
?- mi_list3([natnum(X)]).
   X = 0
;  X = s(0)
;  X = s(s(0))
;  ... .
    

A meta-circular MI

Here is an MI that can handle the built-in predicates clause/2 and (\=)/2:
mi_circ(true).
mi_circ((A,B)) :-
        mi_circ(A),
        mi_circ(B).
mi_circ(clause(A,B)) :-
        clause(A,B).
mi_circ(A \= B) :-
        A \= B.
mi_circ(G) :-
        G \= true,
        G \= (_,_),
        G \= (_\=_),
        G \= clause(_,_),
        clause(G, Body),
        mi_circ(Body).
    
It can interpret its own source code and is thus a meta-circular interpreter:
?- mi_circ(mi_circ(natnum(X))).
   X = 0
;  X = s(0)
;  X = s(s(0))
;  ... .
    

Reasoning about programs

Let us define a predicate provable/2 as follows: provable(Goal, Defs) is true if Goal is provable with respect to the definitions Defs, which is a list of clauses represented as terms of the form Head-Body. In comparison to the other MIs shown so far, the main difference is thus that the clauses are made available explicitly as an argument of the predicate.

In addition, we generalize the handling of built-in predicates by using predicate_property/2 to identify them as such for calling them directly:
provable(true, _).
provable((A,B), Defs) :-
        provable(A, Defs),
        provable(B, Defs).
provable(g(Goal), Defs) :-
        (   predicate_property(Goal, built_in) ->
            call(Goal)
        ;   member(Def, Defs),
            copy_term(Def, Goal-Body),
            provable(Body, Defs)
        ).
    
Note that we need to use copy_term/2 to replace variables by fresh copies. Also, how the built-in predicate !/0 is interpreted by this MI does not match its intended meaning, and building an MI that handles cuts correctly requires more work.

With the following additional definitions, we can use this MI to identify redundant facts in some predicate definitions:
redundant(Functor/Arity, Reds) :-
        functor(Term, Functor, Arity),
        findall(Term-Body, mi_clause(Term, Body), Defs),
        setof(Red, Defs^redundant_(Defs, Red), Reds).

redundant_(Defs, Fact) :-
        select(Fact-true, Defs, Rest),
        once(provable(g(Fact), Rest)).
    
Given the definitions
as([]).
as([a]).   % redundant
as([a,a]). % redundant
as([A|As]) :-
        A = a,           % test built-in (=)/2
        true,            % test built-in true/0
        as(As).
    
we can ask for facts which are deducible from all (respective) remaining clauses and hence redundant:
?- redundant(as/1, Rs).
Rs = [as([a]), as([a, a])].
    
Thus, MIs allow us to determine non-trivial properties of programs in some cases.

Importantly, MIs give us the freedom to interpret each language construct differently than regular Prolog would do it. For example, using abstract interpretation, we can derive type and mode information of predicates.

An example taken from Codish and Søndergaard 2002, Meta-Circular Abstract Interpretation in Prolog is in the source file. By fixpoint computation, we derive non-trivial facts about the Ackermann function over an abstract parity domain:
?- ack_fixpoint(As).

As = [ack(odd, odd, odd), ack(odd, even, odd), ack(odd, one, odd), ack(even, odd, odd),
      ack(odd, zero, odd), ack(even, even, odd), ack(even, one, odd), ack(one, odd, odd),
      ack(even, zero, odd), ack(one, even, even), ack(one, one, odd), ack(one, zero, even),
      ack(zero, even, odd), ack(zero, odd, even), ack(zero, zero, one), ack(zero, one, even)].
    
Now consider the following query:
?- dif(X, one), dif(X, zero), dif(Z, odd), ack_fixpoint(As), member(ack(X,Y,Z), As).
false.
    
This shows that Ackermann(i, j) is odd and greater than 1 for all i > 1.

Reifying backtracking

Let us reify backtracking now. We need to make explicit all alternative clause bodies that are normally found on backtracking, collecting them deterministically using findall/3. A single alternative is represented as a list of goals, and the branches of computation that have yet to be explored as a list of alternatives. The interface predicate, mi_backtrack/1, takes a goal as its argument and creates the representation of the initial state of computation: A list, consisting of a single alternative, [G0]. Actually, the representation is [G0]-G0, and G0 is also passed as the second argument to the worker predicate for reasons that will become clear shortly.
mi_backtrack(G0) :- mi_backtrack_([[G0]-G0], G0).
    
To perform a single step of the computation, we first collect all clause bodies whose heads unify with the first goal of the first alternative. To all found clause bodies, the remaining goals of that first alternative are appended, thus obtaining new alternatives that we prepend to the other alternatives to give the new state of computation. Using the clause representation that makes use of list differences, and findall/4 to append existing alternatives, this becomes:
resstep_([A|As0], As) :-
        findall(Gs-G, (A = [G0|Rest]-G,mi_ldclause(G0,Gs,Rest)), As, As0).
    



Leaves of the computation, i.e., alternatives that we are done with, automatically vanish from the list of alternatives as there is no goal to be proved for them any more. The unification A = [G0|Rest]-G thus fails and only the other alternatives remain.

The worker predicate:
mi_backtrack_([[]-G|_], G).
mi_backtrack_(Alts0, G) :-
        resstep_(Alts0, Alts1),
        mi_backtrack_(Alts1, G).
    
If no goals remain to be proved for an alternative, a solution for the initial query is found and we report the bindings to the user. This is why we needed to pass around the user's query. The second clause describes how the computation is carried on: The list of alternatives is transformed as described above, and the process continues.

Representing all alternatives explicitly allows us to guide the inference process by reordering alternatives, implement fair execution strategies (by appending new alternatives) and so on. Also, the MI shows what is needed to implement Prolog in languages that lack built-in backtracking.

Extending Prolog

Showing proofs

If you want a feature that plain Prolog does not provide, you can add it to a vanilla MI. Here is an MI that behaves like standard pure Prolog and builds a proof-tree in parallel that makes explicit the inferences that lead to the proof:
:- op(750, xfy, =>).

mi_tree(true, true).
mi_tree((A,B), (TA,TB)) :-
        mi_tree(A, TA),
        mi_tree(B, TB).
mi_tree(g(G), TBody => G) :-
        mi_clause(G, Body),
        mi_tree(Body, TBody).
    
Example query:
?- mi_tree(g(natnum(X)), T).
   T = true=>natnum(0), X = 0
;  T = (true=>natnum(0))=>natnum(s(0)), X = s(0)
;  T = ((true=>natnum(0))=>natnum(s(0)))=>natnum(s(s(0))), X = s(s(0))
;  ... .
    

Changing the search strategy

Another group of extensions aims to improve the incomplete default computation strategy. We start with an MI that limits the depth of the search tree, using integer arithmetic:
mi_limit(Goal, Max) :-
        mi_limit(Goal, Max, _).

mi_limit(true, N, N).
mi_limit((A,B), N0, N) :-
        mi_limit(A, N0, N1),
        mi_limit(B, N1, N).
mi_limit(g(G), N0, N) :-
        N0 #> 0,
        N1 #= N0 - 1,
        mi_clause(G, Body),
        mi_limit(Body, N1, N).
    
Example query:
?- mi_limit(g(natnum(X)), 3).
   X = 0
;  X = s(0)
;  X = s(s(0))
;  false.
    
As expected, the number of solutions coincides with the maximal depth of the search tree in the case of natnum/1. Based on this MI, we can implement a complete search strategy, iterative deepening:
mi_id(Goal) :-
        length(_, N),
        mi_limit(Goal, N).
    
Consider the program:
edge(a, b).
edge(b, a).
edge(b, c).

path(A, A, []).
path(A, C, [e(A,B)|Es]) :-
        edge(A, B),
        path(B, C, Es).
    
And the queries:
?- path(a, c, Es).
ERROR: Out of local stack


?- mi_id(g(path(a, c, Es))).
   Es = [e(a,b),e(b,c)]
;  ... .
    
In contrast to depth-first search, iterative deepening finds a solution. At first glance, iterative deepening may seem a wasteful search technique because many nodes of the search tree are explored repeatedly. However, one can show that iterative deepening is in fact an optimal search strategy under very general assumptions. In a general search tree, the number of nodes at each level grows exponentially with the depth of the tree, and the time it takes to explore all nodes at the final depth is enough to cover the previously expended effort with a constant factor.

Sound unification

Omission of the occurs check in the default unification algorithms of common Prolog implementations can lead to unsound inference:
occ(X, f(X)).
    
Without occurs check, the query
?- occ(A, A).
    
succeeds. We can use occurs check for unification of clause heads in an MI:
mi_occ(true).
mi_occ((A,B)) :-
        mi_occ(A),
        mi_occ(B).
mi_occ(g(G)) :-
        functor(G, F, Arity),
        functor(H, F, Arity),
        mi_clause(H, Body),
        unify_with_occurs_check(G, H),
        mi_occ(Body).
    
We get:
?- mi_occ(g(occ(A,A))).
   false.
    
You can use an MI similar to this one to reify the binding environment of variables: Thread the bindings through and add a term of the form unify(G,H) to the set of bindings in the third clause. Use numbervars/3 to get rid of variables if you want to reify unification.

Parsing with left-recursive grammars

As a consequence of Prolog's computation strategy, parsing with left-recursive grammars is problematic. Let us now define an MI that interprets Definite Clause Grammars in such a way that they can handle left-recursion. Consider a simple grammar:
dcgnumber(0).
dcgnumber(1).

expr(N)   --> [N], { dcgnumber(N) }.
expr(A+B) --> expr(A), [(+)], expr(B).
    
This grammar can be used to (unfairly) enumerate an arbitrary number of strings it describes:
?- phrase(expr(E), Ss).
   E = 0, Ss = [0]
;  E = 1, Ss = [1]
;  E = 0+0, Ss = [0,+,0]
;  ... .
    
However, parsing ground strings leads to problems:
?- phrase(expr(E), [1,+,1]).
   E = 1+1
;  ERROR: Out of local stack
    
We first convert the grammar into a more suitable representation:
dcg_clause(expr(N),   [t(N),{dcgnumber(N)}]).
dcg_clause(expr(A+B), [l,nt(expr(A)),t(+),nt(expr(B))]).
    
The atom l in the body of the second clause is used to capture that for this clause to apply, there must be at least one (therefore, one l) token left in the string to be parsed. This is used in the MI to prune the search tree if we run out of tokens. The other terms are: t/1 for terminals, nt/1 for non-terminals and {}/1 for goals.

The interface to the MI:
mi_dcg(NT, String) :-
        length(String, L),
        length(Rest0, L),
        mi_dcg_([nt(NT)], Rest0, _, String, []).
    
The worker predicates:
mi_dcg(t(T), Rest, Rest, [T|Ts], Ts).
mi_dcg({Goal}, Rest, Rest, Ts, Ts) :-
        call(Goal).
mi_dcg(nt(NT), Rest0, Rest, Ts0, Ts) :-
        dcg_clause(NT, Body),
        mi_dcg_(Body, Rest0, Rest, Ts0, Ts).
mi_dcg(l, [_|Rest], Rest, Ts, Ts).

mi_dcg_([], Rest, Rest, Ts, Ts).
mi_dcg_([G|Gs], Rest0, Rest, Ts0, Ts) :-
        mi_dcg(G, Rest0, Rest1, Ts0, Ts1),
        mi_dcg_(Gs, Rest1, Rest, Ts1, Ts).
    
We can now use the left-recursive grammar also for parsing:
?- mi_dcg(expr(E), [1,+,1,+,1]).
   E = 1+(1+1)
;  E = 1+1+1
;  false.
    


Further extensions

Other possible extensions are module systems, delayed goals, checking for various kinds of infinite loops, profiling, debugging, type systems, constraint solving etc. The overhead incurred by implementing these things using MIs can be compiled away using partial evaluation techniques. For instance, we can (mechanically) derive the following partially evaluated version of the DCG example:
pe_expr(Expr, String) :-
        length(String, L),
        length(Rest0, L),
        pe_expr(Expr, Rest0, _, String, []).

pe_expr(N, Rest, Rest, Ts0, Ts) :-
        Ts0 = [N|Ts],
        dcgnumber(N).
pe_expr(A+B, [_|Rest0], Rest, Ts0, Ts) :-
        pe_expr(A, Rest0, Rest1, Ts0, Ts1),
        Ts1 = [+|Ts2],
        pe_expr(B, Rest1, Rest, Ts2, Ts).
    
Speed comparison:
?- sum_of_ones(10^4, Ss), time(mi_dcg(expr(Sum), Ss)).
   % CPU time: 7.010s, 50_245_019 inferences
   ... .

?- sum_of_ones(10^4, Ss), time(pe_expr(Sum, Ss)).
   % CPU time: 0.016s, 70_012 inferences
   ... .
    



Source file containing all examples: acomip.pl


Written Sept. 14th 2005



More about Prolog


Main page