Showing posts with label rules. Show all posts
Showing posts with label rules. Show all posts

Saturday, February 01, 2014

A Drools example with Scala


I've been thinking about using Drools to build up a rule-based query expansion layer. Since we aim to provide semantic search backed by our medical taxonomy, almost all incoming queries are enriched and rewritten to produce results that we think the user will find more useful than plain keyword matches. Each client is different, so over time, we have built up a fairly comprehensive library of query components that aim to address different search intents, which we can use to compose our ultimate query.

In our previous Lucene based search, I had built a platform in which developers could "compose" customized searchers by configuring prebuilt searcher components using Spring XML. The result was a federated search, whereby an incoming query would be rewritten in several different ways and the results layered together.

When we moved to Solr about 3 years ago, we customized Solr (for a customer) to make it do the same thing. While we continue to support this approach, we are gradually trying to move to a single query approach with custom subqueries instead. Different clients may trigger these subqueries in different ways, which is where the rules engine idea (perhaps even database-driven) comes in. By allowing each client to extend a default ruleset specifying under what situations each query component should be invoked, we can achieve functionality similar to the Spring XML approach.

In this post, I attempt to model the automobile driving advisor rules from Dennis Merritt's article "Building Custom Rule Engines" (search for "traffic_light" in the article to see the original ruleset). While the ruleset doesn't seem to have anything in common with what I've been talking about, it uses similar abstractions, so this was a way for me to kick the tires with the setup and make sure it will work. The Drools driven query expansion layer is just the motivation for this work, the code for that would have to remain company confidential.

In any case, the ruleset described in the article is rewritten below using Drools's MVEL dialect. As you can see, there is a one-to-one correspondence between the rules in the article and the rules here.

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
// Source: src/main/resources/traffic.drl
package com.mycompany.solr4extras.rqe;

import com.mycompany.solr4extras.rqe.Traffic;
import com.mycompany.solr4extras.rqe.TrafficResponse;
import com.mycompany.solr4extras.rqe.DrivingStyle;

import com.mycompany.solr4extras.rqe.CityLocator;
global CityLocator cityLocator;

import function com.mycompany.solr4extras.rqe.Functions.*;

dialect "mvel"
no-loop

rule "traffic light green"
when
  $traffic : Traffic ( light == "green" )
then
  insertTrafficResponse(kcontext, $traffic, "proceed")
end

rule "traffic light red"
when 
  $traffic : Traffic ( light == "red" )
then
  insertTrafficResponse(kcontext, $traffic, "stop")
end

rule "traffic light yellow and driving crazy"
when 
  $traffic : Traffic ( light == "yellow" )
  DrivingStyle ( style == "crazy" )
then
  insertTrafficResponse(kcontext, $traffic, "accelerate")
end

rule "traffic light yellow and driving sane"
when 
  $traffic : Traffic ( light == "yellow" )
  DrivingStyle ( style == "sane" )
then
  insertTrafficResponse(kcontext, $traffic, "stop")
end

rule "city is Boston"
when
  $traffic : Traffic (eval (cityLocator.city($traffic) == "Boston" ) )
then
  insertDrivingStyle(kcontext, "crazy")
end

rule "city is not Boston"
when
  $traffic : Traffic (eval (cityLocator.city($traffic) != "Boston" ) )
then
  insertDrivingStyle(kcontext, "sane")
end

And here is the Scala part of this ruleset. TrafficRulesTest is a JUnit test which starts up a new KnowledgeBuilder instance backed by the traffic.drl file above, then inserts a Traffic bean with various traffic light conditions and various city IDs into Drool's StatefulKnowledgeSession, and reads back the TrafficResponse (indicating what the driver should do) from the session after the rules have fired.

The case classes Traffic, TrafficResponse and DrivingStyle represent beans that pass information between the rules engine and the Scala code. CityLocator is a global "utility" class that produces a city name from a city ID, and is instantiated and introduced into the session from the Scala code. The Functions object is a holder of functions that are called by the rule consequences that create the TrafficResponse beans.

