Friday 10 March 2023

19 - Meta-Interpreter 4: The Cut



We've seen how to extend our meta-interpreter to interpret built-ins like is/2. The technique is the same for other simple built-ins like is/2 and >/2.

The cut, however, is one built-in that isn't as easy to simulate. 

In this example we'll see how it can be approximated fairly well using the mechanism of throwing and catching exceptions.


% Example 19 - Meta-Interpreter For The Cut

fruit(apple).
fruit(orange) :- !.
fruit(banana).

% meta-interpreter that handles the cut
prove(true) :- !.
prove(!) :- !, ( true ; throw(cut_exception) ).
prove((A,B)):- !, prove(A), prove(B).
prove(H) :- 
    catch((clause(H,B), prove(B)), cut_exception, fail),
    write(H), write(" ← "), writeln(B).

Our example uses the same program we used to introduce the cut.


Incorrectly Interpreting The Cut

Let's look again at how we implemented unification =/2 in our meta-interpreter.


prove(A=B) :- !, A=B.

When the meta-interpreter finds a goal of the form A=B, this rule executes A=B, thus achieving the effect of attempting to unify A=B, which can either succeed or fail. The cut here prevents the more general prove(H) being matched if this rule prove(A=B) has matched.

Let's look at an example of implementing another built-in, the arithmetic assignment is/2.


prove(A is B) :- !, A is B.

Again, this rule will match goals of the form A is B, and execute A is B, which has the desired effect of attempting to assign the arithmetic evaluation of B to A.

Now let's examine a naive attempt at simulating the cut, following the same pattern. 


prove(!) :- !, !.

Imagine our meta-interpreter is interpreting a rule H which has a cut as one of its goals. The meta-interpreter rule prove(!) will match that goal. This rule will succeed because cuts always succeed. This achieves the first effect of a cut. The second effect of a cut is to prevent backtracking. This doesn't work as we'd like because the cuts in prove(!):-!,! only prevent backtracking in the rule prove(!), and not in the rule H.

This is the reason we need a different approach to implementing the cut. We need a way to send a message from prove(!) back to the meta-interpreting of the parent rule H.



Throwing & Catching Exceptions

Like many languages, prolog supports the throwing and catching of exceptions

If you're not familiar with this idea, think about prolog stopping when a problem occurs, for example running out of memory due to an endless search. When prolog runs out of memory it throws an exception related to the problem. That exception can be thought of as a special signal, sent with a higher priority than the program itself, which causes the program to be paused. Such signals, or exceptions, are intended to be caught, and handled appropriately. By default, an out of memory exception is caught by prolog itself, handled by printing out an error message and aborting the program.

Let's look at an example. The following defines bad(X), intentionally designed to cause an endless search that runs out of memory.


% bad/1 will cause a resource error
bad(X) :- bad(bad(X)).

If we issue a query bad(X), after a short while, the program will crash and an error message will be printed out.


?- bad(X).

Stack limit (0.2Gb) exceeded
Stack sizes: local: 2Kb, global: 0.2Gb, trail: 0Kb
Stack depth: 13,390,437, last-call: 100%, Choice points: 12
...

What we see is prolog's exception handler for this resource error printing out an explanation of what went wrong.

We can catch and handle exceptions by declaring an interest in a specific one, or asking to catch all exceptions. To do this we use catch(Goal, Exception, Handler), where Goal is the goal from which we expect an exception, Exception is the type of exception, and Handler is another goal which will run in response to an exception being caught.

The following asks prolog to catch any exception raised from the goal bad(X), and write out a representation of that exception. Because E is a variable, it will match any exception.


?- catch(bad(X), E, write(E)).

