Refactoring tangled, circular rules in Prolog
Asked Answered
H

3

10

Right up front: This is not a homework exercise. I’m trying to learn Prolog and this is just a problem that happens to need solving and for which Prolog is a perfect fit.

I have a bunch of family relations which comprise my facts:

male/1
female/1
husband/2
wife/2
father/2
mother/2
grandfather/2
grandmother/2
son/2
daughter/2
brother/2
sister/2
uncle/2
aunt/2
nephew/2
niece/2
cousin/2

The data that I have are incomplete, many links of the family mesh are missing. My facts come from an external source, I can only supply the rules. For a given person, I might have male, brother and cousin, for another mother and wife. In the worst case, I barely know cousin, but have enough other facts to be able to infer who is, say, the uncle, therefore that the person might be that brother mentioned elsewhere, hence is male. And so forth.

There’s no way in which I can influence which facts there will be. That’s the whole point of the problem: If the facts were complete, I wouldn’t need to do this. I could do the guesswork by hand, but that’s what a computer is for, if I can find a way of expressing it. The goal is therefore to fill the missing links as good as possible, especially with regard to the ‘indirect’ relations around uncle, aunt, nephew, niece and especially cousin, which are notoriously incomplete.

I could write my rules naïvely like this:

male(Who) :-
   brother(Who, _); father(Who, _); uncle(Who, _); …
brother(Who, Whose) :-
   sibling(Who, Ofwhom), male(Who).
sibling(Who, Whose) :-
   brother(Who, Whose) ; brother(Whose, Who).
motherly_cousin(Cousin, Whose) :-
   cousin(Cousin, Whose),
   sibling(Mother, Uncle_or_Aunt),
   parent(Uncle_or_Aunt, Cousin).

I am rather sure that I am trying to solve the problem in a fundamentally wrong way, as I see no way of breaking the circular reasoning. And without breaking the circles, any Prolog program that I’ll devise for this will degenerate into endless recursions.

So what could I do to break this tangle down into something that can be solved?

Highness answered 15/10, 2014 at 1:11 Comment(1)
I’ll go with Sharky’s approach for now, that way I can go on until I can work out the other two suggestions. Thanks for the great answers!Taphouse
S
6

Generally, this is a tricky problem. Checks for this kind of recursion are possible (similarly for the occurs-check in unification), however most implementations omit them because (a) it is generally unclear which recursive paths to exclude; (b) it is too computationally expensive; or (c) there is usually a way for the programmer to circumvent the problem in the code.

There are a number of ways of dealing with this, some nastier than others. I will present a way which:

  • Permits you to reasonably define your predicates naïvely;
  • Deals with an incomplete set of facts;
  • Is horribly inefficient;
  • Does not recurse infinitely.

The way I will describe employs the use of a meta-interpreter. The standard interpreter in Prolog will not check if your code is executing the same clause over and over again. For example, there is a nasty case of mutual recursion between your definitions of brother/2 and sibling/2. While the definition you've provided for them appears to be fine, consider what happens to them when they are called with all parameters unbound:

brother(X, Y)sibling(X, Y)brother(X, Y) ↝ ... (ad infinitum/nauseum)

Instead, what we can do is define how these predicates should be executed knowing full well they may be infinitely recursive by directing their execution through a separate predicate, which I'll call meta/1. This predicate is the meta-interpreter, and will guide Prolog as to how it should execute the rules and facts you have provided in a way which prevents infinite recursion. Here is one possible definition (with comments inline):

meta(Goal) :-
    % defer to meta/2 with a clause reference accumulator 
    meta(Goal, []).

meta(true, _ClauseRefs) :- 
    % the body to execute is true (i.e., a fact); just succeed.
    !,
    true.

meta(meta(X), ClauseRefs) :-
    % the body to execute is a call to the meta interpreter. 
    % interpret the interior goal X, and NOT the interpreter itself.
    !,
    meta(X, ClauseRefs).

meta((G0, G1), ClauseRefs) :- 
    % interpret a conjunct: ,/2. G0 then G1:
    !,
    % interpret the first sub-goal G0
    meta(G0, ClauseRefs),
    % then interpret the second sub-goal G1
    meta(G1, ClauseRefs).

meta((G0 ; G1), ClauseRefs) :- 
    % interpret a disjunct: ;/2. One or the other:
    ( meta(G0, ClauseRefs) 
    ; meta(G1, ClauseRefs) 
    ),
    !.