One useful piece of information is the kcontext object (its a specially named object on the DRL side that holds the RuleContext, from which you can pull out the session and other useful references on the Scala side. I use this feature in both the methods in the Functions object.

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
// Source: src/test/scala/com/mycompany/solr4extras/rqe/TrafficRulesTest.scala
package com.mycompany.solr4extras.rqe

import java.util.Collection
import org.junit.Test
import org.kie.api.io.ResourceType
import org.kie.api.runtime.rule.RuleContext
import org.kie.internal.KnowledgeBaseFactory
import org.kie.internal.builder.KnowledgeBuilderFactory
import org.kie.internal.io.ResourceFactory
import org.kie.internal.runtime.StatefulKnowledgeSession
import scala.collection.JavaConversions._
import org.junit.Assert

class TrafficRulesTest {

  val kbuilder = KnowledgeBuilderFactory
    .newKnowledgeBuilder()
  kbuilder.add(ResourceFactory
    .newClassPathResource("traffic.drl"), 
    ResourceType.DRL)
  if (kbuilder.hasErrors()) {
    throw new RuntimeException(kbuilder
      .getErrors().toString())
  }

  val kbase = KnowledgeBaseFactory.newKnowledgeBase()
  kbase.addKnowledgePackages(
    kbuilder.getKnowledgePackages())

  @Test
  def testRedInBoston(): Unit = {
    val resp = runTest(Traffic("red", 0))
    Assert.assertEquals("stop", resp.action)
  }
  
  @Test
  def testRedInNewYork(): Unit = {
    val resp = runTest(Traffic("red", 1))
    Assert.assertEquals("stop", resp.action)
  }
    
  @Test
  def testGreenInBoston(): Unit = {
    val resp = runTest(Traffic("green", 0))
    Assert.assertEquals("proceed", resp.action)
  }
  
  @Test
  def testGreenInNewYork(): Unit = {
    val resp = runTest(Traffic("green", 1))
    Assert.assertEquals("proceed", resp.action)
  }

  @Test
  def testYellowInBoston(): Unit = {
    val resp = runTest(Traffic("yellow", 0))
    Assert.assertEquals("accelerate", resp.action)
  }
  
  @Test
  def testYellowInNewYork(): Unit = {
    val resp = runTest(Traffic("yellow", 1))
    Assert.assertEquals("stop", resp.action)
  }
  
  def runTest(traffic: Traffic): TrafficResponse = {
    val session = kbase.newStatefulKnowledgeSession()
    session.setGlobal("cityLocator", new CityLocator())
    session.insert(traffic)
    session.fireAllRules()
    val trafficResponse = 
        getResults(session, "TrafficResponse") match {
      case Some(x) => x.asInstanceOf[TrafficResponse]
      case None => null
    }
    session.dispose()
    trafficResponse    
  }
  
  def getResults(sess: StatefulKnowledgeSession,
      className: String): Option[Any] = {
    val fsess = sess.getObjects().filter(o => 
      o.getClass.getName().endsWith(className))
    if (fsess.size > 0) Some(fsess.toList.head)
    else None
  }
}

case class Traffic(light: String, cid: Int)
case class DrivingStyle(style: String)
case class TrafficResponse(action: String)

class CityLocator {
  
  def city(traffic: Traffic): String =
    if (traffic.cid == 0) "Boston"
    else "New York"
}

object Functions {
  
  def insertTrafficResponse(kcontext: RuleContext, 
      traffic: Traffic, 
      action: String): Unit = {
    // create and insert a TrafficResponse bean
    // back into the session
    val sess = kcontext.getKnowledgeRuntime()
      .asInstanceOf[StatefulKnowledgeSession]
    sess.insert(TrafficResponse(action))
    
    // log the step
    val rulename = kcontext.getRule().getName()
    val cityLocator = sess.getGlobal("cityLocator")
      .asInstanceOf[CityLocator]
    val city = cityLocator.city(traffic)
    Console.println("Rule[%s]: Traffic(%s at %s) => %s"
      .format(rulename, traffic.light, city, action))
  }
  
  def insertDrivingStyle(kcontext: RuleContext, 
      driveStyle: String): Unit = {
    val sess = kcontext.getKnowledgeRuntime()
      .asInstanceOf[StatefulKnowledgeSession]
    Console.println("Driving Style: %s"
      .format(driveStyle))
    sess.insert(DrivingStyle(driveStyle))
  }
}

Thats all I have for today, hope you enjoyed reading as much as I did learning about it. Drools is a bit of a niche, and its hard to find much information on how to get started step-by-step with it, but I found the Drools JBoss Rules 5.0 Developer's Guide from PackT publishing very helpful, although I did have to trawl the Drool mailing lists for answers to some of my problems.

Friday, March 01, 2013

Drools Rules in a Database, Take 2


Quite some time back, I wrote an article describing how I used Drools with database backed rules. Since Drools (version 2) did not support this at the time, I ended up writing some glue code that tied Commons Collections Predicates and Closures into Drools Rule Conditions and Consequences, then built my rules on top of that. It was for a proof of concept project which ultimately never materialized. Over the years, I've had requests for the code backing the article.

Fast forward 6 years, and I find myself trying to do something similar for an upcoming project. Time sure flies when you are having fun. Of course, by this time, Drools is up to version 5, and database backed rules is now natively supported, so its much easier this time round.

What I describe here is a small example I tried out to prove to myself that this will work before putting it into the main application. The example I use is part of a (machine generated) decision tree from Erik Siegel's book "Predictive Analytics", that predicts the risk of mortgage default, given factors such as loan amount, applicant income, etc. Machine generated or not, its a decision tree, and hence can be modeled as a set of rules, which is what I do here.

Rules in Drools (now called JBoss Rules) are written using the "when ${condition} then ${consequence}" pattern. In keeping with the pattern, our rules are also written into a single MySQL table, and the interesting data is contained in two columns: rule_cond (the condition) and rule_cons (the consequence). Here is the dump (edited for readability) of these columns for our toy application.

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
mysql> select rule_cond, rule_cons from mort_rules order by rule_name;
+---------------------------------------+------------------------------------+
| rule_cond                             | rule_cons                          |
+---------------------------------------+------------------------------------+
| interestRate < 7.94 &&                | setRisk(kcontext, $mortgage, 2.6)  |
|   applicantIncome < 78223             |                                    |
+---------------------------------------+------------------------------------+
| interestRate < 7.19 &&                | setRisk(kcontext, $mortgage, 3.4   |
|   applicantIncome >= 78223            |                                    |
+---------------------------------------+------------------------------------+
| interestRate >= 7.19 &&               | setRisk(kcontext, $mortgage, 9.1)  |
|   interestRate < 7.94 &&              |                                    |
|   applicantIncome >= 78223            |                                    |
+---------------------------------------+------------------------------------+
| interestRate >= 7.94 &&               | setRisk(kcontext, $mortgage, 8.1)  |
|   mortgageAmount < 67751 &&           |                                    |
|   loanToValue < 87.4                  |                                    |
+---------------------------------------+------------------------------------+
| interestRate >= 7.94 &&               | setRisk(kcontext, $mortgage, 8.5)  |
|   mortgageAmount < 182926 &&          |                                    |
|   mortgageAmount >= 67751 &&          |                                    |
|   loanToValue < 87.4 &&               |                                    |
|   interestRate < 8.69 &&              |                                    |
|   isCondo == 1                        |                                    |
+---------------------------------------+------------------------------------+
| interestRate >= 7.94 &&               | setRisk(kcontext, $mortgage, 16.3) |
|   mortgageAmount < 182926 &&          |                                    |
|   mortgageAmount >= 67751 &&          |                                    |
|   loanToValue < 87.4 &&               |                                    |
|   interestRate < 8.69 &&              |                                    |
|   isCondo == 0                        |                                    |
+---------------------------------------+------------------------------------+
| interestRate >= 7.94 &&               | setRisk(kcontext, $mortgage, 25.6) | 
|   mortgageAmount < 182926 &&          |                                    |
|   mortgageAmount >= 67751 &&          |                                    |
|   loanToValue < 87.4 &&               |                                    |
|   interestRate >= 8.69                |                                    |
+---------------------------------------+------------------------------------+
| interestRate >= 7.94 &&               | setRisk(kcontext, $mortgage, 6.4)  | 
|   mortgageAmount < 182926 &&          |                                    |
|   loanToValue >= 87.4                 |                                    |
+---------------------------------------+------------------------------------+
| interestRate >= 7.94 &&               | setRisk(kcontext, $mortgage, 15.2) | 
|   mortgageAmount >= 182926 &&         |                                    |
|   isCondo == 1                        |                                    |
+---------------------------------------+------------------------------------+
| interestRate >= 7.94 &&               | setRisk(kcontext, $mortgage, 40.0) | 
|   mortgageAmount >= 182926 &&         |                                    |
|   isCondo == 0                        |                                    |
+---------------------------------------+------------------------------------+
10 rows in set (0.01 sec)

These rules work are substituted into a DRT (Drools Template) file, which is written in MVEL and in my case looks something like this. Drools provides a ResultSetGenerator object which will use the ResultSet to build a large DRL string.

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
// Source: src/main/resources/mort_rules.drt
template header
// list of columns that the SELECT will return
rule_cond
rule_cons

// header (will be written out once)
package com.mycompany.mortgage;

dialect "mvel"
import function com.mycompany.mortgage.MortgageFunctions.setRisk;

template "mortgage"
// this section will be repeated for each database row, the value
// of x will be written out for @{x}.

rule "mortgage_@{row.rowNumber}"
no-loop
when
  $mortgage: Mortgage(@{rule_cond})
then
  @{rule_cons}
end

end template

Here is the code to run a bunch of mortgages (represented by the Mortgage object) through the rules thus generated.

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
// Source: src/main/scala/com/mycompany/mortgage/MortgageRiskCalculator.scala
package com.mycompany.mortgage

import java.io.{FileInputStream, StringReader}
import java.sql.DriverManager

import org.drools.RuleBaseFactory
import org.drools.compiler.PackageBuilder
import org.drools.runtime.rule.RuleContext
import org.drools.template.jdbc.ResultSetGenerator

object MortgageRiskCalculator extends App {
  val mortgages = Seq(
    new Mortgage(120000.00, 2.3, 50000.00, 75.5, 1, -1.0),
    new Mortgage(360000.00, 4.7, 100000.00, 12.0, 0, -1.0),
    new Mortgage(400000.00, 7.5, 250000.00, 20.0, 0, -1.0))
  val rules = new RiskRules("src/main/resources/mort_rules.drt")
  rules.runRules(mortgages)
  mortgages.foreach(x => println("risk=" + x.risk))
}

class Mortgage(
    val mortgageAmount: Double, 
    val interestRate: Double, 
    val applicantIncome: Double, 
    val loanToValue: Double,
    val isCondo: Int,
    var risk: Double)
    
class RiskRules(template: String) {
  // set up database connection
  val jdbcUrl = "jdbc:mysql://localhost:3306/mydb"
  Class.forName("com.mysql.jdbc.Driver")
  val conn = DriverManager.getConnection(jdbcUrl, "myuser", "mypass")
  // build drl with data from database
  val preparedStmt = conn.prepareStatement("""
    select rule_cond, rule_cons 
    from mort_rules""")
  val resultSet = preparedStmt.executeQuery()
  val resultSetGenerator = new ResultSetGenerator()
  val drl = resultSetGenerator.compile(resultSet, 
    new FileInputStream(template))
  resultSet.close()
  preparedStmt.close()
  conn.close()
  // build rule base
  val packageBuilder = new PackageBuilder()
  packageBuilder.addPackageFromDrl(new StringReader(drl))
  val ruleBase = RuleBaseFactory.newRuleBase()
  ruleBase.addPackage(packageBuilder.getPackage())
  val workingMemory = ruleBase.newStatefulSession()
  
  def runRules(facts: Seq[Mortgage]): Unit = {
    facts.foreach(workingMemory.insert(_))
    workingMemory.fireAllRules()
    workingMemory.dispose()
  }
}

object MortgageFunctions {
  def setRisk(ctx: RuleContext, m: Mortgage, r: Double): Unit = {
    m.risk = r
  }
}

The MortgageRiskCalculator is the calling class that instantiates the RiskRules object and runs the Mortgages through the Rule. The RiskRules class is the central class, it reads the rules from the MySQL table, instantiates the RuleSetGenerator, creates the DRL, creates the RuleBase and sets the DRL into it, and finally instantiates a WorkingMemory. Its runRules method inserts all the Mortgage (facts) nto the WorkingMemory and fires all the rules.

In our case, there is only a single Consequence function setRisk that sets the risk value back into the Mortgage object. In practice there can be other such Consequences that are modelled as static methods of MortgageFunctions and are imported into the DRT file so they feel like a DSL to the rule maintainer.

The output of the run is the default risk associated with each mortgage (as a percentage).

1
2
3
risk=2.6
risk=3.4
risk=9.1

In the spirit of full disclosure, in case any of you want to replicate this, I built a standard SBT project with "g8 typesafehub/scala-sbt" and my build.sbt looks like this:

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
name := "mortgage"

version := "1.0"

scalaVersion := "2.9.2"

resolvers += "Typesafe Repository" at "http://repo.typesafe.com/typesafe/repo/"

// drools

libraryDependencies += "org.drools" % "drools-core" % "5.5.0.Final"

libraryDependencies += "org.drools" % "drools-compiler" % "5.5.0.Final"

libraryDependencies += "org.drools" % "drools-jsr94" % "5.5.0.Final"

libraryDependencies += "org.drools" % "drools-decisiontables" % "5.5.0.Final"

libraryDependencies += "org.drools" % "knowledge-api" % "5.5.0.Final"

// mysql

libraryDependencies += "mysql" % "mysql-connector-java" % "5.1.12"

So now that this functionality (support for database backed rules) is available as part of Drools, I finally have justification for deleting the old code (backing the article) off my hard disk :-).

Friday, September 11, 2009

Calling Prolog Rules from Java

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.

1
2
# 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.