error(resource_error(stack),
stack_overflow{choicepoints:13,
depth:13390419, environments:19,
globalused:209227, localused:3,
stack:
...

Let's have a look at throwing our own custom exception.


% numerology(X) throws an exception if X=13
numerology(X):- \+ X=13.
numerology(X):- X=13, throw(unlucky).

% testnumber handles that exception
testnumber(X) :- catch(numerology(X), unlucky, writeln("unlucky number")).

The property numerology(X) succeeds if X cannot be unified with 13. If however X=13, then it throws a custom exception, which we've named unlucky

The property testnumber(X) uses catch/3 to call numerology(X), watch for exceptions of the type unlucky, and handle them by printing “unlucky number”. Let's test it with 12 and 13.


?- testnumber(12).
true

?- testnumber(13).
unlucky number
true

We can see an exception was raised and caught in the case X=13. Notice how the prolog program did not abort after the exception was handled. It continued until it completed, reporting true. Exceptions don't abort a program unless the handler explicitly tells prolog to.


Meta-Interpreting The Cut

Having seen how to throw, catch and handle custom exceptions, we can now explore how to meta-interpret the cut.

The following is a new definition for prove(!)


prove(!) :- true.
prove(!) := !, throw(cut_exception).

When the meta-interpreter finds a cut, it wants to execute prove(!). The first rule matches and succeeds with a true. Although it may appear redundant, this success is what allows solutions in the interpreted cut's parent clause to be confirmed. 

The second rule also matches and throws a custom exception, which we've called cut_exception. The lack of a cut in the body of the first rule allows this second one to execute.

The following uses prolog's semicolon ; to write the two rules as one. The semicolon means disjunction “or”, just like the comma means conjunction “and”.


prove(!) := !, (true; throw(cut_exception)).

We've already talked about why the handling of this exception needs to relate back to the entire rule in whose body the cut was found. Let's see how this is achieved.


prove(H) :- 
    catch((clause(H,B), prove(B)), cut_exception, fail),
    write(H), write(" ← "), writeln(B).

The catch/3 here asks to register an interest in the cut_exception from the grouped goals (clause(H,B),prove(B)), not just prove(B), even though the exception can only come from prove(B). We'll see why soon. The handling of the exception is simply to fail

Let's walk through how this works, step by step.

  • If the rule for H has a cut ! in its body B, then it will eventually be matched by prove(!)
  • prove(!) will succeed with a true and confirm any solutions found up to that point.
  • On backtracking prove(!) will also throw a custom exception given the name cut_exception.
  • The cut_exception will be caught in the context of calling the grouped goals (clause(H,B),prove(B))
  • The handling of this cut_exception is simply to fail. That means the full (clause(H,B),prove(B)) is failed.

At this point, any goals in the body B before the cut have already been meta-interpreted and executed, with any solutions found remaining valid. This mirrors how the cut works in prolog itself.

When the cut is reached, the entire (clause(H,B),prove(B) is failed. Because clause(H,B) is failed, no further attempts are made to find additional rules which also have a head H. The effect of this is to prevent further backtracking for the rule H. This also mirrors how the cut works in prolog.

Phew! This is probably the most complicated prolog we'll encounter on this particular journey, so feel free to work through it several times.


Testing

Let's remind ourselves of the expected behaviour of the program we want to meta-interpret.


?- fruit(X).

X = apple
X = orange

The solution X=banana is excluded by the cut in fruit(orange):-!.

Let's test our enhanced meta-interpreter.


?- prove(fruit(X)).

fruit(apple) ← true
X = apple

fruit(orange) ← !
X = orange

Success! Our meta-interpreter has correctly simulated the cut. The printed explanations also give a visual clue as to why no more solutions were found after X=orange.


Limitations

This approach to meta-interpreting the cut is relatively simple, given the non-trivial effect the cut actually has on the execution of prolog. This simplicity brings with it some limitations. 

First, this approach is computationally heavy, because throwing and handling exceptions is expensive. Exceptions are meant to be thrown, well, by exception, and not used in the normal execution of programs. 

Second, in the unlikely case the program to be meta-interpreted throws an exception of the type cut_exception, this approach will collapse. 


Key Points

  • Because the cut effects the execution of its parent clause, it cannot be meta-interpreted in the same way as simple built-ins like =/2.
  • The cut's behaviour can be approximated by throwing an exception when the cut is meta-interpreted, with the handler scoped to fail the group (clause(H,B), prove(B)). This results in no further searches for rules that match the parent goal H
  • This method has limitations. Throwing and catching exceptions is computationally expensive. The method will also fail if the program to be interpreted throws exceptions of the same type.