meta(G0, ClauseRefs) :-
    % G0 is an executable goal: look up a clause to execute 
    clause(G0, Body, Ref),
    % check to see if this clause reference has already been tried 
    \+ memberchk(Ref, ClauseRefs),
    % continue executing the body of this previously unexecuted clause 
    meta(Body, [Ref|ClauseRefs]).

meta/1 and meta/2 are designed so that they execute goals provided to them in a way which ensures that every clause used in the branch of execution of the goal is explicitly not repeated. In order to use it in your case, consider the following:

brother_of(a, b).
brother_of(b, c).
brother_of(d, e).
brother_of(X, Y) :- meta((sibling_of(X, Y), male(X))).

male(a).
male(d).
male(b).
male(X) :- meta(brother_of(X, _)).

female(c).
female(e).
female(X) :- meta(sister_of(X, _)).

sister_of(X, Y) :- meta((sibling_of(X, Y), female(X))).

sibling_of(X, Y) :- meta(brother_of(X, Y)).
sibling_of(X, Y) :- meta(brother_of(Y, X)).
sibling_of(X, Y) :- meta(sister_of(X, Y)).
sibling_of(X, Y) :- meta(sister_of(Y, X)).

Notice how the body of any of the recursive clauses is wrapped in a call to meta/1, guiding Prolog to execute their definition using the meta-interpreter which will ensure that their execution (by interpretation) will not be recursive. For example, the goal:

?- sister_of(X,Y).
X = c,
Y = b ;
X = c,
Y = b ;
X = c,
Y = b ;
... 
X = e,
Y = d ;
false.

Note that it terminates after finding all bindings via all possible non-recursive execution paths, meaning there may be a lot of repetition (hence, the source of inefficiency). To find unique bindings, you could use setof/3 as follows:

?- setof(sister_of(X,Y), sister_of(X,Y), Set).
Set = [sister_of(c, b), sister_of(e, d)].

This is just one method which you might find useful, and is often a nice (albeit advanced) tool for Prolog programmers to be aware of. You don't need to stick to the inherent execution strategy.

For anyone thinking about simply using meta/1 and meta/2 in practice, there are some other things you should consider:

  • Perhaps you might want or need to permit the same clause to be executed more than once when executing a (sub-)goal (e.g., if you need to execute the same clause but with different head bindings). As an example, think about how you'd implement ancestor/2 recursively using the meta-interpreter, which may need to execute the same clause (itself) several times over with different head bindings (i.e., path expansion). In this case, instead of simply tracking clause references, you could track clause references and their particular head bindings as Ref-Head items, and check to see if these have been executed before. This might be a whole lot extra information to cart around, and could be expensive!
  • The definition of meta/1 and meta/2 above only deal with predicates such as facts (with the implicit true as their body); or predicates with bodies defined using any combination of conjunction (,/2) and disjunction (;/2). You can simply add more clauses to meta/2 to deal with other language constructs, such as implication (->/2), negation (\+/1), cut (!/0), etc. if you need to.
  • Not all problems like this necessitate a meta-interpreter. For example, you might be able to get away with simply structuring your clauses carefully and check for modes (i.e., predicate bindings being ground/non-ground) before they are executed, however this can get tricky the more complex the program is.
  • If you think about the problem hard enough, perhaps there's a way you could simply avoid using recursion altogether: i.e., don't use recursive definitions, but instead, use predicates with different names which aren't mutually recursive.
Shortie answered 15/10, 2014 at 6:14 Comment(4)
just a little addition: you could use expand_goal and add another operator like :~/2 to hide the calls to the meta predicate and ease the writing. using expand_goal broke gtrace on my machine, but there is much fun to have with it anyway.Kinsley
Here are the two lines you'll need: :- op(1200,xfx,':~'). for setting up the operator (with the same precedence as :-) and term_expansion((A:~B),(A:-meta(B))). to change input while it's being read. This is the simplified version of term_expansion, there is a more elaborate one for debugging purposes, but the documentation on that is very sparse. With these two lines and your meta predicate, you just write male(X):~ brother_of(X, _). and the listing shows your corresponding clause.Kinsley
That approach is very similar to what I tried to concoct for myself, but this one seems to actually work. I still have to fully grasp how exactly the cuts are working here, but that’s just a matter of trying out the unifications by hand on paper.Taphouse
The cuts in the definition of meta/2 simply ensure that Prolog doesn't try leaving choice-points to backtrack and execute any subsequent clauses. This is important, because we don't want, say, a goal like (X,Y) being executed by meta((G0, G1), ClauseRefs) and meta(G0, ClauseRefs), because we don't want to execute ,/2 as a goal itself (we want to interpret it instead!).Shortie
O
6

+1 for a nice twist on the usual "family example".

