Revolution
An Experiment With Rule Engines
Table of Contents
Introduction
I like rule engines. They are a simple way to specify your program and overall I have found them to be pretty great to work with. However, one thing I have been thinking about is how one might make a UI for a rule engine. Throwing objects in it and taking them out is all well and good, but what if we could also define the display as a series of rules that determine the presentation.
Here, I do exactly that. I think that overall it has been an interesting adventure, currently it is based on clara-rules and reagent, 1 Which is based in react, hence calling this little thing revolution as a pun on react. however I think it would be entirely reasonable to implement the virtual DOM as a collection of WMEs, with updates to the DOM closely matching updates to the WMEs.
Alternatively, I may just tear both clara and reagent from it and make my own entirely. 2 I have been working on my own little logic programming language on the side. However, while I do like the general concept of forwards chaining representing operations on a UI, I do think there needs to be more backwards chaining related stuff for determining the layout, probably obviating CSS entirely.
Implementation
Writing the little experiment was trivial, and honestly it took way less code than I expected, only about 100 lines. Actually finishing the features for a virtual DOM might end up taking perhaps another 100 or so, but not all that much considering what's implemented.
CLJS
Namespace
As you might imagine, the main namespace depends on reagent and clara-rules. 3 I generally prefer to use refer stylistically for stuff I use a lot
(ns revolution.core (:require [reagent.core :as reagent] [reagent.dom :as dom] [clara.rules :refer [query retract! insert! retract insert insert-all fire-rules]]))
Core Objects
At the core of it we define components and top-level components, which can be trivially converted into HTML. 4 These are similar to the clojure.xml objects. However, I may eventually end up replacing this with something like a component protocol which is then reified by specific types like div
elements and so on, or alternatively further decouple it from HTML.
(defrecord Component [tag attrs content]) (defrecord TLComponent [tag attrs content])
We also, of course, define functions to create these objects with a syntax vaguely reminiscent of hiccup, because I do like the syntax.
(defn com "Create a component." [tag & args] (let [[attr & content] (if-not (= (type (first args)) cljs.core/PersistentArrayMap) (cons nil args) args)] (->Component tag attr content))) (defn tlcom "Create a toplevel component." [tag & args] (let [[attr & content] (if-not (= (type (first args)) cljs.core/PersistentArrayMap) (cons nil args) args)] (->TLComponent tag attr content)))
Converting to Hiccup
We, of course, define a simple function to convert it to hiccup syntax. Again, note that with the above changes this might also change considerably.
(defn repr->hiccup [{:keys [tag attrs content] :as obj}] (if (contains? #{Component TLComponent} (type obj)) (vec (concat [tag] (when attrs [attrs]) (if (or (vector? content) (seq? content)) (map repr->hiccup content) [(repr->hiccup content)]))) obj))
Defining the Global Session
To actually get the code to display (and for the system to work) we define a global, dynamic session variable which will be set by the system and modified by the extern
functions, which are used in on-change
functions.
(def ^:dynamic *session* nil)
Extern Functions
We also define a series of extern functions. 5 I seriously need to have a better name for these. To ensure that redisplays occur on changes, we use a flip-flop value that the extern functions reset.
(def reagent-flip-flop "A value that is flipped between negative and positive in order to get reagent to redisplay stuff." (reagent/atom true))
The most interesting extern function here is the alter-extern
function, as it unconditionally inserts a new fact into the system, which does break truth maintenance. However, since these are all side-effectual I don't think that matters or can be made to really exist coherently.
(defn alter-extern! [old-fact new-fact] (doto *session* (swap! retract old-fact) (swap! insert new-fact) (swap! fire-rules)) (swap! reagent-flip-flop not)) (defn insert-extern! [fact] (doto *session* (swap! insert fact) (swap! fire-rules)) (swap! reagent-flip-flop not)) (defn retract-extern! [fact] (doto *session* (swap! retract fact) (swap! fire-rules)) (swap! reagent-flip-flop not))
Displaying and Mounting the Session
Finally we want to display the session and do so using this function. Here the reagent-flip-flop
value is used to ensure re-rendering happens when a retraction, insertion, or alter function is executed.
(defn display "Update the state of the display given the session atom." [session-atom] @reagent-flip-flop [:div (->> (-> session-atom (swap! fire-rules) (query "revolution.core/all-tlcomponents")) (map :?tl) (map repr->hiccup) (concat [:div]) vec)])
Mounting our session is also easy, simply setting the session variable in the system and making a call to the render function.
(defn mount-session [session element-id] (set! *session* (reagent/atom session)) (swap! *session* fire-rules) (dom/render [display *session*] (.getElementById js/document element-id)))
CLJ
Namespace
To define our macros we need to add a new file for Clojure code with the same namespace. It will be automatically loaded during the macroexpansion phase. 6 Which occurs when you use :refer-macros
. In essence Clojure has a two-tier importation system
(ns revolution.core (:require [clara.rules :refer [defsession insert-all]]))
defact
and defengine
Macros
Our first macro is a simple one which defines a fact which is to be inserted into the rule engine at the start of its execution. This is sort of an extension of clara-rules which I have always thought should have been added.
;; adds a base fact to the rule base at engine start (defmacro defact [value] `(def ~(gensym) (with-meta ~value {:fact true})))
To ge tthis to work, we define an extension to the defsession
macro to create the session which includes the facts we define, called defengine
. I may end up renaming this, or even aliasing the defsession macro.
(defmacro defengine [name sources-and-options] (let [session-name (gensym)] `(do (defsession ~session-name [(quote {:ns-name revolution.core :lhs [{:type revolution.core/TLComponent :constraints [] :fact-binding :?tl}] :params #{} :name "revolution.core/all-tlcomponents"})] ~@sources-and-options) (def ~name (insert-all ~session-name (->> [~@(map (fn [n] `(ns-publics ~n)) sources-and-options)] (apply merge) (map second) (map #(deref %)) (filter #(:fact (meta %)))))))))