Of course, the implementation I am going to talk about here is naive and inefficient but the rules engine is just a pretext here: this is about Scala and how it shines. And it is also a subset of what Scala is good at: I mainly scratch the surface by focusing on pattern matching and collections handling.
The fact representation I use is the same than the one used by RuleML: a fact is a named relationship between predicates. For example, in "blue is the color of the sky", color is the relationship between blue and sky. The rules engine matches these facts on predefined patterns and infers new facts out of these matches.
In Scala, I naturally opted for using a case class to define a fact, as the pattern matching feature it offers is all what is needed to build inference rules:
- case class Fact(rel: String, predicates: Any*) {
- override def toString: String = rel + "{" + predicates.mkString(",") + "}"
- override def hashCode : Int = {
- var h = 31 + rel.hashCode
- predicates.foreach(predicate => h = 31 * h + predicate.hashCode)
- return h
- }
- }
For rules, I opted to have them created as objects constrained by a particular trait:
- trait Rule {
- def arity(): Int
- def run(facts: List[Fact]): Option[Fact]
- }
So how is the list of facts fed to the rule run method created? This is where the naive and inefficient part comes into play: each rule tells the engine the number of facts it needs to receive as arguments when called. The engine then feeds the rule with all the unique combinations of facts that match its arity.
This is an inefficient but working design. The really amazing part here is how Scala shines again by providing all what is required to build these combinations in a concise manner. This is achieved thanks to the for-comprehension mechanism as shown here:
- def combine[E](source : List[E], arity : Int) : List[List[E]] =
- rity match {
- case 0 => List(List())
- case _ =>
- for {
- a <- combine(source.tail ::: List(source.head), arity-1)
- b <- source
- if (!a.contains(b))
- } yield a ::: List(b)
- def processUntilDone(factBase : Set[Fact], ruleBase : List[Rule]) : Set[Fact] =
- // call process until the fact base stabilizes
- processRules(factBase, ruleBase) match {
- case processedFactBase if (processedFactBase == factBase)
- => factBase
- case processedFactBase
- => processUntilDone(processedFactBase, ruleBase)
- }
- def processRules(factBase : Set[Fact], ruleBase : List[Rule]) : Set[Fact] =
- ruleBase match {
- case rule :: tail
- => processOneRule(factBase, rule) ++ processRules(factBase, tail)
- case Nil
- => factBase
- }
- def processOneRule(factBase : Set[Fact], rule : Rule) =
- Set() ++ combine(factBase.toList, rule.arity).flatMap(facts => rule.run(facts))
- val factBase = Set[Fact](
- Fact("Luxury", "Porsche"),
- Fact("Regular", "Honda"),
- Fact("Spending", "Peter Miller", 2007, 5500),
- Fact("Spending", "John Doe", 2007, 4500))
- object premiumCustomerRule extends Rule {
- def arity() = 1
- def run(facts: List[Fact]): Option[Fact] =
- facts match {
- case List(Fact("Spending", customer: String, year: Int, amount: Int)) if (amount >= 5000)
- => Some(Fact("Premium", customer))
- case _
- => None
- }
- }
- object luxuryDiscountRule extends Rule {
- def arity() = 2
- def run(facts: List[Fact]): Option[Fact] =
- facts match {
- case List(Fact("Premium", customer: String), Fact("Luxury", item: String))
- => Some(Fact("Discount", customer, item, "7.5%"))
- case _
- => None
- }
- }
- val ruleBase = premiumCustomerRule :: luxuryDiscountRule :: Nil
- processUntilDone(factBase, ruleBase).foreach(fact => println(fact))
- Spending{Peter Miller,2007,5500}
- Spending{John Doe,2007,4500}
- Regular{Honda}
- Luxury{Porsche}
- <span style="font-weight: bold;">Premium{Peter Miller}</span>
- <span style="font-weight: bold;">Discount{Peter Miller,Porsche,7.5%}</span>
So why does Scala matter? I believe the JVM is still the best execution platform available as of today. It is stable, fast, optimized, cross-platform and manageable. An alternative byte-code compiled language like Scala opens the door to new approaches in software development on the JVM.
Closing note: an interesting next step will consist in parallelizing the engine by using Scala actors. But this is another story...