1

Main features

I recently have been looking to make a Prolog meta-interpreter with a certain set of features, but I am starting to see that I don't have the theoretical knowledge to work on it.

The features are as follows :

  1. Depth-first search.
  2. Interprets any non-recursive Prolog program the same way a classic interpreter would.
  3. Guarantees breaking out of any infinite recursion. This most-likely means breaking Turing-completeness, and I'm okay with that.
  4. As long as each step of the recursion reduces the complexity of the expression, keep evaluating it. To be more specific, I want predicates to be allowed to call themselves, but I want to prevent a clause to be able to call a similarly or more complex version of itself.

Obviously, (3) and (4) are the ones I am having problems with. I am not sure if those 2 features are compatible. I am not even sure if there is a way to define complexity such that (4) makes logical sense.

In my researches, I have come across the concept of "unavoidable pattern", which, I believe, provides a way to ensure feature (3), as long as feature (4) has a well-formed definition.

I specifically want to know if this kind of interpreter has been proven impossible, and, if not, if theoretical or concrete work on similar interpreters has been done in the past.

Extra features

Provided the above features are possible to implement, I have extra features I want to add, and would be grateful if you could enlighten me on the feasibility of such features as well :

  1. Systematically characterize and describe those recursions, such that, when one is detected, a user-defined predicate or clause could be called that matches this specific form of recursion.
  2. Detect patterns that result in an exponential number of combinatorial choices, prevent evaluation, and characterize them in the same way as step (5), such that they can be handled by a built-in or user-defined predicate.

Example

Here is a simple predicate that obviously results in infinite recursion in a normal Prolog interpreter in all but the simplest of cases. This interpreter should be able to evaluate it in at most PSPACE (and, I believe, at most P if (6) is possible to implement), while still giving relevant results.

eq(E, E).
eq(E, F):- eq(E,F0), eq(F0,F).

eq(A + B, AR + BR):- eq(A, AR), eq(B, BR).

eq(A + B, B + A).
eq(A * B, B * A).
eq((A * B) / B, A).

eq(E, R):- eq(R, E).

Example of results expected :

?- eq((a + c) + b, b + (c + a)).
true.

?- eq(a, (a * b) / C).
C = b.

The fact that this kind of interpreter might prove useful by the provided example hints me towards the fact that such an interpreter is probably impossible, but, if it is, I would like to be able to understand what makes it impossible (for example, does (3) + (4) reduce to the halting problem? is (6) NP?).

false
  • 10,264
  • 13
  • 101
  • 209
