Rules

Basics

The provisdom.simple.rules namespace contains the rule definitions. As we'll see, this ultimately forms a self-contained model of the system, which can be independently executed and tested with no effects implementations attached. Most of the clara-rules DSL applies to defining the rules and queries, and we'll just highlight the differences or extensions maali provides. If you're not familiar with forward-chaining rules, we suggest consulting the excellent clara-rules documentation. We will assume some familiarity with forward-chaining rules in what follows.

clara-rules defines individual rules, named with a symbol, using the defrule macro. Clojure namespaces are the default mechanism for grouping rules. maali provides a defrules macro which allows a group of rules to be defined under a symbol. The rules themselves are named with keywords. Similarly, clara-rules defines queries via defquery, and maali instead supplies defqueries. The default mechanism for fact typing in clara-rules is to use defrecord's. maali leverages spec, and fact types in maali correspond to named keys specs, or specs derived from keys via operations like merge. Thus facts are maps whose keys correspond to keyword specs. The specs defining the types used for tic-tac-toe are shown below. Note that for brevity we'll only show code fragments in this article, please refer to the original code to see the full context, namespace definitions, etc.

(s/def ::player #{:x :o}) (s/def ::position (s/int-in 0 9)) (s/def ::Move (s/keys :req [::player ::position])) (s/def ::CurrentPlayer (s/keys :req [::player])) (s/def ::Winner (s/keys :req [::player])) (s/def ::WinningSquare (s/keys :req [::position])) (s/def ::CatsGame (s/keys)) (s/def ::GameOver (s/keys)) (s/def ::teaching-mode boolean?) (s/def ::TeachingMode (s/keys :req [::teaching-mode])) (def-derive ::MoveRequest ::common/Request (s/keys :req [::position ::player])) (def-derive ::MoveResponse ::common/Response (s/keys :req [::position ::player])) (def-derive ::ResetBoardRequest ::common/Request)

def-derive is a short-cut macro defined in maali, which allows a fact spec to be "derived" from a parent. So in the above, ::MoveResponse inherits the keys of ::common/Response (which is just ::common/Request), and derive is called to establish the hierarchy. clara-rules respects this hierarchy, which proves useful for R³, as we'll see later.

A very quick review of rule syntax may be helpful. We'll examine one example from our tic-tac-toe game:

[::move-response! "Handle response to ::MoveRequest by inserting a new ::Move and switch ::CurrentPlayer to the opponent." [?request <- ::MoveRequest (= ?position position)] [::MoveResponse (= ?request Request) (= ?position position) (= ?player player)] [?current-player <- ::CurrentPlayer] => (rules/insert-unconditional! ::Move {::position ?position ::player ?player}) (rules/upsert! ::CurrentPlayer ?current-player assoc ::player (next-player ?player))]

For maali, rules are defined as vectors. The first element is the keyword name, which must be unique (in the session, not in the entire program). The name is followed by an optional doc string, then vectors defining the left-hand-side (LHS) clauses and bindings of the rule (the "if" part), followed by the => symbol and some number of Clojure(Script) expressions for the right-hand-side (RHS, or the "then" part). Variables starting with a ? are query variables, which are bound to fact data in the LHS, and can be reused in LHS for joins or tests, and in the RHS as data supplied to the RHS expressions. The most basic form of a rule-clause is shown by [::MoveResponse (= ?request Request) (= ?position position) (= ?player player)]. First we specify the type, followed by tests or binding clauses. Each clause gets an implicit :keys destructuring based on the attributes defined by the fact spec, so behind the scenes this clause is surrounded by something like (let [{:keys [::common/Request ::position ::player} fact] ...), allowing the local symbols Request, position, and player to hold the corresponding values and be used in expressions, here for binding. We can also bind a fact, as shown in the clause [?request <- ::MoveRequest (= ?position position)], where ?request is bound to an instance of ::MoveRequest. Note how both ?request and ?position combine to form a join with the ::MoveResponse clause, and how we can use these bindings in RHS expressions.

clara-rules allows for arbitrary Clojure expressions on the RHS, and thus arbitrary effects. We are restricting ourselves to only expressions which update the rule session, using the following functions:

  • provisdom.maali.rules/insert! - conditional insert, where any facts inserted are conditionally dependent on the LHS bindings, and will be retracted by TMS if those bindings become invalid.
  • provisdom.maali.rules/retract! - removes facts from the session. Always unconditional (i.e. facts are not put back if the LHS becomes invalid).
  • provisdom.maali.rules/insert-unconditional! - unconditional insert, facts not automatically retracted by TMS if LHS becomes invalid. The use of insert-unconditional! is generally thought to be a code smell when coding rules, but we'll explain why its usage is required and sensible for R³.
  • provisdom.maali.rules/upsert! - shorthand for a retract! followed by an insert-unconditional!, with syntax similar to the Clojure update function. upsert! is sort of like "update in place", and is only valid when applied to facts inserted unconditionally. The reason is that the retraction will make the LHS invalid, and thus invalidate any subsequent conditional inserts (conditional update-in-place represents a logical contradiction).

Our tic-tac-toe session will ultimately incorporate 9 rules. Two of these come from provisdom.maali.common and model some generic request/response logic. The other seven are defined in provisdom.simple.rules, and are specific to the tic-tac-toe project. A session is the clara-rules construct for combining rule/query definitions along with the current set of facts. maali also provides its own defsession macro.

Game Rules

We'll review the application-specific rules first.

[::winner! "For all the moves made by a player, if any contains a win then set the ::Winner fact and mark the winning squares." [?moves <- (acc/all) :from [::Move (= ?player player)]] [:test (< 0 (ai/score-board (squares->board ?moves) ?player 0))] => (let [player-positions (set (map ::position (filter #(= ?player (::player %)) ?moves))) winning-positions (some #(when (= 3 (count %)) %) (map (partial set/intersection player-positions) ai/winning-indices))] (doseq [position winning-positions] (rules/insert! ::WinningSquare {::position position})) (rules/insert! ::Winner {::player ?player}))] [::cats-game! "If nobody won and all of the squares have been used, it's a ::CatsGame." [:not [::Winner]] [?count <- (acc/count) :from [::Move]] [:test (apply = [9 ?count])] ;Not using = here due to clara-rules issue #357 => (rules/insert! ::CatsGame {})] [::game-over! "Game is over if either there is a ::Winner or ::CatsGame." [:or [::Winner] [::CatsGame]] => (rules/insert! ::GameOver {})]

The first three rules handle end-of-game scenarios. Note that we adopt the convention that rule names are suffixed with !, both to indicate that rules have the effect of changing the state, and to avoid name collisions with queries. ::winner determines if the game has been won. ?moves holds the result of accumulating all ::Move facts for a given player, and the [:test] clause uses the AI to check if that set of moves contains a winning position. If those two clauses are satisfied by the current facts, we insert some new facts. Obviously we insert a ::Winner fact indicating which player (:x or :o) won the game. We also identify which squares formed the winning moves (::WinningSquare).

::cats-game! is fairly simple, and can be read as "If there is no winner and all squares are full, insert a ::CatsGame fact". Note that ::CatsGame is just an empty map. This is a common pattern. There is no data to be carried by ::CatsGame, all we care about is its (non)existence. This pattern is also used in the ::game-over! rule, which simply tests if there exists either a ::Winner or ::CatsGame fact exists, and inserts the empty ::GameOver map accordingly.

These three rules hopefully give some insight into the benefits of using forward-chaining rules to enforce logical consistency. Rather than writing specific conditional logic with an implementation that imperatively updates the state, we express rules as logical constraints, statements that should always be true, and let the TMS perform the appropriate state changes to ensure that logical consistency. As we'll see, ::Move facts originate from the "outside world", either the human user or the AI "service". The ::Move facts are considered unconditional, in the sense that they do not depend on the existence of any other facts or rules. The three rules above, either directly or indirectly, depend on the current set of ::Move's. The TMS ensures that the conditions above are always true, given whatever set of ::Move facts we have. Thus ::Winner only ever exists once we've inserted the ::Move facts corresponding to a tic-tac-toe win; correspondingly TMS will ensure that if we have a ::Winner we also have ::GameOver. If we were to retract one of the winning ::Move's, TMS would also automatically retract ::Winner, and thus also ::GameOver.

The next four rules deal with requests and responses. Not surprisingly, these generally come in pairs, with one rule to generate a particular type of request and its partner handling the corresponding response. Let's look at the simpler example first, where we handle request/response for resetting the game.

[::reset-board-request! "Request to reset the game." [?moves <- (acc/all) :from [::Move]] [:test (not-empty ?moves)] [::common/ResponseFunction (= ?response-fn response-fn)] => (rules/insert! ::ResetBoardRequest (common/request ::common/Response ?response-fn))] [::reset-board-response! "Handle response for game reset, retract all existing ::Move's and ::MoveRequest's, make :x the ::CurrentPlayer." [?request <- ::ResetBoardRequest] [::common/Response (= ?request Request)] [?moves <- (acc/all) :from [::Move]] [?current-player <- ::CurrentPlayer] => (apply rules/retract! ::Move ?moves) (rules/upsert! ::CurrentPlayer ?current-player assoc ::player :x)])

::reset-board-request essentially states the following: "If the game has ::Move's then insert a ::ResetBoardRequest." There's a bit more going on which has to do with the implementation of R³. The ::common/ResponseFunction fact holds the function which implements the response logic. When presented with requests, effect code needs a way to provide a response. We don't want effects to be coupled to the details implementing response handling, but instead provide an abstraction that allows the response to be generated as a simple function call. Further, it is useful for debugging and testing to be able to use different implementations for response functions, which is also enabled by runtime injection. The provisdom.maali.common/request function sticks the response function into the request map, and the corresponding provisdom.maali.common/respond-to provides the point of interaction for effects. respond-to can be called with just the request (for dataless responses, e.g. occurring with click events) or the request and some associated response data.

The response function used in our example, defined in provisdom.simple.app/response-fn is shown below:

(defn response-fn [spec response] (swap! session-atom common/handle-response [spec response]))

So in our implementation, the clara-rules session is held in an atom, and that atom is updated by calling provisdom.maali.common/handle-response, which at its core simply inserts the response data and calls provisdom.maali.rules/fire-rules to yield an updated session.

For the ::ResetBoardRequest, an effect would respond by calling (common/respond-to request), where request is an instance of ::ResetBoardRequest. Effects get requests through queries, and we'll demonstrate examples of this later in the article. Calling common/respond-to thus causes a ::ResetBoardResponse to be inserted, (potentially) triggering the ::reset-board-response! rule. The first two clauses show a general pattern of how a response fact is correlated with it's specific request partner. Part of what common/respond-to does is add the ::Request attribute to the response map, allowing these two clauses to correctly bind the request/response pair. Subsequent clauses are binding data that we need to modify to reset the game:

  • All ::Move facts will be removed.
  • The ::CurrentPlayer is set back to :x (:x is the computer, who always goes first in our example).

The next two rules handle requests and responses for game moves:

[::move-request! "If the game isn't over, request ::Moves from the ::CurrentPlayer for eligible squares." [:not [::GameOver]] [::CurrentPlayer (= ?player player)] [?moves <- (acc/all) :from [::Move]] [::common/ResponseFunction (= ?response-fn response-fn)] => (let [all-positions (set (range 9)) empty-positions (set/difference all-positions (set (map ::position ?moves))) requests (map #(common/request {::position % ::player ?player} ::MoveResponse ?response-fn) empty-positions)] (apply rules/insert! ::MoveRequest requests))] [::move-response! "Handle response to ::MoveRequest by inserting a new ::Move and switch ::CurrentPlayer to the opponent." [?request <- ::MoveRequest (= ?position position)] [::MoveResponse (= ?request Request) (= ?position position) (= ?player player)] [?current-player <- ::CurrentPlayer] => (rules/insert-unconditional! ::Move {::position ?position ::player ?player}) (rules/upsert! ::CurrentPlayer ?current-player assoc ::player (next-player ?player))]

The LHS of the ::move-request! rule does the following:

  • Ensures the game is not over (can't move if the game is over)
  • Binds the current ?player
  • Gets all existing moves (since we shouldn't issue ::MoveRequests for positions which have already been marked)
  • Grabs the ?response-fn

The RHS calculates which squares are are still empty, constructs ::MoveRequests for those positions, and then inserts those requests.

::move-response! begins with the usual pattern joining the ::MoveResponse fact to its corresponding ::MoveRequest. We also bind ::CurrentPlayer, since after a move is made we need to update ::CurrentPlayer to give the other player a turn. The RHS performs an unconditional insert of the ::Move based on the data supplied in ::MoveResponse, as well as an upsert! of ::CurrentPlayer.

As noted before, unconditional inserts are often considered a sign of something bad in forward-chaining rule definitions. Why are we using them here? R³ is explicitly modeling interactions with the "outside world", entities (human or machine) which are not part of the rules session or bound by its constraints. When the user decides to mark the center square with "O", we have no facts or rules in our session providing logical support for that choice. So the ::Move is unconditional with respect to the rules and facts in our session, and a similar argument applies to ::CurrentPlayer (which only changes due to an external response). Another way to see this is through the logical consistency enforced by the TMS. When we insert a new ::Move, the ?moves binding LHS of ::move-request! is going to become invalid because now there's a different set of ::Move's in the session. Following the rule logic, we will no longer have a ::MoveRequest for the newly inserted move. That in turn makes the LHS of ::move-response! invalid. If the ::Move we inserted conditionally, that invalidation of the LHS of ::move-response! would result in the retraction of our new ::Move. So we have a general pattern used in R³: all facts which are modified by responses must be unconditional.

Common Rules

provisdom.maali.common defines two rules which are always included in R³ sessions.

(defrules rules [::cancel-request! "Cancellation is a special response that always causes the corresponding request to be retracted. Note that the ::retract-orphan-response! rule below will then cause the cancellation fact to also be retracted." [?request <- ::Cancellable] [::Cancellation (= ?request Request)] => (rules/retract! (rules/spec-type ?request) ?request)] [::retract-orphan-response! "Responses are inserted unconditionally from outside the rule engine, so explicitly retract any responses without a corresponding request." [?response <- ::Response (= ?request Request)] [:not [?request <- ::Request]] => (rules/retract! (rules/spec-type ?response) ?response)])

These common rules leverage the hierarchy created by derive. For R³ applications, fact types deriving from ::common/Request and ::common/Response will be covered by these rules. The ::cancel-request! rule isn't really relevant for our tic-tac-toe example, but it allows for requests which derive from ::common/Cancellable to be explicitly cancelled by inserting a special ::Cancellation response. ::retract-orphan-response! is more interesting, stating that if we find a response with no corresponding request, to retract the response. ::retract-orphan-response! serves two purposes:

  • Maintain logical consistency between the current valid requests and responses. When a response is inserted into the session, it often results in changes that invalidate its corresponding request, e.g. ::MoveResponse results in a ::Move which causes TMS to retract the associated ::MoveRequest. Leaving these orphaned responses in the session is, if nothing else, a memory leak. They could potentially be harmful as well, matching incorrectly with a new request (we will discuss later some implementation details in maali which avoid this problem).
  • Avoid "Heisenbugs" caused by race conditions. Suppose you made a move, were waiting for the AI service to respond, and realized you chose poorly and were about to lose the game. So you hit the "Reset" button, which causes the ::ResetBoardResponse to be sent. If we were to subsequently receive the ::MoveResponse from the AI service, inserting it could result in an extra move. ::retract-orphan-response ensures this does not occur.

Queries

clara-rules queries are similar to rules, with two main differences:

  • Queries have only a LHS with conditions and bindings, no RHS for effects or session updates.
  • Queries can be parameterized.

The queries defined for our tic-tac-toe game are shown below:

(defqueries queries [::move-request [:?position :?player] [?request <- ::MoveRequest (= ?position position) (= ?player player)]] [::reset-request [] [?request <- ::ResetBoardRequest]] [::current-player [] [::CurrentPlayer (= ?player player)]] [::move [:?position] [?move <- ::Move (= ?position position) (= ?player player)]] [::winner [] [?winner <- ::Winner (= ?player player)]] [::winning-square [:?position] [?winning-square <- ::WinningSquare (= ?position position)]] [::game-over [] [?game-over <- ::GameOver]])

As with rules, a query is defined as a vector whose first element is a unique keyword name. The second element is the parameter list, which can be empty, and the remaining elements are vectors defining binding clauses and tests. Calling the provisdom.maali.rules/query function results in a sequence of maps where the map keys correspond to the bound query variables. So for the ::current-player query, the maps would contain the key :?player. For the ::move query, we specify the ?position variable as a parameter, so the resulting maps would only contain keys for :?move and :?player.

Queries provide the mechanism for the outside world to interrogate session facts. R³ applications typically have a set of queries for requests, along with another set which provide useful data to effects, such as rendering the UI.

Testing Business Logic

The rules we have defined should specify the entirety of the logic around our business processes, including how we interact with the outside world. We should then be able to completely test that logic, independent of any effect implementations. Let's see an example starting with the code to initialize the rules session.

(defonce session-atom (atom nil)) (defn response-fn [spec response] (swap! session-atom common/handle-response [spec response])) (defn init-session [session] (reset! common/request-id 0) (-> session (rules/insert ::simple/CurrentPlayer {::simple/player :x}) (rules/insert ::common/ResponseFunction {::common/response-fn response-fn}) (rules/fire-rules))) (defonce hackorama (reset! session-atom (init-session simple/session)))

Most of this should be obvious, the key part being the init-session function, which sets the initial facts for the session, in particular injecting response-fn so that responses will update our session-atom. Given the intialized session, we are free to play around with it in any way we desire. We can directly interact with it through a REPL, or run unit tests. In this case, we are going to do simulation testing.

(defn check-invariants [session] (let [moves (map :?move (rules/query-partial session ::simple/move)) move-requests (map :?request (rules/query-partial session ::simple/move-request)) game-over (common/query-one :?game-over session ::simple/game-over)] (let [counts (into {} (map (juxt key (comp count val)) (group-by ::simple/player moves))) xs (or (:x counts) 0) os (or (:o counts) 0)] ; Make sure we don't have any extra moves. :x goes first so should be ; either one ahead or equal to :o. (when (or (< 1 (- xs os)) (> 0 (- xs os))) (throw (ex-info "Invariant violation: extra moves" {:counts counts})))) ; If all the squares are full, the game should be over. (when (= 9 (count moves)) (when (not game-over) (throw (ex-info "Invariant violation: game should over" {})))) (let [mr-pos (set (map ::simple/position move-requests)) m-pos (set (map ::simple/position moves))] ; Can't request a move for a square that's already been used. (when (not-empty (set/intersection mr-pos m-pos)) (throw (ex-info "Invariant violation: moves and move requests should not have overlapping positions" {})))))) (defn abuse-simple [session-atom iterations] (add-watch session-atom :check-invariants (fn [_ _ _ session] (check-invariants session))) (loop [i 0] (if (< i iterations) (do (if (rules/query-one :?game-over @session-atom ::simple/game-over) ; If the game is over, just reset (let [req (rules/query-one :?request @session-atom ::simple/reset-request)] (if req (common/respond-to req) (throw (ex-info "Should have reset request for game over" {})))) ; if the game is not over, then play (if (> 0.01 (rand)) ; Once in awhile, be a jerk and reset the game. (when-let [req (rules/query-one :?request @session-atom ::simple/reset-request)] (common/respond-to req)) (let [reqs (rules/query-partial @session-atom ::simple/move-request)] (if (not-empty reqs) ; If legal moves exist, choose one at random (let [{::simple/keys [position player] :as req} (:?request (rand-nth reqs))] (common/respond-to req #::simple{:position position :player player})))))) (recur (inc i))) (remove-watch session-atom :check-invariants))))

The check-invariants function encodes our invariants, statements (albeit conditional) we always expect to be true. We grab some data from the session via queries. The query-partial function allows us to partially (or not) specify the parameters to a query, and receiving bindings for the variables we otherwise would have passed in. So (rules/query-partial session ::simple/move) returns a sequence of maps containing the keys :?move, :?position, and :?player, basically all existing moves. The query-one function is a shortcut which selects the first result from the query, and applies the function in the first argument (in this case the :?game-over keyword) to that result (there is a corresponding query-many which maps the function over the sequence of results). check-invariants subsequently applies some tests to this data, and throws exceptions if the invariants are violated.

The abuse-simple function performs the actual simulation. A very simple approach would be to get all current requests from the session and just pick one, but this isn't very realistic, for example resulting in frequent resets so that it would be rare that a simulated game proceeded to completion. At the beginning of the function we place a watch on session-atom to call check-invariant whenever the session changes. Note the use of common/respond-to throughout. Executing 10000 iterations in abuse-simple takes a little under 20 seconds in Chrome.

It bears repeating that what we've been able to do here is test the entirety of the essential logic defining the business processes in our UI, without ever implementing any UI or other effects. If there's anything missing it's dealing with asynchrony, and we'll add that in later. The sort of simulation test we implemented above is possible due to the dynamic and self-describing nature of R³, quite literally the fact that requests and responses are reified as part of the state, making them available to be queried. The "API" in some sense is a function of state. This sort of test would be difficult with a static API, because the various functions/commands/actions/whatever are only conditionally valid. Suppose we had such a static API here. It might consist of two functions or actions, one to register a move, and one to reset the board. But we can't just randomly choose to call one of these with random arguments, because it could lead to errors that aren't really "bugs" in the logic per se, just the effect of poorly-written test code. To effectively test the static API, we would need to essentially re-implement a significant part of the business logic in our test code, just to figure out which actions are valid given the current state. And testing invalid paths through state space is a waste of time anyway, if for no other reason that wrong paths are almost always vastly more numerous than correct ones, greatly reducing the chance that you test anything of interest. R³ handles this automatically.

results matching ""

    No results matching ""