In addition to what others already said, consider using Constraint Handling Rules (CHR). They seem like a good fit for this problem, where a fixpoint needs to be computed from a set of facts and rules.

EDIT: As requested, a small example. I focus on an illustration surrounding brother_of/2. First, notice that brother_of/2 is clearly more specific than male/1, since we know that a brother is always male, but not vice versa. Informally, the first CHR rule thus says: When brother_of(X,_) holds, and male(X) holds, then drop the male(X) constraint, because it can always be deduced later. The second rule shows an example of deducing that brother(X, Y) holds. The third rule removes redundant brother_of/2 constraints.

The complete code, tested with SWI-Prolog:

:- use_module(library(chr)).

:- chr_constraint male/1, brother_of/2, child_parent/2.

brother_of(X, Y) \ male(X) <=> brother_of(X, Y).

male(X), child_parent(X, P), child_parent(Y, P) ==> X \== Y | brother_of(X, Y).

brother_of(X, Y) \ brother_of(X, Y) <=> true.

Example query and its result:

?- male(john), child_parent(john, mary), child_parent(susan, mary).
brother_of(john,susan)
child_parent(susan,mary)
child_parent(john,mary)
true ;
false.
Overlying answered 15/10, 2014 at 7:13 Comment(5)
+1: computing a fixpoint using CHR would indeed be a nice way of solving this problem, particularly if efficiency is important.Shortie
If you have time, I would love to see a sketch of this approach.Milomilon
I’ll have some reading to do, but from a cursory glance over the terminology it looks like a very interesting approach.Taphouse
Thanks for the example, mat! Time for me to buy the CHR book.Milomilon
This solution works well, but I'd like to simplify it further. Is there a more concise way to represent logical disjunctions in CHR?Linear
M
3

I'm a little ashamed of trying to say anything after @sharky's excellent post. But if I were approaching the problem I would make a few smaller changes and accept that Prolog is not completely logical. This is to say I would select @sharky's suggestion of avoiding mutual recursion altogether.

For one thing, gender in reality is an intrinsic rather than a derived fact. By this I merely mean that I don't derive my maleness from belonging to a father/uncle/brother relation with someone else.

If you put male(X) before sibling(X,Y) rather than after, it has no effect on the logical meaning of the program, but it will change the way the program is executed by Prolog in ways that matter practically. For instance if X is unbound, male(X) may generate (assuming you make the change I suggest) without having to re-enter brother/2 by a distant mutual recursion from sibling/2.

I would encourage you to separate facts from predicates, unless the facts are really base cases.

Unfortunately, Prolog isn't going to save you from having to design a coherent data model. You still need to worry about whether you're storing the right data in the right shape. You can provide the rich API you've come up with either way, it's just that right now you have the data kind of smeared all over. You can box yourself in with anything, it's just that in Prolog you tend to get partial results even when that has happened.

I feel like tabling might be of some help to you but since it only exists in fairly obscure implementations the benefit may be too limited. I've never used it myself so I don't know if it does indeed solve these problems or just mitigates the symptoms of mild problems. I suspect the latter simply because if it were both really helpful and solving an important intrinsic problem I'd expect it to have been ported to GNU and SWI (but perhaps I'm overly optimistic).

Milomilon answered 15/10, 2014 at 6:53 Comment(4)
Thanks! +1 to you too. Tabling is indeed one way of handling the problem, but as you say, isn't widely implemented (and not in SWI as far as I'm aware). Additionally, changing the order of sub-goals to affect their execution order and bindings is what I was hinting at when I mentioned carefully structuring your clause bodies taking mode into consideration, but even that won't solve this particular problem.Shortie
Side note about tabling. I think tabling is implemented and documented very well in B-Prolog, I don't think it qualifies as a "fairly obscure implementation".Medawar
@SergeyDymchenko I have tried a few times to build the various tabling-supporting Prologs on my Mac and haven't gotten anywhere. "Obscure" probably isn't the right word, GNU and SWI are in a class by themselves in terms of support and portability and I simply haven't had good luck with the others. But I bring it up because I hope the OP will check it out and maybe have better luck than me.Milomilon
(I still have to digest Sharky’s answer, so I’ll get to that one later.) Unfortunately, I cannot decide what facts there are. To stay with the example, I can only derive maleness in some cases from indirect sibling relations, where in others it’s explicitely given. In reality, it’s of course intrinsic, but I have to work with the facts that I have. :o) But you do have a point, and maybe I can get rid of some of the more problematic derivations. (The other suggestions nonwithstanding, which I still need to look into.)Taphouse

© 2022 - 2024 — McMap. All rights reserved.