Xenos
  • 155
  • 5
  • Just sub-problems of what you want are a research topics in itself, like [E-unification](https://en.wikipedia.org/wiki/Unification_(computer_science)#E-unification). – false Feb 21 '22 at 10:56
  • @false Well, E-unification aims to completely solve a set of equation. What I'm looking for is not an algorithm to solve all possible equations of such a set, but simply an algorithm that gives a solution to sufficiently simple ones. Any sufficiently complex equation would require a recursive expansion of the equation that cannot be proven to halt. This interpreter would side-step the problem by forcing a halting in evaluation as soon as more then 1 similar steps of expansions are done. – Xenos Feb 21 '22 at 11:07
  • I've only yet read through your initial 1 through 4 conditions, and I'd suggest just to switch from DFS to BFS. boom! :) (with some additional diagonalization / [dovetailing](https://en.wikipedia.org/wiki/Dovetailing_(computer_science)) added, probably, to overcome a possibly infinite branching factor...) – Will Ness Feb 21 '22 at 12:21
  • @WillNess While BFS does solve part of the problem, it doesn't really solve the whole question (ie. it would only halt if one of the branches halts. I want it to halt even if none of the branches would normally halt, and if your solution to that was to add a depth limit, this could also be added to a DFS and give the same result), and loses some desirable properties that DFS has (mainly ordering of solutions and side-effects, and more efficient memory usage). This is the reason why DFS is my first condition. – Xenos Feb 21 '22 at 12:36
  • 1
    Left recursion - XSB tabling. Also in SWI Prolog. https://www.swi-prolog.org/pldoc/man?section=tabling – Peter Ludemann Feb 21 '22 at 19:00
  • @PeterLudemann Thanks for the pointers, I will look into those concepts, and hopefully find answers to some of my questions! – Xenos Feb 21 '22 at 22:10
  • 1
    Another solution is to write a meta-circular interpreter -- if you have trouble finding good techniques, I can did through some of my files (O'Keefe's book has a chapter on writing interpreters). Or you can easily do iterative deepening with https://www.swi-prolog.org/pldoc/man?predicate=call_with_depth_limit/3 – Peter Ludemann Feb 22 '22 at 19:16
  • @PeterLudemann I am aware of iterative deepening, but it doesn't really solve the core problem nor answer my question, as it has the same downsides as bfs. As for meta-circular interpreters, I believe those refer to interpreters able to run their own code, am I correct? If so, I don't really see how that would solve the issue, especially since I'm pretty sure any prolog interpreter requires recursion, or some sort of substitute for it, which means that according to my specifications, this interpreter shouldn't be able to run it. – Xenos Feb 22 '22 at 20:54
  • 1
    @Xenos re feature #4, you should look at "reduction orders" for termination proofs of term rewriting systems. Basically you find a homomorphism from the set of terms under the rewriting relation (in this case Prolog programs under your execution strategy) to some well-founded relation, e.g. the natural numbers under the (strictly) less-than relation. The canonical example is just mapping any term to its size and showing that the rewriting system always strictly reduces the size (universal termination), or has at least one branch which always strictly reduces the size (existential termination). – GeoffChurch Feb 23 '22 at 21:12
  • @GeoffChurch thanks, that's a very helpful comment, it seems like reduction orders part part of what I was looking for. – Xenos Feb 23 '22 at 22:56
  • Meta-circular interpreters merely mean that some interpretation rules use the system. For example, you might define `interpret((A,B)) :- interpret(A), interpret(B).`; that is you're using the built-in "and". The important thing is that you can change *some* parts of the interpretation, e.g. using breadth-first instead of depth-first. (Note that in general you can't catch all "infinite" recursions so you need to decide what limitations you'll accept.) – Peter Ludemann Feb 24 '22 at 09:13
  • @PeterLudemann If by "system", you mean the underlying prolog engine, isn't that usually refered to as "absored" features? If so, I still don't see how that's helpful. Sure, it can make an interpreter more concise, but my question is specifically about modifying those features to change the behavior, so there are some features I can't absorb. – Xenos Feb 24 '22 at 09:18
  • Your edit makes it more clear, but as mentioned, I don't really see how that helps solve the core question, it sounds more like some general statement about meta-interpreters. – Xenos Feb 24 '22 at 09:23
  • A meta-circular interpreter uses built-in features (such as `call`) where it can and modifies other features (such as `,` and `;`). If we take the example of `a;b`, standard Prolog will try `a` first and if that fails try `b` (left-to-right, depth-first). A meta-circular interpreter could change this to breadth-first. In other words, change the behavior of control primitives but leave everything else alone. And there are variations on this, such as `when/2` that can be used to change the order of goals. – Peter Ludemann Feb 28 '22 at 22:43
  • @PeterLudemann I know how meta-interpreters work, my question was not about how to write any meta interpreter in prolog, it was about how I could go about implementing the specific set of features listed in my question into a meta-interpreter – Xenos Feb 28 '22 at 22:47
  • @xenos - I would start with chapter 7 of Richard O'Keefe's "The Craft of Prolog" ... he does a far better job of explaining than I ever could. ISTR a discussion by O'Keefe in the long-defunct Prolog Digest, but that discussion doesn't seem to have been preserved. :( There are probably many other explanations, but as O'Keefe points out, there are some subtleties that need to be taken into account. – Peter Ludemann Mar 01 '22 at 18:37

1 Answers1

2

If you want to guarantee termination you can conservatively assume any input goal is nonterminating until proven otherwise, using a decidable proof procedure. Basically, define some small class of goals which you know halt, and expand it over time with clever ideas.

Here are three examples, which guarantee or force three different kinds of termination respectively (also see the Power of Prolog chapter on termination):

  • existential-existential: at least one answer is reached before potentially diverging
  • universal-existential: no branches diverge but there may be an infinite number of them, so the goal may not be universally terminating
  • universal-universal: after a finite number of steps, every answer will be reached, so in particular there must be a finite number of answers

In the following, halts(Goal) is assumed to correctly test a goal for existential-existential termination.

Existential-Existential

This uses halts/1 to prove existential termination of a modest class of goals. The current evaluator eval/1 just falls back to the underlying engine:

halts(halts(_)).

eval(Input) :- Input.

:- \+ \+ halts(halts(eval(_))).

safe_eval(Input) :-
    halts(eval(Input)),
    eval(Input).
?- eval((repeat, false)).
  C-c C-cAction (h for help) ? a
abort
% Execution Aborted
?- safe_eval((repeat, false)).
false.

The optional but highly recommended goal directive \+ \+ halts(halts(eval(_))) ensures that halts will always halt when run on eval applied to anything.

The advantage of splitting the problem into a termination checker and an evaluator is that the two are decoupled: you can use any evaluation strategy you want. halts can be gradually augmented with more advanced methods to expand the class of allowed goals, which frees up eval to do the same, e.g. clause reordering based on static/runtime mode analysis, propagating failure, etc. eval can itself expand the class of allowed goals by improving termination properties which are understood by halts.

One caveat - inputs which use meta-logical predicates like var/1 could subvert the goal directive. Maybe just disallow such predicates at first, and again relax the restriction over time as you discover safe patterns of use.

Universal-Existential

This example uses a meta-interpreter, adapted from the Power of Prolog chapter on meta-interpreters, to prune off branches which can't be proven to existentially terminate:

eval(true).
eval((A,B)) :- eval(A), eval(B).
eval((A;_)) :- halts(eval(A)), eval(A).
eval((_;B)) :- halts(eval(B)), eval(B).
eval(g(Head)) :-
    clause(Head, Body),
    halts(eval(Body)),
    eval(Body).

So here we're destroying branches, rather than refusing to evaluate the goal.

For improved efficiency, you could start by naively evaluating the input goal and building up per-branch sets of visited clause bodies (using e.g. clause/3), and only invoke halts when you are about to revisit a clause in the same branch.

Universal-Universal

The above meta-interpreter rules out at least all the diverging branches, but may still have an infinite number of individually terminating branches. If we want to ensure universal termination we can again do everything before entering eval, as in the existential-existential variation:

...

:- \+ \+ halts(halts(\+ \+ eval(_))).

...

safe_eval(Input) :-
    halts(\+ \+ eval(Input)),
    eval(Input).

So we're just adding in universal quantification.


One interesting thing you could try is running halts itself using eval. This could yield speedups, better termination properties, or qualitatively new capabilities, but would of course require the goal directive and halts to be written according to eval's semantics. E.g. if you remove double negations then \+ \+ would not universally quantify, and if you propagate false or otherwise don't conform to the default left-to-right strategy then the (goal, false) test for universal termination (PoP chapter on termination) also would not work.

GeoffChurch
  • 395
  • 1
  • 13
  • 1
    This seems like an effective approach, but I'm a bit curious if the halting check can be inter-weaved with the evalution, such that it can be used to prune the branches that enter recursion to help the evaluator select nly terminating branches? Because right now, it seems like this an "all or nothing" technique that would simply disallow evaluation of of programs that have even one branch that can't be proven to halt, whereas my goal would be to evaluate those, but only for the branches that can be proven to halt. – Xenos Feb 23 '22 at 23:12
  • Sure, `halts` can technically be defined to check for universal or existential termination, or any desired property. Wrt to interweaving, it should actually be pretty easy to implement. At each disjunction (`clause/2` or `;/2`) you'd want to check existential termination for each option before calling `eval` on it. So you could have `eval` and `halts` be mutually recursive in a loose sense - `eval` directly applies `halts` before recursion, and `halts` checks a branch _with respect to_ `eval`, though it of course mustn't actually call it. Definitely check out https://www.metalevel.at/acomip/ – GeoffChurch Feb 24 '22 at 01:56
  • Thanks, I think that answers my question, I should have enough to make some progress by myself. The site you linked happens to be the one that made me want to work on this interpreter. – Xenos Feb 24 '22 at 08:48
  • Actually, now that I think about it, that opens up another question : if I were to check for existential termination on a recursive predicate, and one of the clause terminated while the other didn't, it means the non-terminating clause would have at least one sub-branch that terminates. According to that specification, it would mean that the interpreter would keep interpreting the recursive clause's terminating branches recursively. To prevent that without reading the existing stack would be akin to only accepting universal temination wouldn't it? – Xenos Feb 24 '22 at 09:00
  • "the non-terminating clause would have at least one sub-branch that terminates" -- The way I'm thinking about it, this isn't necessarily true. `halts` either succeeds or fails (it never diverges). If it succeeds it means that this particular branch is guaranteed to succeed or fail after a finite time (no false positives). If it fails it means that it _might_ diverge (we're okay with false negatives because halting isn't decidable in general). So if it succeeds we can safely `eval` that branch without risking divergence. – GeoffChurch Feb 24 '22 at 16:41
  • But in that case `halt` can only evaluate universal termination, not existential one, and thus you would never get to evaluate branches that would terminate, because `halt` would need to fail on the entire goal, since the goal has a branch that doesn't terminate. – Xenos Feb 24 '22 at 16:47
  • By guarding for existential termination of each branch, we ensure that `eval` will never diverge _between_ answers. If there is an infinite number of terminating branches even after the `halts` check, then you can just keep requesting new solutions forever. So the use of `halts` turns a regular evaluator (which could diverge after 0, 1, or any number of solutions) into an evaluator which never diverges on any particular solution (but may still not universally terminate). You can check universal termination with `halts(eval(Input), false)` or `halts(findall(_, eval(Input), _))`. – GeoffChurch Feb 24 '22 at 17:09
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/242378/discussion-between-xenos-and-geoffchurch). – Xenos Feb 24 '22 at 17:54