Last week, I tried my hand at writing some semi-real world Prolog code by modeling the Drools Petstore Example in Prolog. Looking back now, I think the code was pretty amateurish - more expressive code could probably be written using a procedural language such as Java.
This week, I provide a somewhat improved implementation that is less procedural and more logical, or at least more in line with how I think one would code against a typical rules engine. Typically, one would call a goal on a rules engine, and the goal would either succeed or fail. If it fails, there should be some mechanism to let the client know that it failed and (optionally) what extra information it needs to proceed. The client can then call it again (and again) with the extra information, until the goal succeeds, or give up and do something else, depending on the application.
I also wanted the set of Prolog rules to be callable from Java. My main goal in learning Prolog is to be able to write rules that can be treated as configuration from a Java application - the application will retrieve and pass in the necessary facts for the Prolog rules to work on, and then use the decision/output of the rules to do whatever its supposed to. Prior to this, I have built several home grown data-driven "rule-engines" out of database tables/properties files and application code to do something similar, but I figured that Prolog would be more expressive and flexible once I learnt how to use it correctly.
Prolog Rulebase
Here is the new and improved version of the rules. As you can see, its shorter and (at least to me) cleaner. The client will call the checkout/1 goal. If the goal can proceed to completion, ie, it has all the information it needs to complete, then it will assert a cart/1 predicate into the Prolog factbase. The client will see that the goal completed successfully, and will query the factbase with cart(X). On the other hand, if there are not enough facts for the goal to complete, it will fail and assert a question/1 predicate representing the question it needs answered before it can continue. The client will see that the goal failed, and query the factbase with question(X) to get the question that needs to be answered, answer it, and call checkout/1 again with the answer appended to the original list of parameters.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88 | %% Source: petstore2.pro
:- dynamic cart/1.
:- dynamic question/1.
:- dynamic answer/2.
% A shopping cart is minimally represented as a sequence of quantities
% of [fish, food, tank]. Elements subsequent to that are answers to
% questions that the engine has asked and which it requires answers to
% in order to proceed. If provided, they are asserted into the factbase.
itemize(Cart, Fish, Food, Tank) :-
[Fish, Food, Tank | Q] = Cart,
extract_info(Q).
extract_info([H|R]) :-
assert(H),
extract_info(R).
extract_info([]) :- !.
% Add 1 packet of food for every 5 fish purchased.
add_free_food(Fish, FreeFood) :-
FreeFood is floor(Fish / 5).
% Depending on customer's feedback, add more fish food to shopping cart.
% Only do this if the customer has bought some fish. If he is here to
% buy only food or a tank, don't even ask him about it.
add_more_food(Fish, MoreFood) :-
Fish =< 0,
MoreFood is 0,
!.
add_more_food(Fish, MoreFood) :-
Fish > 0,
not(answer(how_many_food, _)),
assert(question(how_many_food)),
MoreFood is 0,
fail.
add_more_food(Fish, MoreFood) :-
Fish > 0,
answer(how_many_food, MoreFood).
% If customer has bought 10 or more fish and no tank to put them in,
% ask if he wants a fish tank. If answer is already provided, add
% fish tank (or not if he says no) to cart.
add_fish_tank(Fish, _, AddTank) :-
Fish < 10,
AddTank is 0,
!.
add_fish_tank(_, Tank, AddTank) :-
Tank > 0,
AddTank is 0,
!.
add_fish_tank(Fish, Tank, AddTank) :-
Fish >= 10,
Tank == 0,
not(answer(add_a_tank, _)),
assert(question(add_a_tank)),
AddTank is 0,
fail.
add_fish_tank(Fish, Tank, AddTank) :-
Fish >= 10,
Tank == 0,
answer(add_a_tank, AddTank).
% Apply a 10% discount on orders over $50.
apply_discount(Total, Discount) :-
Total < 50,
Discount is 0.
apply_discount(Total, Discount) :-
Total >= 50,
Discount is 0.1 * Total.
% The main goal which is called from the Java client. Applies the sub-goals
% in sequence, failing if there is not enough information to go ahead. If
% all information is provided, then populates a new shopping cart and asserts
% it into the factbase, from which it should be retrieved by the client.
checkout(Cart) :-
retractall(answer(_, _)),
retractall(question(_)),
retractall(cart(_)),
itemize(Cart, Fish, Food, Tank),
add_free_food(Fish, FreeFood),
add_more_food(Fish, MoreFood),
TotalFood is Food + MoreFood,
add_fish_tank(Fish, Tank, AddTank),
Total is (5 * Fish) + (2 * TotalFood) + (40 * AddTank),
apply_discount(Total, Discount),
append([], [Fish, TotalFood, AddTank, FreeFood, Discount], NewCart),
assert(cart(NewCart)),
!.
|
A typical session within the SWI-Prolog listener application looks like this. This is the same flow that the Java client will use as well.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 | ?- consult('petstore2.pro').
% petstore2.pro compiled 0.00 sec, 0 bytes
true.
?- checkout([10, 1, 0]).
false.
?- question(X).
X = how_many_food.
?- checkout([10, 1, 0, answer(how_many_food,2)]).
false.
?- question(X).
X = add_a_tank.
?- checkout([10, 1, 0, answer(how_many_food,2), answer(add_a_tank,1)]).
true.
?- cart(X).
X = [10, 3, 1, 2, 5.6].
|
Java Connectivity: Setting up JPL
For calling the rulebase from within Java, I used JPL, a native (JNI) Java library which provides bi-directional connectivity between SWI-Prolog and Java, and which comes bundled with the version of my SWI-Prolog (5.6.64). I copied the JAR file from the distribution into my Maven repository and added it as a dependency to my POM. Here is the snippet.
1
2
3
4
5
6
7
8
9
10
11
12 | <project...>
<dependencies>
...
<dependency>
<groupId>swiprolog</groupId>
<artifactId>jpl</artifactId>
<version>5.6.64</version>
<scope>compile</scope>
</dependency>
</dependencies>
...
</project>
|
At this point, I could write and compile the client but not run it, since it kept failing with the error "UnsatisfiedLinkError: no jpl in java.library.path". Googling for solutions, I came across many pages like this one, but none of them worked for me - perhaps they are all Windows centric?. Ultimately, I remembered using LD_LIBRARY_PATH back in the days when I used JNI libraries, and setting that in my environment (I use Linux, Fedora Core 9, in case that helps) worked instantly with no other change. Here is the snippet from my .bash_profile. Your SWI_Prolog installation directory may be different if you are installing from a package, I had to compile mine from source because there were no distributions on 64-bit platforms.
| # Prolog/JPL native access
export LD_LIBRARY_PATH=/usr/local/lib/pl-5.6.64/lib/x86_64-linux
|
Java Client
The JPL library has a very clean, minimalistic feel. The code here is based on the examples in the JPL Getting Started page. One caveat - the documentation is either outdated or incorrect, at least in the "Querying with Variables" section - I wasted quite a bit of time trying to figure out why I could not get back the value of the variable from the Prolog rulebase. Ultimately it turned out that I was not naming the variable, and looking up with the Variable reference as shown in the page. The code here pointed me in the right direction. So anyway, here is the Java code for the client.
A "real" client would instantiate the client, and call its init() method, then call the checkout(PetstoreCart) method with various values of PetstoreCart, and finally terminate it with the destroy() method. The checkout(PetstoreCart) method calls the _checkout(PetstoreCart) method in a loop until the cart is fully processed, then returns it.
It is worth pointing out that the _checkout(PetstoreCart) is synchronized. This is because there is only a single Prolog engine per JVM, so access from multiple threads must be serialized. When client code calls _checkout, it passes in the input cart data (see PetstoreCart.clone()), along with an answer map of questions already answered. The first 3 lines of the Prolog checkout/1 goal cleans up any data left over from a previous call. Within the synchronized _checkout(PetstoreCart) code, the code first makes a call to checkout/1, then based on the goal status, makes another call to either cart/1 or question/1.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206 | // Source: src/main/java/net/sf/jtmt/inferencing/prolog/PrologPetstoreClient.java
package net.sf.jtmt.inferencing.prolog;
import java.io.Console;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import jpl.Atom;
import jpl.JPL;
import jpl.Query;
import jpl.Term;
import jpl.Util;
import jpl.Variable;
import jpl.fli.Prolog;
/**
* A JPL client to the Prolog rules for the Drools Petstore Example.
* The basic idea is that the client calls the checkout goal repeatedly
* until it succeeds. If it fails, the checkout goal will assert a
* question which needs to be answered before the goal can proceed
* further, which the Java client reads and answers.
*/
public class PrologPetstoreClient {
private final String RULES_FILE =
"src/main/prolog/net/sf/jtmt/inferencing/prolog/petstore2.pro";
/**
* Connects to the engine and injects the rules file into it. This is
* done once during the lifetime of the engine.
* @throws Exception if thrown.
*/
public void init() throws Exception {
JPL.init();
Query consultQuery = new Query("consult", new Term[] {
new Atom(RULES_FILE)});
if (! consultQuery.hasSolution()) {
throw new Exception("File not found: " + RULES_FILE);
}
consultQuery.close();
}
/**
* Stops the prolog engine.
* @throws Exception if thrown.
*/
public void destroy() throws Exception {
Query haltQuery = new Query("halt");
haltQuery.hasSolution();
haltQuery.close();
}
/**
* Called by client after the PetstoreCart reaches the checkout phase.
* This method is unsynchronized because it will go back and forth for
* user input. This is the version that is expected to be called from
* the client.
* @param cart the Petstore cart object.
* @return the processed cart object.
*/
public PetstoreCart checkout(PetstoreCart cart) {
return checkout(cart, null);
}
/**
* Overloaded version for testing. This contains a hook to supply the
* answers to the questions, so we don't have to enter them at the
* command prompt.
* @param cart the PetstoreCart object to checkout.
* @param answers a Map of question and answer.
* @return the processed PetstoreCart object.
*/
protected PetstoreCart checkout(PetstoreCart cart,
Map<String,String> answers) {
PetstoreCart newCart = null;
for (;;) {
newCart = _checkout(cart);
if (newCart.isInProgress()) {
String question = newCart.getQuestion();
String answer = prompt(question, answers);
newCart.getAnswers().put(question, answer);
} else {
break;
}
}
return newCart;
}
/**
* Prompt the user for the question and waits for an answer, or
* gets the answer from the answers map, if provided.
* @param question the question to answer.
* @param answers a Map of question and answer.
* @return the answer to the question.
*/
private String prompt(String question, Map<String,String> answers) {
if (answers == null) {
Console console = System.console();
if (console != null) {
return console.readLine(">> " + question + "?");
} else {
throw new RuntimeException("No console, start client on OS prompt");
}
} else {
// run using a map of predefined answers (for testing).
if (answers.containsKey(question)) {
String answer = answers.get(question);
System.out.println(">> " + question + "? " + answer + ".");
return answer;
} else {
throw new RuntimeException("No answer defined for question:[" +
question + "]");
}
}
}
/**
* Called from checkout. This is the method that actually issues the
* checkout query to the Prolog engine, and in case of failure, retrieves
* the question to be answered, and in case of success, retrieves back
* the Cart object from the Prolog engine.
* @param cart the current form of the PetstoreCart object.
* @return the processed PetstoreCart object.
*/
private synchronized PetstoreCart _checkout(PetstoreCart cart) {
Query checkoutQuery = new Query("checkout",
new Term[] {buildPrologCart(cart)});
boolean checkoutSuccessful = checkoutQuery.hasSolution();
checkoutQuery.close();
if (checkoutSuccessful) {
// succeeded, get cart from factbase
Variable X = new Variable("X");
Query cartQuery = new Query("cart", new Term[] {X});
Term prologCart = (Term) cartQuery.oneSolution().get("X");
PetstoreCart newCart = parsePrologCart(prologCart);
newCart.setInProgress(false);
cartQuery.close();
return newCart;
} else {
// failed, get question, and stick it into question
PetstoreCart newCart = cart.clone();
Variable X = new Variable("X");
Query questionQuery = new Query("question", new Term[] {X});
newCart.setQuestion(
String.valueOf(questionQuery.oneSolution().get("X")));
newCart.setInProgress(true);
questionQuery.close();
return newCart;
}
}
/**
* Builds a Term representing a Prolog List object from the contents
* of a PetstoreCart. The List is passed to the checkout goal.
* @param cart the PetstoreCart object.
* @return a Term to pass to the checkout goal.
*/
private Term buildPrologCart(PetstoreCart cart) {
StringBuilder prologCart = new StringBuilder();
prologCart.append("[").
append(String.valueOf(cart.getNumFish())).
append(",").
append(String.valueOf(cart.getNumFood())).
append(",").
append(String.valueOf(cart.getNumTank()));
Map<String,String> answers = cart.getAnswers();
for (String question : answers.keySet()) {
prologCart.append(",").
append("answer(").
append(question).append(",").
append(answers.get(question)).
append(")");
}
prologCart.append("]");
return Util.textToTerm(prologCart.toString());
}
/**
* Parses the returned Term object representing a Prolog List that
* represents the processed contents of the cart. The Term is
* returned as a compound term of nested "." (concat) functions.
* @param prologCart the Term representing the Cart, returned from
* querying Prolog with cart(X).
* @return a PetstoreCart object.
*/
private PetstoreCart parsePrologCart(Term prologCart) {
List<String> elements = new ArrayList<String>();
Term term = prologCart;
for (;;) {
if (term.type() == Prolog.COMPOUND) {
elements.add(term.arg(1).toString());
term = term.arg(2);
} else if (term.type() == Prolog.ATOM) {
break;
}
}
PetstoreCart cart = new PetstoreCart(
Integer.valueOf(elements.get(0)),
Integer.valueOf(elements.get(1)),
Integer.valueOf(elements.get(2)));
cart.setNumFreeFood(Integer.valueOf(elements.get(3)));
cart.setDiscount(Float.valueOf(elements.get(4)));
return cart;
}
}
|
The PetstoreCart class is a simple bean with a custom clone() and prettyPrint() methods. The bean is shown (without the getter/setter methods for brevity) below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50 | // Source: src/main/java/net/sf/jtmt/inferencing/prolog/PetstoreCart.java
package net.sf.jtmt.inferencing.prolog;
import java.util.HashMap;
import java.util.Map;
import org.apache.commons.lang.builder.ReflectionToStringBuilder;
import org.apache.commons.lang.builder.ToStringStyle;
/**
* Simple bean to hold shopping cart information for PrologPetstoreClient.
*/
public class PetstoreCart {
private int numFish;
private int numFood;
private int numTank;
private String question;
private Map<String,String> answers =
new HashMap<String,String>();
private int numFreeFood;
private float discount;
private boolean inProgress;
public PetstoreCart(int numFish, int numFood, int numTank) {
this.numFish = numFish;
this.numFood = numFood;
this.numTank = numTank;
}
@Override
public PetstoreCart clone() {
PetstoreCart clone = new PetstoreCart(numFish, numFood, numTank);
clone.setAnswers(getAnswers());
return clone;
}
public String prettyPrint() {
StringBuilder buf = new StringBuilder();
buf.append("[").append(String.valueOf(getNumFish())).
append(", ").append(String.valueOf(getNumFood())).
append(", ").append(String.valueOf(getNumTank())).
append(", ").append(String.valueOf(getNumFreeFood())).
append(", ").append(String.valueOf(getDiscount())).
append("]");
return buf.toString();
}
...
}
|
The corresponding unit test for the client is shown below. I test several common boundary cases to make sure that the code works.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76 | // Source: src/test/java/net/sf/jtmt/inferencing/prolog/PrologPetstoreClientTest.java
package net.sf.jtmt.inferencing.prolog;
import java.util.Map;
import org.apache.commons.lang.ArrayUtils;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
/**
* Test cases to exercise the Prolog Petstore JPL client.
*/
public class PrologPetstoreClientTest {
private static PrologPetstoreClient CLIENT;
@BeforeClass
public static void setupBeforeClass() throws Exception {
CLIENT = new PrologPetstoreClient();
CLIENT.init();
}
@AfterClass
public static void teardownAfterClass() throws Exception {
CLIENT.destroy();
}
@Test
public void test5_0_0() throws Exception {
Map<String,String> answers = ArrayUtils.toMap(new String[][] {
new String[] {"how_many_food", "0"},
new String[] {"add_a_tank", "0"}
});
System.out.println("?- checkout([5, 0, 0]).");
PetstoreCart cart = CLIENT.checkout(new PetstoreCart(5, 0, 0), answers);
System.out.println("?- cart(" + cart.prettyPrint() + ").");
System.out.println();
}
@Test
public void test10_0_0() throws Exception {
Map<String,String> answers = ArrayUtils.toMap(new String[][] {
new String[] {"how_many_food", "5"},
new String[] {"add_a_tank", "0"}
});
System.out.println("?- checkout([10, 0, 0]).");
PetstoreCart cart = CLIENT.checkout(new PetstoreCart(10, 0, 0), answers);
System.out.println("?- cart(" + cart.prettyPrint() + ").");
System.out.println();
}
@Test
public void test10_0_0_1() throws Exception {
Map<String,String> answers = ArrayUtils.toMap(new String[][] {
new String[] {"how_many_food", "5"},
new String[] {"add_a_tank", "1"}
});
System.out.println("?- checkout([10, 0, 0]).");
PetstoreCart cart = CLIENT.checkout(new PetstoreCart(10, 0, 0), answers);
System.out.println("?- cart(" + cart.prettyPrint() + ").");
System.out.println();
}
@Test
public void test0_10_0() throws Exception {
Map<String,String> answers = ArrayUtils.toMap(new String[][] {
new String[] {"how_many_food", "0"},
new String[] {"add_a_tank", "0"}
});
System.out.println("?- checkout([0, 10, 0]).");
PetstoreCart cart = CLIENT.checkout(new PetstoreCart(0, 10, 0), answers);
System.out.println("?- cart(" + cart.prettyPrint() + ").");
System.out.println();
}
}
|
Here is the output from the JUnit test run. For completeness, one should put in Assert calls, but as you can see, the cart(X) values look correct. Another thing to notice is that based on the inputs, not all the questions are required everytime, so the system asks them only as needed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 | ?- checkout([5, 0, 0]).
>> how_many_food? 0.
?- cart([5, 0, 0, 1, 0.0]).
?- checkout([10, 0, 0]).
>> how_many_food? 5.
>> add_a_tank? 0.
?- cart([10, 5, 0, 2, 6.0]).
?- checkout([10, 0, 0]).
>> how_many_food? 5.
>> add_a_tank? 1.
?- cart([10, 5, 1, 2, 10.0]).
?- checkout([0, 10, 0]).
?- cart([0, 10, 0, 0, 0.0]).
|
What about Prova?
I did take another look at Prova, this time in anger, because I struggled quite a bit to get the JNI stuff to work. However, it turns out that Prova's Prolog dialect is different from SWI-Prolog, because it failed to parse my rulebase. I did not want to learn Prova and rewrite the Prolog code in Prova at that point, so I (fortunately, it turns out) decided to take another crack at JPL.
Another thing about Prova is that it comes with a lot of dependencies. That should probably not be a concern in our brave new Mavenized world, as long as Prova exposes the dependencies in its meta information. I don't know for sure either way, although they probably do since their JAR file appears to be built by Maven based on its naming convention. But in any case, I decided to stick with SWI-Prolog and JPL for now - just too lazy to learn another language right now, I guess.
One other thing that attracted me to Prova in the first place is its ability to connect to a database from within the rulebase and potentially generate facts directly from it. However, this is also possible to do with JPL (indirectly, by asserting facts into the rulebase from the Java client) as this post describes.