Note: The joke in the paragraph below is now a real thing: odoyle-rules
A library providing an alternative syntax for Clara Rules. After deep contemplation, thorough discussion with the greatest minds I know, and summoning the willpower of a Greek god, I decided to resist calling it O'Doyle Rules. You are welcome.
Why does clara need a new coat of paint? Consider an example using clara's current syntax:
(ns examples.clara
(:require [clara.rules :as clara #?(:clj :refer :cljs :refer-macros) [defsession defquery defrule]]
[clara.rules.accumulators :as acc]))
(defrecord Player [x y health])
(defrecord Enemy [x y health])
(defrule remove-dead-enemies
[?enemy <- Enemy (= health 0)]
=>
(clara/retract! ?enemy))
(defquery get-player
[]
[?player <- Player])
(defquery get-nearby-enemies
[]
[?player <- Player]
[?enemy <- (acc/all) :from [Enemy (= (:x ?player) x) (= (:y ?player) y)]])
(defquery get-enemies-at
[:?x :?y]
[?enemy <- (acc/all) :from [Enemy (= ?x x) (= ?y y)]])
(defsession session 'examples.clara)
(def *session
(-> session
(clara/insert (->Player 1 1 10)
(->Enemy 1 1 10)
(->Enemy 1 1 10)
(->Enemy 2 2 10)
(->Enemy 2 2 0))
clara/fire-rules
atom))
(-> (clara/query @*session get-player)
first :?player println)
;; => #examples.clara.Player{:x 1, :y 1, :health 10}
(-> (clara/query @*session get-nearby-enemies)
first :?enemy println)
;; => [#examples.clara.Enemy{:x 1, :y 1, :health 10} #examples.clara.Enemy{:x 1, :y 1, :health 10}]
(-> (clara/query @*session get-enemies-at :?x 2 :?y 2)
first :?enemy println)
;; => [#examples.clara.Enemy{:x 2, :y 2, :health 10}]
;; this only returns one, because the other enemy at (2, 2) was removed by the remove-dead-enemies rule
I see two main problems:
- The syntax for defining rules and queries is a bit weird and hard to read. The whole
<-
thingy for binding local names, the:from [...]
syntax for accumulators, and so on. I think we can make this more intuitive. - We are forced to make an icky global var for every rule and query we define. Generally it's better for a macro to just return a value and let the user
def
it themselves if they want to.
Here's the same program using clarax:
(ns examples.clarax
(:require [clara.rules :as clara]
[clara.rules.accumulators :as acc]
#?(:clj [clarax.macros-java :refer [->session]]
:cljs [clarax.macros-js :refer-macros [->session]])))
(defrecord Player [x y health])
(defrecord Enemy [x y health])
(def *session
(-> ;; all rules/queries are specified in a single hash map.
;; as you can see, rules look like familiar `let` expressions,
;; mapping a name (enemy) to a record type (Enemy).
;; any binding pair can be followed by a :when expression,
;; similar to the :when expressions in `for`, `doseq`, etc.
{:remove-dead-enemies
(let [enemy Enemy
:when (= (:health enemy) 0)]
;; the body of the `let` is what will run when the rule fires
(clara/retract! enemy))
;; queries look exactly like rules, except the are surrounded by `fn`
:get-player
(fn []
(let [player Player]
;; the body of the `let` determines what the query will return.
;; in this case, it will simply return the entire player record.
player))
:get-nearby-enemies
(fn []
(let [player Player
enemy Enemy
:accumulator (acc/all) ;; this is how you use an accumulator
:when (and (= (:x player) (:x enemy))
(= (:y player) (:y enemy)))]
enemy))
:get-enemies-at
(fn [?x ?y]
(let [{:keys [x y] :as enemy} Enemy ;; you can destructure just like in a normal `let` form
:accumulator (acc/all)
:when (and (= ?x x) (= ?y y))]
enemy))}
;; this macro creates the session from the hash map
->session
;; you use the same functions from clara to insert and fire rules
(clara/insert (->Player 1 1 10)
(->Enemy 1 1 10)
(->Enemy 1 1 10)
(->Enemy 2 2 10)
(->Enemy 2 2 0))
clara/fire-rules
atom))
(println (clara/query @*session :get-player))
;; => #examples.clara.Player{:x 1, :y 1, :health 10}
(println (clara/query @*session :get-nearby-enemies))
;; => [#examples.clara.Enemy{:x 1, :y 1, :health 10} #examples.clara.Enemy{:x 1, :y 1, :health 10}]
(println (clara/query @*session :get-enemies-at :?x 2 :?y 2))
;; => [#examples.clara.Enemy{:x 2, :y 2, :health 10}]
;; this only returns one, because the other enemy at (2, 2) was removed by the remove-dead-enemies rule
When compiling with ClojureScript, it's possible for that map of rules and queries to become too large, at which point you'll get a Method code too large!
error. And since ->session
is a macro, it needs that map to exist at compile time, so merging smaller maps together at runtime won't work. Instead, you can merge the maps together at compile time with a macro. See the dungeon-crawler game for an example of this.
As mentioned before, clarax doesn't force you to define top-level vars; queries are done by passing keywords to the clara.rules/query
function. That being said, as a performance optimization you may want to pull them out into their own vars. You can do so like this:
(let [query-fns (clarax.rules/query-fns @*session)]
(def get-player (:get-player query-fns))
(def get-nearby-enemies (:get-nearby-enemies query-fns))
(def get-enemies-at (:get-enemies-at query-fns)))
(println (get-player @*session))
;; => #examples.clara.Player{:x 1, :y 1, :health 10}
(println (get-nearby-enemies @*session))
;; => [#examples.clara.Enemy{:x 1, :y 1, :health 10} #examples.clara.Enemy{:x 1, :y 1, :health 10}]
(println (get-enemies-at @*session {:?x 2 :?y 2}))
;; => [#examples.clara.Enemy{:x 2, :y 2, :health 10}]
- Install the Clojure CLI tool
- To run the examples in this README:
clj -A:dev
- To run the tests:
clj -A:test
- To install the release version:
clj -A:prod install
All files that originate from this project are dedicated to the public domain. I would love pull requests, and will assume that they are also dedicated to the public domain.