├── .gitignore ├── devcards ├── figwheel-main.edn ├── resources │ └── public │ │ ├── css │ │ └── style.css │ │ ├── imgs │ │ ├── background.png │ │ ├── flappy-base.png │ │ ├── pillar-bkg.png │ │ ├── lower-pillar-head.png │ │ ├── scrolling-border.png │ │ └── upper-pillar-head.png │ │ └── index.html ├── src │ └── minikusari │ │ ├── macro.clj │ │ ├── intro.cljs │ │ ├── tutorial2.cljs │ │ ├── tutorial4.cljs │ │ ├── tutorial1.cljs │ │ └── tutorial3.cljs ├── dev.cljs.edn ├── .gitignore ├── deps.edn └── README.md ├── deps.edn ├── src └── minikusari │ └── core.cljc ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml -------------------------------------------------------------------------------- /devcards/figwheel-main.edn: -------------------------------------------------------------------------------- 1 | {} 2 | 3 | -------------------------------------------------------------------------------- /devcards/resources/public/css/style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps {datascript {:mvn/version "1.0.5"}}} -------------------------------------------------------------------------------- /devcards/src/minikusari/macro.clj: -------------------------------------------------------------------------------- 1 | (ns minikusari.macro) 2 | 3 | (defmacro file [path] 4 | (slurp path)) -------------------------------------------------------------------------------- /devcards/dev.cljs.edn: -------------------------------------------------------------------------------- 1 | ^{:watch-dirs ["src"] 2 | :css-dirs ["resources/public/css"]} 3 | {:main minikusari.intro 4 | :devcards true} -------------------------------------------------------------------------------- /devcards/resources/public/imgs/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankiesardo/minikusari/HEAD/devcards/resources/public/imgs/background.png -------------------------------------------------------------------------------- /devcards/resources/public/imgs/flappy-base.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankiesardo/minikusari/HEAD/devcards/resources/public/imgs/flappy-base.png -------------------------------------------------------------------------------- /devcards/resources/public/imgs/pillar-bkg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankiesardo/minikusari/HEAD/devcards/resources/public/imgs/pillar-bkg.png -------------------------------------------------------------------------------- /devcards/resources/public/imgs/lower-pillar-head.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankiesardo/minikusari/HEAD/devcards/resources/public/imgs/lower-pillar-head.png -------------------------------------------------------------------------------- /devcards/resources/public/imgs/scrolling-border.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankiesardo/minikusari/HEAD/devcards/resources/public/imgs/scrolling-border.png -------------------------------------------------------------------------------- /devcards/resources/public/imgs/upper-pillar-head.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frankiesardo/minikusari/HEAD/devcards/resources/public/imgs/upper-pillar-head.png -------------------------------------------------------------------------------- /devcards/.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml 2 | *jar 3 | /lib/ 4 | /classes/ 5 | /out/ 6 | /target/ 7 | .lein-deps-sum 8 | .lein-repl-history 9 | .lein-plugins/ 10 | .repl 11 | .nrepl-port 12 | .cpcache/ 13 | .rebel_readline_history 14 | -------------------------------------------------------------------------------- /devcards/src/minikusari/intro.cljs: -------------------------------------------------------------------------------- 1 | (ns ^:figwheel-hooks minikusari.intro 2 | (:require 3 | [devcards.core :as dc] 4 | [minikusari.tutorial1] 5 | [minikusari.tutorial2] 6 | [minikusari.tutorial3] 7 | [minikusari.tutorial4]) 8 | (:require-macros 9 | [minikusari.macro :refer [file]] 10 | [devcards.core :refer [defcard-doc]])) 11 | 12 | (defcard-doc (file "../README.md")) 13 | 14 | (dc/start-devcard-ui!) -------------------------------------------------------------------------------- /devcards/resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | minikusari 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/minikusari/core.cljc: -------------------------------------------------------------------------------- 1 | (ns minikusari.core 2 | (:require [datascript.core :as d] 3 | [clojure.walk :as walk])) 4 | 5 | (defn r 6 | "Tries to match rule against db 7 | Returns tx-data or empty list if rule does not match" 8 | [{:keys [when then args]} db] 9 | (let [syms (for [row then el row :when (symbol? el)] el) 10 | results (apply d/q {:find syms :in (cons '$ (keys args)) :where when} db (vals args))] 11 | (for [match results tx then] 12 | (let [swaps (zipmap syms match) 13 | f (fn [x] (if (coll? x) x (get swaps x x)))] 14 | (walk/postwalk f tx))))) -------------------------------------------------------------------------------- /devcards/deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {org.clojure/clojure {:mvn/version "1.10.0"} 2 | org.clojure/clojurescript {:mvn/version "1.10.773"} 3 | devcards/devcards {:mvn/version "0.2.7"} 4 | frankiesardo/minikusari {:local/root "../"}} 5 | :paths ["src" "resources"] 6 | :aliases {:fig {:extra-deps 7 | {com.bhauman/rebel-readline-cljs {:mvn/version "0.1.4"} 8 | com.bhauman/figwheel-main {:mvn/version "0.2.11"}} 9 | :extra-paths ["target" "test"]} 10 | :build {:main-opts ["-m" "figwheel.main" "-b" "dev" "-r"]} 11 | :min {:main-opts ["-m" "figwheel.main" "-O" "advanced" "-bo" "dev"]}}} 12 | -------------------------------------------------------------------------------- /devcards/README.md: -------------------------------------------------------------------------------- 1 | To get an interactive development environment change into the project root (the directory just created) and execute: 2 | 3 | clojure -A:fig:build 4 | After the compilation process is complete, and a browser has popped open the compiled project in your browser, you will get a ClojureScript REPL prompt that is connected to the browser. 5 | 6 | An easy way to verify this is: 7 | 8 | cljs.user=> (js/alert "Am I connected?") 9 | and you should see an alert in the browser window. 10 | 11 | You can also supply arguments to figwheel.main like so: 12 | 13 | clojure -A:fig -b dev -r 14 | To clean all compiled files: 15 | 16 | rm -rf target/public 17 | To create a production build: 18 | 19 | rm -rf target/public 20 | clojure -A:fig:min -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # minikusari 2 | 3 | > And if, you don't love me now 4 | > 5 | > You will never love me again 6 | > 7 | > I can still hear you saying 8 | > 9 | > You would never break the chain (Never break the chain) 10 | 11 | [minikusari](https://github.com/frankiesardo/minikusari) is a minimal rules engine built on top of Datascript (and can work with Datomic or Datahike). 12 | 13 | `kusari` is Japanese for `chain`. The name `minikusari` is a tribute to `minikanren` (the embedded language for logic programming) 14 | 15 | You can use minikusari to simulate a [Forward Chaining](https://en.wikipedia.org/wiki/Forward_chaining) system that generates new facts based on a set of rules. 16 | 17 | Think of minikusari as the dual of the datalog query engine: 18 | 19 | - `datascript.core/q` finds all facts (datoms) that match a particular condition (backward chaining) 20 | - `minikusari.core/r` generates new facts (datoms) if a particular condition is met (forward chaining) 21 | 22 | ## What does it look like 23 | 24 | ```clojure 25 | (def people-db 26 | (d/db-with (d/empty-db) [#:person{:first-name "John" :last-name "Doe" :age 45} 27 | #:person{:first-name "Jane" :last-name "Doe" :age 44} 28 | #:person{:first-name "Sarah" :last-name "Doe" :age 19} 29 | #:person{:first-name "Bobby" :last-name "Doe" :age 14}])) 30 | 31 | (def ballot-rule 32 | '{:when [[?e :person/first-name ?first] 33 | [?e :person/last-name ?last] 34 | [?e :person/age ?age] 35 | [(>= ?age 18)] 36 | [(str ?first " " ?last) ?full]] 37 | :then [[:db/add ?e :person/ballot-sent? true] 38 | [:db/add ?full :ballot/full-name ?full]]}) 39 | 40 | (= 41 | (minikusari.core/r ballot-rule people-db) 42 | [[:db/add 1 :person/ballot-sent? true] 43 | [:db/add "John Doe" :ballot/full-name "John Doe"] 44 | [:db/add 2 :person/ballot-sent? true] 45 | [:db/add "Jane Doe" :ballot/full-name "Jane Doe"] 46 | [:db/add 3 :person/ballot-sent? true] 47 | [:db/add "Sarah Doe" :ballot/full-name "Sarah Doe"]]) 48 | ``` 49 | 50 | ## Comparison with other Clojure rules engines 51 | 52 | While other rules engines have defined their own semantics and language, minikusari is intentionally kept small and without any additional cognitive overhead. 53 | If you know Datascript, you already know how to use minikusari. Go to the [interactive documentation](https://frankiesardo.github.io/minikusari/#!/minikusari.tutorial1) and see for yourself. 54 | 55 | minikusari is therefore well suited as a learning tool to tinker with rule engines and see them at work. 56 | You can build fully working systems with it, such as an [inference engine](https://frankiesardo.github.io/minikusari/#!/minikusari.tutorial2) or a [game](https://frankiesardo.github.io/minikusari/#!/minikusari.tutorial3), but they're obviously not optimised for performance. 57 | 58 | More mature and "production ready" solutions already exist for Clojure: 59 | - [clara](https://github.com/cerner/clara-rules) led the way on forward chaining in Clojure. Its syntax might be a bit tricky to get at first, but it's a battle tested library 60 | - [mimir](https://github.com/hraberg/mimir) is a fantastic implementation of the Rete Algorithm in clojure 61 | - [zeder](https://www.youtube.com/watch?v=1E2CoObAaPQ) was the first to play with the idea of rules engines built on top of the Datomic model, but it was never released as an OS library 62 | - [odoyle](https://github.com/oakes/odoyle-rules) is the most recent library, and it's used for both inference and as a game engine. It's the main inspiration for minikusari, and the reason I wanted to build a similar experience on top of Datascript 63 | -------------------------------------------------------------------------------- /devcards/src/minikusari/tutorial2.cljs: -------------------------------------------------------------------------------- 1 | (ns minikusari.tutorial2 2 | (:require 3 | [minikusari.core :refer [r]] 4 | [devcards.core] 5 | [datascript.core :as d]) 6 | (:require-macros 7 | [devcards.core :as dc :refer [defcard-doc deftest]] 8 | [cljs.test :refer [testing is]])) 9 | 10 | (defcard-doc 11 | "# Forward Chaining Inference for the modern detective" 12 | 13 | "> Key concepts: `infer` loop" 14 | 15 | "We know the basics of minikusari rules, but they don't seem too useful for now, don't they." 16 | 17 | "I mean, we can basically transact a bit of extra data whenever some conditions are met, but what can we do with it?" 18 | 19 | "Well, everything becomes more interesting when it's recursive: what if the transaction data returned by the rules is added to the db, and then rules are run again against the new db?" 20 | 21 | "We can chain facts that can be derived from very simple assumptions, just like Sherlock Holmes does." 22 | 23 | "(We can also decouple complex systems where different actions trigger changes to related data sources, like a payment and shipping system causing changes on your order system, but let's just stick to Sherlock for now)") 24 | 25 | (def crime-db 26 | (d/db-with 27 | (d/empty-db {:country/code {:db/unique :db.unique/identity} 28 | :person/name {:db/unique :db.unique/identity} 29 | :person/citizens-of {:db/cardinality :db.cardinality/many 30 | :db/valueType :db.type/ref} 31 | :country/enemy-of {:db/cardinality :db.cardinality/many 32 | :db/valueType :db.type/ref} 33 | :country/owns {:db/cardinality :db.cardinality/many 34 | :db/valueType :db.type/ref} 35 | :object/sold-by {:db/valueType :db.type/ref}}) 36 | 37 | [{:db/id "USA" :country/code :usa} 38 | {:db/id "Robert" :person/criminal? false :person/name "Robert" :person/citizens-of [{:db/id "USA"}]} 39 | {:db/id "Missiles" :object/type :missiles :object/sold-by {:db/id "Robert"}} 40 | {:db/id "Sokovia" :country/code :sokovia :country/enemy-of [{:db/id "USA"}] :country/owns [{:db/id "Missiles"}]}])) 41 | 42 | (def rules 43 | '[;; American law 44 | {:when [[?p :person/american? true] 45 | [?c :country/hostile? true] 46 | [?o :object/weapon? true] 47 | [?i :invoice/seller ?p] 48 | [?i :invoice/buyer ?c] 49 | [?i :invoice/object ?o]] 50 | :then [[:db/add ?p :person/criminal? true]]} 51 | ;; A USA citizen is American 52 | {:when [[?p :person/citizens-of ?c] 53 | [?c :country/code :usa]] 54 | :then [[:db/add ?p :person/american? true]]} 55 | ;; An enemy is hostile 56 | {:when [[?c1 :country/enemy-of ?c2] 57 | [?c2 :country/code :usa]] 58 | :then [[:db/add ?c1 :country/hostile? true]]} 59 | ;; A missile is a weapon 60 | {:when [[?o :object/type :missiles]] 61 | :then [[:db/add ?o :object/weapon? true]]} 62 | ;; If someone sold it and someone bought it, there should be an invoice somewhere, right? 63 | {:when [[?c :country/owns ?o] 64 | [?o :object/sold-by ?p] 65 | (not [?i :invoice/object ?o])] 66 | :then [[:db/add "invoice" :invoice/seller ?p] 67 | [:db/add "invoice" :invoice/buyer ?c] 68 | [:db/add "invoice" :invoice/object ?o]]}]) 69 | 70 | (defn infer 71 | "Takes a db and a list of rules. Returns a new db with all inferred facts. 72 | Keeps adding data derived from the rules to the db until db doesn't change. 73 | Stops after max 100 iterations." 74 | [db rules] 75 | (loop [db db max-iter 100] 76 | (let [tx-data (for [rule rules tx (r rule db)] tx) 77 | new-db (d/db-with db tx-data)] 78 | (cond 79 | (= db new-db) new-db 80 | (= 1 max-iter) new-db 81 | :else (recur new-db (dec max-iter)))))) 82 | 83 | (defcard-doc 84 | "## Robert's in trouble" 85 | 86 | "Consider this problem statement: 87 | 88 | > As per the law, it is a crime for an American to sell weapons to hostile nations. Sokovia, an enemy of America, has some missiles, and all the missiles were sold to it by Robert, who is an American citizen. 89 | > 90 | > Prove that Robert is criminal" 91 | 92 | "We collect the evidence at our disposal and place it in our crime-db" 93 | 94 | (dc/mkdn-pprint-source crime-db) 95 | 96 | "We have to add a little bit of schema to this db so we can follow links between countries, objects and people 97 | 98 | If we create a rule that matches the definition given by the law, it would look something like" 99 | 100 | (dc/mkdn-pprint-code (first rules)) 101 | 102 | "We have a problem now: our rule does not match the shape of our data. We have to add additional rule that derive more facts until this rule is applicable. 103 | 104 | Let's code a basic `infer` loop" 105 | 106 | (dc/mkdn-pprint-source infer) 107 | 108 | "Now we can keep generating data until we get to the correct shape that matches our initial rule. 109 | 110 | Let's add a few more rules, for example: that a missile is a weapon, an enemy is hostile, etc." 111 | 112 | (dc/mkdn-pprint-source rules) 113 | 114 | "NB: in the last rule there is a `not` exists condition to avoid generating an infinite amount of invoices 115 | 116 | Let's see our infer loop in action") 117 | 118 | (deftest robert-trial-test 119 | (testing "We assume Robert is innocent until proven otherwise" 120 | (is (= {:person/criminal? false} (-> crime-db (d/pull [:person/criminal?] [:person/name "Robert"]))))) 121 | (testing "We can prove Robert is a criminal following a forward chaining inference" 122 | (is (= {:person/criminal? true} (-> crime-db (infer rules) (d/pull [:person/criminal?] [:person/name "Robert"])))))) 123 | 124 | (defcard-doc "Too bad Robert, computer says you're a criminal") 125 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /devcards/src/minikusari/tutorial4.cljs: -------------------------------------------------------------------------------- 1 | (ns minikusari.tutorial4 2 | (:require 3 | [minikusari.core :refer [r]] 4 | [devcards.core] 5 | [datascript.core :as d] 6 | [sablono.core :as sab]) 7 | (:require-macros 8 | [devcards.core :as dc :refer [defcard-doc deftest defcard]] 9 | [cljs.test :refer [testing is]])) 10 | 11 | (defn socially-distanced? [next-x positions] 12 | (let [next-y (count positions) 13 | not-ok (fn [[x y]] (or (= next-x x) 14 | (= (js/Math.abs (- next-x x)) 15 | (js/Math.abs (- next-y y)))))] 16 | (empty? (filter not-ok positions)))) 17 | 18 | 19 | (defcard-doc 20 | "# Socially distanced queens" 21 | 22 | "> Key concepts: backtracking solutions using `retract`" 23 | 24 | "Wait a minute, I thought rule systems were about logic, and logic was about exploring different options and discarding invalid solutions." 25 | 26 | "`minikusari` seems good at deriving state, but can it actually narrow down a problem with branching logic to a unique solution?" 27 | 28 | "Let's test it with a classic problem: placing eight queens on a chessboard in a socially distanced manner, so no two queens are on the same row, column or diagonal" 29 | 30 | "Queens have position x and y and we will add them one at a time, so we just need to find the next valid `x` for our queen." 31 | 32 | (dc/mkdn-pprint-source socially-distanced?)) 33 | 34 | (deftest social-distance-test 35 | (testing "Cannot add a queen on the same column" 36 | (is (false? (socially-distanced? 0 [[0 0]])))) 37 | (testing "Cannot add a queen on the diagonal" 38 | (is (false? (socially-distanced? 1 [[0 0]]))) 39 | (is (false? (socially-distanced? 6 [[0 1] [1 3] [2 7]])))) 40 | (testing "Otherwise it's fine" 41 | (is (true? (socially-distanced? 2 [[0 0]]))))) 42 | 43 | (def queens-db 44 | (d/db-with 45 | (d/empty-db 46 | {:queen/tried {:db/cardinality :db.cardinality/many}}) 47 | (concat (for [y (range 8)] {:queen/y y}) 48 | [{:queen/current 1}]))) 49 | 50 | (defn valid-x [db] 51 | (let [positions (d/q '{:find [?x ?y] :where [[?e :queen/y ?y] [?e :queen/x ?x]]} db)] 52 | (shuffle (for [next-x (range 8) :when (socially-distanced? next-x positions)] next-x)))) 53 | 54 | (def rules 55 | [{:when '[[?e :queen/current ?queen] 56 | [(valid-x $) [?x ...]] 57 | (not [?queen :queen/tried ?x]) 58 | [(inc ?queen) ?next]] 59 | :then '[[:db/add ?queen :queen/x ?x] 60 | [:db/add ?e :queen/current ?next]] 61 | :args {'valid-x valid-x}} 62 | {:when '[[?e :queen/current ?queen] 63 | (not [(valid-x $) [?x ...]] 64 | (not [?queen :queen/tried ?x])) 65 | [(dec ?queen) ?prev] 66 | [?prev :queen/x ?wrong-x]] 67 | :then '[[:db.fn/retractAttribute ?prev :queen/x] 68 | [:db/add ?prev :queen/tried ?wrong-x] 69 | [:db/add ?e :queen/current ?prev] 70 | [:db.fn/retractAttribute ?queen :queen/tried]] 71 | :args {'valid-x valid-x}}]) 72 | 73 | (defcard-doc 74 | "We want to keep a list of `x` positions that we've tried for a queen, in case we need to backtrack and try a new one." 75 | 76 | "We need to add that to our Datascript schema" 77 | 78 | (dc/mkdn-pprint-source queens-db) 79 | 80 | "And what about our rules?" 81 | 82 | "We need a rule that says: 83 | - Give me the next queen we want to position 84 | - Give me a valid position for that queen 85 | - If we haven't tried that before, let's add the queen to the board" 86 | 87 | "Something like this" 88 | 89 | (dc/mkdn-pprint-code (-> rules first (dissoc :args))) 90 | 91 | "Where `valid-x` gets the relevant information from the db" 92 | 93 | (dc/mkdn-pprint-source valid-x) 94 | 95 | "Notice that `valid-x` returns more than one result (and we shuffle them to randomize the solutions) so every time this rule runs we're transacting multiple `[:db/add ?e :queen/x ?x]`" 96 | 97 | "This is fine since the cardinality is `one` by default, so the last one will win, but if we were using an attribute with `:db.cardinality/many`, like our `queen/tried` that would be a problem, just so you know." 98 | 99 | "Ok then, how about backtracking incorrect solutions?" 100 | 101 | "Let's say we placed 5 queens and the 6th one has nowhere to go" 102 | 103 | "Well, if we cannot place the current queen, we want to remove the `x` position of the previous one and save the fact that that is not a position to try again" 104 | 105 | (dc/mkdn-pprint-code (-> rules second (dissoc :args))) 106 | 107 | "A little detail that is easy to miss is the last assertion in the second rule `[:db.fn/retractAttribute ?e :queen/tried]`. Why do we need that?" 108 | 109 | "When you backtrack the previous queen position you want to clear the list of positions you tried with the current one, because they are valid positions to try again" 110 | 111 | "That's all we need, literally, two rules (and a little help from Datascript)." 112 | 113 | (dc/mkdn-pprint-source rules) 114 | 115 | "You can check the interactive version below and observe the engine trying to place a queen at a time") 116 | 117 | (defcard socially-distanced-queens 118 | (fn [data-atom] 119 | (let [db @data-atom 120 | queens (d/q '{:find [?x ?y] :where [[?e :queen/y ?y] [?e :queen/x ?x]]} db) 121 | next-step (fn [] (swap! data-atom #(d/db-with % (for [rule rules tx (r rule %)] tx)))) 122 | clear (fn [] (reset! data-atom queens-db))] 123 | (sab/html 124 | [:div {:style {:margin "0px auto"}} 125 | [:table {:style {:border "5px solid #333"}} 126 | [:tbody 127 | (for [y (range 8)] 128 | [:tr (for [x (range 8)] 129 | [:td {:style {:width 48 :height 48 130 | :fontSize 26 :textAlign :center 131 | :backgroundColor (if (even? (+ x y)) "white" "#999")}} 132 | (if (queens [x y]) "\uD83D\uDC51" " ")])])]] 133 | [:button {:style {:margin 8} :onClick clear} "Clear"] 134 | (when (< (count queens) 8) [:button {:onClick next-step} "Next step"])]))) 135 | queens-db) -------------------------------------------------------------------------------- /devcards/src/minikusari/tutorial1.cljs: -------------------------------------------------------------------------------- 1 | (ns minikusari.tutorial1 2 | (:require 3 | [minikusari.core :refer [r]] 4 | [devcards.core] 5 | [datascript.core :as d]) 6 | (:require-macros 7 | [devcards.core :as dc :refer [defcard-doc deftest]] 8 | [cljs.test :refer [testing is]])) 9 | 10 | (def people-db 11 | (d/db-with (d/empty-db) [#:person{:first-name "John" :last-name "Doe" :age 45} 12 | #:person{:first-name "Jane" :last-name "Doe" :age 44} 13 | #:person{:first-name "Sarah" :last-name "Doe" :age 19} 14 | #:person{:first-name "Bobby" :last-name "Doe" :age 14}])) 15 | 16 | (def ballot-query 17 | '{:find [?full] 18 | :where [[?e :person/first-name ?first] 19 | [?e :person/last-name ?last] 20 | [?e :person/age ?age] 21 | [(>= ?age 18)] 22 | [(str ?first " " ?last) ?full]]}) 23 | 24 | (def ballot-rule 25 | '{:when [[?e :person/first-name ?first] 26 | [?e :person/last-name ?last] 27 | [?e :person/age ?age] 28 | [(>= ?age 18)] 29 | [(str ?first " " ?last) ?full]] 30 | :then [[:db/add ?e :person/ballot-sent? true] 31 | [:db/add ?full :ballot/full-name ?full]]}) 32 | 33 | (defcard-doc 34 | "# Anatomy of a query" 35 | 36 | "> Key concepts: rule `when` and `then`" 37 | 38 | "Let's start by revising what we know: how a Datascript db its query operations work." 39 | 40 | "Take for example the following db containing information about the Doe family" 41 | 42 | (dc/mkdn-pprint-source people-db) 43 | 44 | "If we want to find out the full name of the people in this family eligible for an electoral ballot, we might write a query such as this." 45 | 46 | (dc/mkdn-pprint-source ballot-query) 47 | 48 | "And hopefully it will return the expected result") 49 | 50 | (deftest datascript-test 51 | (testing "We remember how to use Datascript" 52 | (is (= (d/q ballot-query people-db) 53 | #{["John Doe"] ["Jane Doe"] ["Sarah Doe"]})))) 54 | 55 | (defcard-doc 56 | "# Anatomy of a rule" 57 | 58 | "A rule is very similar to a query, but it returns transaction data instead of query results." 59 | 60 | "Imagine, in the example above, that we want to create an electoral ballot for anyone that is eligible." 61 | 62 | "We create a rule: when [person is adult] -> then [assert ballot with person full name]" 63 | 64 | "Datascript already has a syntax to describe asserting or retracting facts in the form of" 65 | 66 | (dc/mkdn-pprint-code 67 | '[[:db/add entity attribute value] 68 | [:db/retract entity attribute value] 69 | ...]) 70 | 71 | "We can go ahead and write our rule like so" 72 | 73 | (dc/mkdn-pprint-source ballot-rule) 74 | 75 | "Notice how the `when` part is identical to our first query and how we reuse the same variable names (`?e`, `?full`) in our `then` part. 76 | 77 | This is important: our rule is pure data (keywords and symbols) so any concrete value we want to assert has to first be matched in the query. 78 | 79 | BTW if you're wondering why I am doing `[:db/add ?full]` I am just using a string tempid that is different for every ballot, so they do not clash") 80 | 81 | 82 | (defcard-doc 83 | "# Anatomy of an engine 84 | We're ready to learn the entire extent of the code contained in minikusari. It's so small it almost fits into a tweet. 85 | 86 | This is where the magic happens" 87 | 88 | (dc/mkdn-pprint-source r) 89 | 90 | "We're basically saying: 91 | 92 | - Create a `find` query clause based on all the symbols referenced in the transaction vectors 93 | - Run the query just like you would in Datascript (remember: query `where` and rule `when` are basically the same) 94 | - Swaps the query results into the transaction vector, to obtain the `then` clause" 95 | 96 | "Don't worry about what `args` is and what happens in the `in` clause for now. Let's see it in action") 97 | 98 | (deftest r-test 99 | (testing "We get transaction data when matching a rule against our db" 100 | (is (= (r ballot-rule people-db) 101 | [[:db/add 1 :person/ballot-sent? true] 102 | [:db/add "John Doe" :ballot/full-name "John Doe"] 103 | [:db/add 2 :person/ballot-sent? true] 104 | [:db/add "Jane Doe" :ballot/full-name "Jane Doe"] 105 | [:db/add 3 :person/ballot-sent? true] 106 | [:db/add "Sarah Doe" :ballot/full-name "Sarah Doe"]]))) 107 | (testing "We can transact this data into our db to create a ballot for each member of the family (except Bobby, Bobby is too young)" 108 | (is (= (->> (d/datoms (d/db-with people-db (r ballot-rule people-db)) :eavt) 109 | (map (juxt :e :a :v))) 110 | [[1 :person/age 45] 111 | [1 :person/ballot-sent? true] 112 | [1 :person/first-name "John"] 113 | [1 :person/last-name "Doe"] 114 | [2 :person/age 44] 115 | [2 :person/ballot-sent? true] 116 | [2 :person/first-name "Jane"] 117 | [2 :person/last-name "Doe"] 118 | [3 :person/age 19] 119 | [3 :person/ballot-sent? true] 120 | [3 :person/first-name "Sarah"] 121 | [3 :person/last-name "Doe"] 122 | [4 :person/age 14] 123 | [4 :person/first-name "Bobby"] 124 | [4 :person/last-name "Doe"] 125 | [5 :ballot/full-name "John Doe"] 126 | [6 :ballot/full-name "Jane Doe"] 127 | [7 :ballot/full-name "Sarah Doe"]])))) 128 | 129 | (defcard-doc 130 | "Congrats! You have learned how to use a rules engine to generate new facts based on existing ones!" 131 | 132 | "Forward chaining inference is pretty useful, even if it's very basic like this one." 133 | 134 | "Compared with backward chaining inference, where you already have all the facts and you want to find a subset that matches a certain condition (hey, like a db!), forward chaining shines in a system that is constantly accumulating new facts (hey, like a game!) to easily generate derived data." 135 | 136 | "Check the other tutorials to observe minikusari in action and discover how you can **solve crimes** or **make things fly** with it") 137 | 138 | (defcard-doc 139 | "## Current limitations and future work" 140 | 141 | "### The shape of the `then` clause") 142 | 143 | (deftest shape-of-then-clause-test 144 | (testing "The supported syntax is a vector of vectors" 145 | (is (= (r '{:when [[?e :person/age 14]] :then [[:db/add ?e :person/kid? true]]} people-db) 146 | '[[:db/add 4 :person/kid? true]]))) 147 | (testing "Maps might be supported in the future, but not for now" 148 | (is (not= (r '{:when [[?e :person/age 14]] :then [{:db/id ?e :person/kid? true}]} people-db) 149 | '[[:db/add 4 :person/kid? true]])))) 150 | 151 | (defcard-doc 152 | "### The `:args` key." 153 | 154 | "I resisted adding this workaround for cljs because the rule stops being pure data and you have to put quotes around every vector." 155 | 156 | "Possible way to solve that: use `multimethods`" 157 | 158 | (dc/mkdn-pprint-code '(defmulti f first)) 159 | 160 | (dc/mkdn-pprint-code '(defmethod f :?my-fn [_ & args])) 161 | 162 | (dc/mkdn-pprint-code '{:when [[?e :person/last-name ?last] 163 | [(f :?my-fn ?e) ?result] 164 | [(< ?result 3)]] 165 | :then [...]}) 166 | 167 | "But there's a bit too much magic for my taste. At least `args` is very explicit.") 168 | 169 | (defn at-least-one-changed 170 | [{:keys [max-tx]} t1 & more] 171 | (some #{max-tx} (cons t1 more))) 172 | 173 | (def last-name-rule 174 | {:when '[[?e :person/last-name ?last ?t1] 175 | [?e :person/first-name ?first ?t2] 176 | [(at-least-one-changed $ ?t1 ?t2)] 177 | [(str ?first " " ?last) ?full]] 178 | :then '[[:db/add ?e :person/full-name ?full]] 179 | :args {'at-least-one-changed at-least-one-changed}}) 180 | 181 | (defcard-doc 182 | "### The network of constraints" 183 | 184 | "It would be great to only trigger rules when the underlying data changes without having to specify `max-tx`." 185 | 186 | "The current approach has some limitations. You can recalculate a rule when either t1 or t2 change by comparing them to ?max-tx" 187 | 188 | (dc/mkdn-pprint-source at-least-one-changed) 189 | 190 | (dc/mkdn-pprint-source last-name-rule)) 191 | 192 | (deftest track-multiple-changes 193 | (testing "Trigger when both tx change" 194 | (is (= ["John Doe" "Jane Doe" "Sarah Doe" "Bobby Doe"] (map last (r last-name-rule people-db))))) 195 | (testing "Trigger when just one change" 196 | (is (let [db (d/db-with people-db [{:db/id 1 :person/first-name "Johnny"}])] 197 | (= ["Johnny Doe"] (map last (r last-name-rule db)))))) 198 | (testing "Do not trigger when neither changed" 199 | (is (let [db (d/db-with people-db [{:db/id 1 :person/age 46}])] 200 | (= [] (map last (r last-name-rule db))))))) 201 | 202 | (defcard-doc 203 | "While this works fine, there might be cases when one infer loop changes t1 and another changes t2 and it's tricky to track that." 204 | 205 | "I once wrote a 'percolator' for datomic for 'saved searches'. The idea is similar to the ElasticSearch percolator: I give you a query and you call me when the result of that query changes." 206 | 207 | "But another interesting approach is to look at what [posh](https://github.com/denistakeda/re-posh) is doing ") 208 | 209 | 210 | 211 | 212 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /devcards/src/minikusari/tutorial3.cljs: -------------------------------------------------------------------------------- 1 | (ns minikusari.tutorial3 2 | (:require 3 | [sablono.core :as sab :include-macros true] 4 | [minikusari.core :refer [r]] 5 | [datascript.core :as d]) 6 | (:require-macros 7 | [devcards.core :as dc :refer [defcard defcard-doc deftest]] 8 | [cljs.test :refer [testing is]])) 9 | 10 | (def horiz-vel -0.15) 11 | (def gravity 0.05) 12 | (def jump-vel 21) 13 | (def start-y 312) 14 | (def bottom-y 561) 15 | (def flappy-x 212) 16 | (def flappy-width 57) 17 | (def flappy-height 41) 18 | (def pillar-spacing 324) 19 | (def pillar-gap 158) 20 | (def pillar-width 86) 21 | 22 | (def flappy-db 23 | (d/db-with 24 | (d/empty-db) 25 | [{:game/started? false 26 | :game/score 0 27 | :game/start-time 0 28 | :game/border-pos 0} 29 | {:flappy/active? false 30 | :flappy/start-time 0 31 | :flappy/cur-y start-y} 32 | {:pillar/start-time 0 33 | :pillar/start-x 900 34 | :pillar/cur-x 900 35 | :pillar/upper-height 200}])) 36 | 37 | (defn sine-wave [time-delta] 38 | (+ start-y (* 30 (js/Math.sin (/ time-delta 300))))) 39 | 40 | (defn new-y [time-delta flappy-y] 41 | (let [cur-vel (- jump-vel (* time-delta gravity)) 42 | new-y (- flappy-y cur-vel)] 43 | (min new-y (- bottom-y flappy-height)))) 44 | 45 | (defn rand-height [] 46 | (+ 60 (rand-int (- bottom-y 120 pillar-gap)))) 47 | 48 | (defn translate [start-pos vel time] 49 | (js/Math.floor (+ start-pos (* time vel)))) 50 | 51 | (defn curr-pillar-pos [cur-time start-x start-time] 52 | (translate start-x horiz-vel (- cur-time start-time))) 53 | 54 | (defn pillars-in-world [db] 55 | (d/q '[:find (count ?e) . 56 | :in $ pillar-width 57 | :where 58 | [?e :pillar/cur-x ?cur-x] 59 | [(> ?cur-x pillar-width)]] 60 | db (- pillar-width))) 61 | 62 | (defn new-pillar-start-x [db] 63 | (+ pillar-spacing (d/q '[:find (max ?cur-x) . :where [?e :pillar/cur-x ?cur-x]] db))) 64 | 65 | (defn in-pillar? [cur-x] 66 | (and (>= (+ flappy-x flappy-width) cur-x) 67 | (< flappy-x (+ cur-x pillar-width)))) 68 | 69 | (defn in-pillar-gap? [cur-y upper-height] 70 | (and (< upper-height cur-y) 71 | (> (+ upper-height pillar-gap) 72 | (+ cur-y flappy-height)))) 73 | 74 | (defn bottom-collision? [cur-y] 75 | (>= cur-y (- bottom-y flappy-height))) 76 | 77 | (defn collision? [cur-x upper-height cur-y] 78 | (or (bottom-collision? cur-y) 79 | (and (in-pillar? cur-x) 80 | (not (in-pillar-gap? cur-y upper-height))))) 81 | 82 | (defn calc-score [cur-time start-time] 83 | (-> cur-time 84 | (- start-time) 85 | (* horiz-vel) 86 | (- 544) 87 | (/ pillar-spacing) 88 | js/Math.floor 89 | js/Math.abs 90 | (- 4) 91 | (max 0))) 92 | 93 | (defn infer 94 | "Returns a new db with all inferred facts and a list of tx-data. 95 | Stops when no more rules apply or after 100 iterations" 96 | [db rules] 97 | (loop [db db max-iter 100] 98 | (let [tx-data (for [rule rules tx (r rule db)] tx)] 99 | (cond 100 | (empty? tx-data) db 101 | (zero? max-iter) db 102 | :else (recur (d/db-with db tx-data) (dec max-iter)))))) 103 | 104 | (def rules 105 | [;; start 106 | {:when '[[_ :action/start ?now ?max-tx] 107 | [(max-tx $) ?max-tx] 108 | [?game :game/started? false] 109 | [?flappy :flappy/active?] 110 | [?pillar :pillar/start-time]] 111 | :then '[[:db/add ?game :game/started? true] 112 | [:db/add ?game :game/start-time ?now] 113 | [:db/add ?flappy :flappy/start-time ?now] 114 | [:db/add ?pillar :pillar/start-time ?now] 115 | [:db/add :db/current-tx :db/doc "Start"]] 116 | :args {'max-tx :max-tx}} 117 | ;; jump 118 | {:when '[[_ :action/jump ?now ?max-tx] 119 | [(max-tx $) ?max-tx] 120 | [?flappy :flappy/active?]] 121 | :then '[[:db/add ?flappy :flappy/active? true] 122 | [:db/add ?flappy :flappy/start-time ?now] 123 | [:db/add :db/current-tx :db/doc "Jump"]] 124 | :args {'max-tx :max-tx}} 125 | ;; time update 126 | {:when '[[_ :action/update-time ?now ?max-tx] 127 | [(max-tx $) ?max-tx] 128 | [?game :game/started?] 129 | [?flappy :flappy/start-time ?flappy-start-time] 130 | [(- ?now ?flappy-start-time) ?time-delta]] 131 | :then '[[:db/add ?game :game/current-time ?now] 132 | [:db/add ?game :game/time-delta ?time-delta] 133 | [:db/add :db/current-tx :db/doc "Time update"]] 134 | :args {'max-tx :max-tx}} 135 | ;; update flappy 136 | ;; NB: we have some branching logic here: one rule for when flappy is active, one for when it is not active 137 | {:when '[[?game :game/time-delta ?time-delta ?max-tx] 138 | [(max-tx $) ?max-tx] 139 | [?flappy :flappy/active? true] 140 | [?flappy :flappy/cur-y ?cur-y] 141 | [(new-y ?time-delta ?cur-y) ?new-y]] 142 | :then '[[:db/add ?flappy :flappy/cur-y ?new-y] 143 | [:db/add :db/current-tx :db/doc "Update active flappy"]] 144 | :args {'max-tx :max-tx 'new-y new-y}} 145 | {:when '[[?game :game/time-delta ?time-delta ?max-tx] 146 | [(max-tx $) ?max-tx] 147 | [?flappy :flappy/active? false] 148 | [(sine-wave ?time-delta) ?cur-y]] 149 | :then '[[:db/add ?flappy :flappy/cur-y ?cur-y] 150 | [:db/add :db/current-tx :db/doc "Update inactive flappy"]] 151 | :args {'max-tx :max-tx 'sine-wave sine-wave}} 152 | ; update pillars 153 | {:when '[[?game :game/current-time ?current-time ?max-tx] 154 | [(max-tx $) ?max-tx] 155 | [?pillar :pillar/start-x ?start-x] 156 | [?pillar :pillar/start-time ?start-time] 157 | [(curr-pillar-pos ?current-time ?start-x ?start-time) ?new-x]] 158 | :then '[[:db/add ?pillar :pillar/cur-x ?new-x] 159 | [:db/add :db/current-tx :db/doc "Update pillars"]] 160 | :args {'max-tx :max-tx 'curr-pillar-pos curr-pillar-pos}} 161 | ;; delete old pillars 162 | {:when '[[?pillar :pillar/cur-x ?cur-x ?max-tx] 163 | [(max-tx $) ?max-tx] 164 | [(<= ?cur-x pillar-width)]] 165 | :then '[[:db/retractEntity ?pillar] 166 | [:db/add :db/current-tx :db/doc "Delete pillar"]] 167 | :args {'max-tx :max-tx 'pillar-width (- pillar-width)}} 168 | ;; add new pillar 169 | ;; NB: only trigger this when the pillar position has just been updated 170 | {:when '[[?pillar :pillar/cur-x ?cur-x ?max-tx] 171 | [(max-tx $) ?max-tx] 172 | [?game :game/current-time ?current-time] 173 | [(pillars-in-world $) ?count] 174 | [(new-pillar-pos-x $) ?pos-x] 175 | [(< ?count 3)] 176 | [(rand-height) ?height]] 177 | :then '[[:db/add "new-pillar" :pillar/start-time ?current-time] 178 | [:db/add "new-pillar" :pillar/start-x ?pos-x] 179 | [:db/add "new-pillar" :pillar/cur-x ?pos-x] 180 | [:db/add "new-pillar" :pillar/upper-height ?height] 181 | [:db/add :db/current-tx :db/doc "Add pillar"]] 182 | :args {'max-tx :max-tx 'rand-height rand-height 'pillars-in-world pillars-in-world 'new-pillar-pos-x new-pillar-start-x}} 183 | ;; check collisions 184 | {:when '[[?pillar :pillar/cur-x ?cur-x ?max-tx] 185 | [(max-tx $) ?max-tx] 186 | [?pillar :pillar/upper-height ?height] 187 | [?game :game/started?] 188 | [?flappy :flappy/cur-y ?cur-y] 189 | [(collision? ?cur-x ?height ?cur-y)]] 190 | :then '[[:db/add ?game :game/started? false] 191 | [:db/add :db/current-tx :db/doc "Check collision"]] 192 | :args {'max-tx :max-tx 'collision? collision?}} 193 | ;; update score 194 | {:when '[[?game :game/current-time ?current-time ?max-tx] 195 | [(max-tx $) ?max-tx] 196 | [?game :game/start-time ?start-time] 197 | [?game :game/score ?old-score] 198 | [(calc-score ?current-time ?start-time) ?new-score] 199 | [(> ?new-score ?old-score)]] 200 | :then '[[:db/add ?game :game/score ?new-score] 201 | [:db/add :db/current-tx :db/doc "Update score"]] 202 | :args {'max-tx :max-tx 'calc-score calc-score}}]) 203 | 204 | (defn ui [{:game/keys [started? current-time score border-pos]} 205 | {:flappy/keys [cur-y]} pillar-list 206 | {:keys [start-game jump]}] 207 | [:div {:style {:position :relative :width 480 :height 640 :overflow :hidden 208 | :margin "0px auto" 209 | :background "rgb(112, 197, 206) url(imgs/background.png) no-repeat left bottom"} 210 | :onMouseDown (fn [e] (.preventDefault e) (when jump (jump)))} 211 | [:h1 {:style {:position :absolute 212 | :width 300 213 | :text-align :center 214 | :left 90 215 | :font-size 58 216 | :top 13 217 | :color :white 218 | :text-shadow "-4px 0 black, 0 4px black, 4px 0 black, 0 -4px black" 219 | :z-index 5 220 | :font-family :monospace}} score] 221 | (if-not started? 222 | [:a {:style {:color "rgb(244,176,36)" 223 | :background-color :white 224 | :font-size 40 225 | :font-family :monospace 226 | :z-index 5 227 | :position :absolute 228 | :border-bottom "3px solid rgb(84,56,71)" 229 | :border-right "3px solid rgb(84,56,71)" 230 | :padding "1px 10px" 231 | :top 363 232 | :left 128 233 | :text-align :center 234 | :width 200 235 | :text-shadow "-3px 0 black, 0 3px black, 3px 0 black, 0 -3px black"} 236 | :onClick (or start-game identity)} (if (pos? current-time) "RESTART" "START")] 237 | [:span]) 238 | [:div (for [{:pillar/keys [cur-x upper-height]} pillar-list 239 | :let [lower-height (- bottom-y upper-height pillar-gap)]] 240 | [:div 241 | [:div 242 | {:style {:position :absolute 243 | :top 0 244 | :right 150 245 | :background-image "url(imgs/upper-pillar-head.png), url(imgs/pillar-bkg.png)" 246 | :background-position "left bottom, left bottom" 247 | :background-repeat "no-repeat, repeat-y" 248 | :width 86 249 | :left cur-x 250 | :height upper-height}}] 251 | [:div 252 | {:style {:position :absolute 253 | :bottom 80 254 | :right 150 255 | :background-image "url(imgs/lower-pillar-head.png), url(imgs/pillar-bkg.png)" 256 | :background-position "left top, left top" 257 | :background-repeat "no-repeat, repeat-y" 258 | :width 86 259 | :left cur-x 260 | :height lower-height}}]])] 261 | [:div {:style {:position :absolute 262 | :background "transparent url(imgs/flappy-base.png) no-repeat top left" 263 | :top cur-y 264 | :left 212 265 | :width 57 266 | :height 41}}] 267 | [:div {:style {:position :absolute 268 | :height 10 269 | :width 480 270 | :top 567 271 | :background "transparent url(imgs/scrolling-border.png) repeat-x top left" 272 | :background-position-x border-pos}}]]) 273 | 274 | (defcard-doc 275 | "# Meet flappy, he needs some help to fly" 276 | 277 | "> Key concepts: rule `args`, rules that execute only when something changed, `:db/current-tx` metadata" 278 | 279 | "In this classic flappy bird game, we're going to see how our rule engine can derive a new game state based on user input") 280 | 281 | (defcard flappy-static 282 | (fn [data-atom] 283 | (let [[game flappy pillar-list] @data-atom] 284 | (sab/html (ui game flappy pillar-list {})))) 285 | [{:game/started? true 286 | :game/score 1 287 | :game/border-pos 0} 288 | {:flappy/cur-y start-y} 289 | [{:pillar/cur-x 350 290 | :pillar/upper-height 200}]] 291 | {:frame false :inspect-data true}) 292 | 293 | 294 | (defcard-doc 295 | "## Not so fast, flappy. 296 | 297 | If you recall our rules are pure data. How the heck are we supposed to calculate gravity based games and invoke complex Math functions? 298 | 299 | On the JVM it's not a big deal, because we can call functions directly in our binding vectors. 300 | 301 | The following is perfectly valid code in clj" 302 | 303 | (dc/mkdn-pprint-code '(defn my-fn [] (rand-int 5))) 304 | 305 | (dc/mkdn-pprint-code '{:when [[?e :person/age ?age] 306 | [(my.ns/my-fn) ?rand] 307 | [(< ?age ?rand)]] 308 | :then [[:db/retractEntity ?e :person/age ?age]]}) 309 | 310 | "You can even call functions that rely on the state of the database itself by passing the implicit db reference `$`" 311 | 312 | (dc/mkdn-pprint-code '(defn count-people [db] (d/q '[:find (count ?e) . :where [?e :person/name]] db))) 313 | 314 | (dc/mkdn-pprint-code '{:when [[(my.ns/count-people $) ?count] 315 | [(< ?count 5)]] 316 | :then [[:db/add "new person" :person/name "Johnny"]]}) 317 | 318 | "Unfortunately this doesn't work in cljs because we're missing the ability to `resolve` symbols that refer to functions." 319 | 320 | "We need to introduce a new concept. Remember the code that interprets our rules" 321 | 322 | (dc/mkdn-pprint-source r) 323 | 324 | "You can optionally specify an `args` key. This is a map of symbols to values or functions, and that allows you to refer to those symbols in your `when` clause, similarly to how an `in` clause works for queries." 325 | 326 | "Let's see this in action below." 327 | 328 | (dc/mkdn-pprint-source flappy-db) 329 | 330 | (dc/mkdn-pprint-source pillars-in-world)) 331 | 332 | (deftest args-test 333 | (testing "You can pass arguments to the rule execution. Note that you need to move the quote next to each vector, because args needs to be unquoted" 334 | (is (= (r {:when '[[(pillars-in-world $) ?count] 335 | [(< ?count 3)]] 336 | :args {'pillars-in-world pillars-in-world} 337 | :then '[[:db/add "new pillar" :pillar/index ?count]]} 338 | flappy-db) 339 | [[:db/add "new pillar" :pillar/index 1]])))) 340 | 341 | (defcard-doc 342 | "We will need `args` not only to calculate complex values, but also to check what has been recently updated." 343 | 344 | "In most of the examples we've seen before it was fine to re-run some rules." 345 | 346 | "But in something as complex as flying a bird we only want to trigger rules when certain things have just been updated." 347 | 348 | "Thankfully, Datascript stores the transaction number for each datom, so we could just check if the tx at the end of the eavt vector is the latest one in the db.") 349 | 350 | (deftest max-tx-test 351 | (testing "You can observe max-tx from a Datascript db by simply getting it by key" 352 | (is (= (get flappy-db :max-tx) 536870913))) 353 | (testing "You can match rules against recently changed datoms by matching it with max-tx" 354 | (is (let [rule {:when '[[(max-tx $) ?max-tx] 355 | [?e :game/started? false ?max-tx]] 356 | :args {'max-tx :max-tx} 357 | :then '[[:db/add :db/current-tx :db/doc "The game begins" :tx/trigger ?max-tx]]}] 358 | (= (r rule flappy-db) 359 | [[:db/add :db/current-tx :db/doc "The game begins" :tx/trigger 536870913]])))) 360 | (testing "If something else changed in the meantime the same rule will not trigger again" 361 | (is (let [rule {:when '[[(max-tx $) ?max-tx] 362 | [?e :game/started? false ?max-tx]] 363 | :args {'max-tx :max-tx} 364 | :then '[[:db/add :db/current-tx :db/doc "The game begins" :tx/trigger ?max-tx]]}] 365 | (= (r rule (d/db-with flappy-db [{:some :data}])) 366 | []))))) 367 | 368 | (defcard-doc 369 | "## Ready, set, fly" 370 | 371 | "The eagle eyed of you might have spotted an interesting thing in the previous example: we're using `:db/current-tx` to attach metadata on the current transaction, so it's easier to see which rule was executed. This is nice, we're going to use it." 372 | 373 | "We pretty much have all the ingredients now. Let's cook something up." 374 | 375 | "There are only two user inputs the game takes: `start` and `jump` and one automatic new fact: `time-update`" 376 | 377 | "All the other rules are to help us derive some more facts when we change the model: for example, when we update the game clock, some rules will take care of updating the gravity of the bird and others will move the pillars forward." 378 | 379 | (dc/mkdn-pprint-source rules) 380 | 381 | "We need some tweak to our infer loop so we check for empty tx-data as a stopping condition" 382 | 383 | (dc/mkdn-pprint-source infer) 384 | 385 | "Have a go with the version below. Time is not updated automatically, you have to click to increase it." 386 | 387 | "You can use the arrows at the bottom of the card to rewind and replay state history") 388 | 389 | (defcard flappy-manual 390 | (fn [data-atom] 391 | (let [db @data-atom 392 | game (d/q '[:find (pull ?e [*]) . :where [?e :game/started?]] db) 393 | flappy (d/q '[:find (pull ?e [*]) . :where [?e :flappy/active?]] db) 394 | pillar-list (d/q '[:find [(pull ?e [*]) ...] :where [?e :pillar/cur-x]] db) 395 | {:game/keys [current-time started?]} game 396 | perform-action! (fn [action] (swap! data-atom #(-> % (d/db-with [action]) (infer rules))))] 397 | (sab/html 398 | [:div 399 | (ui game flappy pillar-list {}) 400 | (if started? 401 | [:div 402 | [:button {:onClick #(reset! data-atom (-> flappy-db (d/db-with [{:action/start 0}]) (infer rules)))} "Restart!"] 403 | [:button {:onClick #(perform-action! {:action/update-time (+ current-time 120)})} "Increase time"] 404 | [:button {:onClick #(perform-action! {:action/jump (+ current-time 60)})} "Jump"]] 405 | [:button {:onClick #(reset! data-atom (-> flappy-db (d/db-with [{:action/start 0}]) (infer rules)))} "Start!"])]))) 406 | flappy-db 407 | {:frame false :history true}) 408 | 409 | (defcard-doc 410 | "## Putting it all together" 411 | 412 | "I hope that was interesting, seeing how a list of rules together can describe the state changes a system goes through" 413 | 414 | "What I find fascinating is that when you add a new rule (say: `update-score`), you don't need to go and find the right place to add a function to transform the state" 415 | 416 | "Every rule lives in a global space and it only needs to specify what bits of state it wants to react to (say: `:game/current-time`) to get called when it's needed" 417 | 418 | "But yes, I know what you are about to say: where's the real flappy already? Don't worry, you can play the full version of the game below") 419 | 420 | (defonce conn (d/conn-from-db flappy-db)) 421 | 422 | (defn time-loop [] 423 | (let [db @conn 424 | {:game/keys [started?]} (d/q '[:find (pull ?e [*]) . :where [?e :game/started?]] db)] 425 | (when started? 426 | (swap! conn #(-> % (d/db-with [{:action/update-time (js/Date.now)}]) (infer rules)))) 427 | (js/setTimeout time-loop 30))) 428 | 429 | (defonce start-timer (time-loop)) 430 | 431 | (defcard flappy-full 432 | (fn [data-atom] 433 | (let [db @data-atom 434 | game (d/q '[:find (pull ?e [*]) . :where [?e :game/started?]] db) 435 | flappy (d/q '[:find (pull ?e [*]) . :where [?e :flappy/active?]] db) 436 | pillar-list (d/q '[:find [(pull ?e [*]) ...] :where [?e :pillar/cur-x]] db) 437 | perform-action! (fn [action] (swap! data-atom #(-> % (d/db-with [action]) (infer rules))))] 438 | (sab/html 439 | [:div 440 | (ui game flappy pillar-list {:start-game #(reset! data-atom (-> flappy-db (d/db-with [{:action/start (js/Date.now)}]) (infer rules))) 441 | :jump #(perform-action! {:action/jump (js/Date.now)})})]))) 442 | conn 443 | {:frame false}) 444 | --------------------------------------------------------------------------------