diff --git a/README.md b/README.md index cb827aa..5f77e43 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Namespaces are split between Clojure and ClojureScript: - Clojure: `protocol55.step.alpha`, `protocol55.step.alpha.specs` - ClojureScript: `protocol55.step-cljs.alpha`, `protocol55.step-cljs.alpha.specs` +Only one namespace is provided for `protocol55.step.state.alpha`. + ## Walkthrough A step is defined as a tuple of: @@ -37,17 +39,17 @@ We use `protocol55.step.alpha/stepdef` to define a step spec. We'll begin by defining the state and action specs for our system: ```clojure +(require '[protocol55.step.state.alpha :as state]) + (s/def ::drink (s/tuple #{:drink})) (s/def ::fill (s/tuple #{:fill})) -(s/def :in-between/v (s/int-in 1 10)) -(s/def :in-between/state (s/keys :req-un [:in-between/v])) +(s/def ::v (state/cases :empty zero? :in-between (s/int-in 1 10) :full #{10})) +(s/def ::state (s/keys :req [::v])) -(s/def :empty/v #{0}) -(s/def :empty/state (s/keys :req-un [:empty/v])) - -(s/def :full/v #{10}) -(s/def :full/state (s/keys :req-un [:full/v])) +(s/def ::in-between-state (state/produce ::state :of {::v :in-between})) +(s/def ::empty-state (state/produce ::state :of {::v :empty})) +(s/def ::full-state (state/produce ::state :of {::v :full})) ``` In the example above the system defines a state modeling a cup of water with a @@ -57,21 +59,21 @@ We then have two actions - `::drink` and `::fill`, which are [Re-frame](https://github.com/Day8/re-frame/) style tuples containing a keyword as the intent of the action. -Next we define the state variation specs. It's important to note that we are -using the same names with different qualifiers here. Because of limitations of -the `keys` spec this will only work with `:opt-un` and `:req-un`. +Next we define the state variation specs. We use the helpers found in +`protocol55.step.state.alpha` to define cases for our specs and then produce +state specs which references those cases. With those in place we'll define our step spec: ```clojure (stepdef ::step - (:in-between/state - ::drink (:in-between/state :empty/state) - ::fill (:in-between/state :full/state)) - (:empty/state - ::fill (:in-between/state)) - (:full/state - ::drink (:in-between/state))) + (::in-between-state + ::drink (::in-between-state ::empty-state) + ::fill (::in-between-state ::full-state)) + (::empty-state + ::fill (::in-between-state)) + (::full-state + ::drink (::in-between-state))) ``` Each form within `stepdef` is defined as: @@ -87,34 +89,21 @@ The ability to define multiple next-states (state') for an action lets us model more complex systems than a normal state machine `state -> action -> state'` tuple would. -Conformance for steps looks like the following: - -```clojure -(s/conform ::step [{:v 1} [:drink] {:v 0}]) -;; => [:in-between [:drink [:empty [{:v 1} [:drink] {:v 0}]]]] -``` - -where: - -```clojure -[state-qualifier [action-name [state'-qualifier step-tuple]]] -``` - Exercising gives us results conforming to our step spec: ```clojure (s/exercise ::step) -([[{:v 0} [:fill] {:v 2}] [:empty [:fill [:in-between [{:v 0} [:fill] {:v 2}]]]]] - [[{:v 1} [:drink] {:v 2}] [:in-between [:drink [:in-between [{:v 1} [:drink] {:v 2}]]]]] - [[{:v 10} [:drink] {:v 1}] [:full [:drink [:in-between [{:v 10} [:drink] {:v 1}]]]]] - [[{:v 10} [:drink] {:v 2}] [:full [:drink [:in-between [{:v 10} [:drink] {:v 2}]]]]] - [[{:v 0} [:fill] {:v 3}] [:empty [:fill [:in-between [{:v 0} [:fill] {:v 3}]]]]] - [[{:v 10} [:drink] {:v 1}] [:full [:drink [:in-between [{:v 10} [:drink] {:v 1}]]]]] - [[{:v 10} [:drink] {:v 5}] [:full [:drink [:in-between [{:v 10} [:drink] {:v 5}]]]]] - [[{:v 4} [:drink] {:v 0}] [:in-between [:drink [:empty [{:v 4} [:drink] {:v 0}]]]]] - [[{:v 10} [:drink] {:v 8}] [:full [:drink [:in-between [{:v 10} [:drink] {:v 8}]]]]] - [[{:v 8} [:fill] {:v 10}] [:in-between [:fill [:full [{:v 8} [:fill] {:v 10}]]]]]) +([[{::v 0} [:fill] {::v 2}] [:empty [:fill [:in-between [{::v 0} [:fill] {::v 2}]]]]] + [[{::v 1} [:drink] {::v 2}] [:in-between [:drink [:in-between [{::v 1} [:drink] {::v 2}]]]]] + [[{::v 10} [:drink] {::v 1}] [:full [:drink [:in-between [{::v 10} [:drink] {::v 1}]]]]] + [[{::v 10} [:drink] {::v 2}] [:full [:drink [:in-between [{::v 10} [:drink] {::v 2}]]]]] + [[{::v 0} [:fill] {::v 3}] [:empty [:fill [:in-between [{::v 0} [:fill] {::v 3}]]]]] + [[{::v 10} [:drink] {::v 1}] [:full [:drink [:in-between [{::v 10} [:drink] {::v 1}]]]]] + [[{::v 10} [:drink] {::v 5}] [:full [:drink [:in-between [{::v 10} [:drink] {::v 5}]]]]] + [[{::v 4} [:drink] {::v 0}] [:in-between [:drink [:empty [{::v 4} [:drink] {::v 0}]]]]] + [[{::v 10} [:drink] {::v 8}] [:full [:drink [:in-between [{::v 10} [:drink] {::v 8}]]]]] + [[{::v 8} [:fill] {::v 10}] [:in-between [:fill [:full [{::v 8} [:fill] {::v 10}]]]]]) ``` ### Options @@ -132,13 +121,13 @@ on the kinds of steps produced. {:extra-defs [::state-action :no-state' ::states :only-states ::action :only-action]} - (:in-between/state - ::drink (:in-between/state :empty/state) - ::fill (:in-between/state :full/state)) - (:empty/state - ::fill (:in-between/state)) - (:full/state - ::drink (:in-between/state))) + (::in-between-state + ::drink (::in-between-state ::empty-state) + ::fill (::in-between-state ::full-state)) + (::empty-state + ::fill (::in-between-state)) + (::full-state + ::drink (::in-between-state))) ``` Restriction keys are as follows: @@ -157,14 +146,10 @@ function. ```clojure (s/def ::action (s/or :drink ::drink :fill ::fill)) -(s/def ::state (s/or :in-between :in-between/state - :empty :empty/state - :full :full/state)) - (defn next-state [state action] (case (first action) - :drink (update state :v dec) - :fill (update state :v inc))) + :drink (update state ::v dec) + :fill (update state ::v inc))) (s/fdef next-state :args ::state-action @@ -191,26 +176,28 @@ fully validate against our full `::step` spec: ```clojure (stepdef ::step {:data-def step-data} - (:in-between/state - ::drink (:in-between/state :empty/state) - ::fill (:in-between/state :full/state)) - (:empty/state - ::fill (:in-between/state)) - (:full/state - ::drink (:in-between/state))) + (::in-between-state + ::drink (::in-between-state ::empty-state) + ::fill (::in-between-state ::full-state)) + (::empty-state + ::fill (::in-between-state)) + (::full-state + ::drink (::in-between-state))) ``` The above will define both the `::step` spec definition as well as ```clojure (def step-data - {:in-between/state - {::drink #{:in-between/state :empty/state} - ::fill #{:in-between/state :full/state}} - :empty/state - {::fill #{:in-between/state}} - :full/state - {::drink #{:in-between/state}}}) + {::in-between-state + {::drink #{::in-between-state ::empty-state} + ::fill #{::in-between-state ::full-state}} + + ::empty-state + {::fill #{::in-between-state}} + + ::full-state + {::drink #{::in-between-state}}}) ``` One use for this data is to generate state transition graphs. We'll use the @@ -258,9 +245,9 @@ Macro Forms are of the shape: - (:in-between/state - ::drink (:in-between/state :empty/state) - ::fill (:in-between/state :full/state)) + (::in-between-state + ::drink (::in-between-state ::empty-state) + ::fill (::in-between-state ::full-state)) where all keywords are registered specs. @@ -283,13 +270,13 @@ Macro {:extra-defs [::state-action :no-state' ::states :only-states ::action :only-action]} - (:in-between/state - ::drink (:in-between/state :empty/state) - ::fill (:in-between/state :full/state)) - (:empty/state - ::fill (:in-between/state)) - (:full/state - ::drink (:in-between/state))) + (::in-between-state + ::drink (::in-between-state ::empty-state) + ::fill (::in-between-state ::full-state)) + (::empty-state + ::fill (::in-between-state)) + (::full-state + ::drink (::in-between-state))) will define ::step, ::state-action, ::states, and ::action specs. @@ -299,22 +286,45 @@ Macro (stepdef ::step {:data-def step-data} - (:in-between/state - ::drink (:in-between/state :empty/state) - ::fill (:in-between/state :full/state)) - (:empty/state - ::fill (:in-between/state)) - (:full/state - ::drink (:in-between/state))) + (::in-between-state + ::drink (::in-between-state ::empty-state) + ::fill (::in-between-state ::full-state)) + (::empty-state + ::fill (::in-between-state)) + (::full-state + ::drink (::in-between-state))) will define both the ::step spec and (def step-data - {:in-between/state - {::drink #{:in-between/state :empty/state} - ::fill #{:in-between/state :full/state}} - :empty/state - {::fill #{:in-between/state}} - :full/state - {::drink #{:in-between/state}}}) + {::in-between-state + {::drink #{::in-between-state ::empty-state} + ::fill #{::in-between-state ::full-state}} + + ::empty-state + {::fill #{::in-between-state}} + + ::full-state + {::drink #{::in-between-state}}}) +``` + +### `protocol55.step.state.alpha/cases` + +``` +([& key-pred-forms]) +Macro + Creates a spec similar to clojure.spec.alpha/or which is compatible with + protocol55.step.state.alpha/produce. + + (state/cases :true true? :false false?) +``` + +### `protocol55.step.state.alpha/produce` + +``` +([spec & {:keys [of of-un]}]) + Creates and returns a map validating spec for specific cases of its keys. :of + and :of-un are both maps of namespace-qualified keywords to case keywords. + + (state/produce ::state :of {::v :empty}) ``` diff --git a/deps.edn b/deps.edn index f1ae0f5..144a144 100644 --- a/deps.edn +++ b/deps.edn @@ -9,7 +9,7 @@ :sha "5fb4fc46ad0bf2e0ce45eba5b9117a2e89166479"}} :main-opts ["-m" "cognitect.test-runner"]} - :testcljs + :test-cljs {:extra-paths ["test"] :extra-deps {org.clojure/clojurescript {:mvn/version "1.10.145"} olical/cljs-test-runner {:mvn/version "2.1.0"}} diff --git a/src/protocol55/step/alpha.clj b/src/protocol55/step/alpha.clj index f69687f..e7ce4e7 100644 --- a/src/protocol55/step/alpha.clj +++ b/src/protocol55/step/alpha.clj @@ -15,16 +15,16 @@ [forms & {:keys [restrict]}] `(s/or ~@(mapcat (fn [[state & {:as action->states'}]] - (let [state-qualifier (namespace state)] - `(~(keyword state-qualifier) + (let [state-qualifier state] + `(~state-qualifier (s/or ~@(mapcat (fn [[action states']] - `(~(-> action name keyword) + `(~action (s/or ~@(mapcat (fn [state'] - (let [state'-qualifier (namespace state')] - `(~(keyword state'-qualifier) + (let [state'-qualifier state'] + `(~state'-qualifier ~(case restrict :no-state' `(s/tuple ~state ~action) diff --git a/src/protocol55/step/state/alpha.cljc b/src/protocol55/step/state/alpha.cljc new file mode 100644 index 0000000..3644176 --- /dev/null +++ b/src/protocol55/step/state/alpha.cljc @@ -0,0 +1,130 @@ +(ns protocol55.step.state.alpha + (:require [clojure.spec.alpha :as s] + [clojure.spec.gen.alpha :as gen]) + #?(:cljs + (:require-macros [protocol55.step.state.alpha :refer [cases]]))) + +(defn- ->spec [x] + (cond + (keyword? x) + (s/get-spec x) + + (s/spec? x) + x + + :else + (throw (ex-info "Can't convert to spec!" {:x x})))) + +(defprotocol CasesSpec + (case-gen* [this k] "returns the generator for the case k")) + +(defn case-gen [s k] + (case-gen* (->spec s) k)) + +;; TODO: make into its own spec, without reliance on or-spec +;; This will allow support for (with-cases x :zero zero?) for generation and +;; conformance +(defn cases-spec-impl [or-spec pred-map gfn] + (reify + s/Specize + (s/specize* [_] + (s/specize* or-spec)) + (s/specize* [_ form] + (s/specize* or-spec form)) + s/Spec + (s/conform* [_ x] + (s/conform* or-spec x)) + (s/unform* [_ y] + (s/unform* or-spec y)) + (s/explain* [_ path via in x] + (s/explain* or-spec path via in x)) + (s/gen* [_ overrides path rmap] + (if gfn + (gfn) + (s/gen* or-spec overrides path rmap))) + (s/with-gen* [_ gfn] + (cases-spec-impl or-spec pred-map gfn)) + (describe* [_] + (s/describe* or-spec)) + CasesSpec + (case-gen* [_ k] + (s/gen (k pred-map))))) + +#?(:clj + (do + +(defmacro cases + "Creates a spec similar to clojure.spec.alpha/or which is compatible with + protocol55.step.state.alpha/produce. + + (state/cases :true true? :false false?)" + [& key-pred-forms] + (let [pred-map (apply hash-map key-pred-forms)] + `(cases-spec-impl (s/or ~@key-pred-forms) ~pred-map nil))) + +)) + +(defn- named-key [n m] + (->> (keys m) (filter #(= (name %) n)) first)) + +(defn- produce* [of of-un conformed] + (every? (fn [[k [ctag _]]] + (if-not (namespace k) + (= ctag (get of-un (named-key (name k) of-un))) + (= ctag (k of)))) + conformed)) + +(defn- case-pairs [m & {:keys [un?]}] + (mapcat (fn [[k v]] + [(if un? (keyword (name k)) k) (case-gen k v)]) + (vec m))) + +(defn produce + "Creates and returns a map validating spec for specific cases of its keys. :of + and :of-un are both maps of namespace-qualified keywords to case keywords. + + (state/produce ::state :of {::v :empty})" + [spec & {:keys [of of-un]}] + (s/spec (s/and spec #(produce* of of-un %)) + :gen (fn [] + (->> (concat (case-pairs of) + (case-pairs of-un :un? true)) + (apply gen/hash-map))))) + +(comment + (s/def ::m (cases :idle #{1} :busy #{0})) + (s/def ::q (cases :empty #{0} :some (s/int-in 1 1e9))) + (s/def ::ss-state (s/keys :req [::m ::q])) + + (s/def ::ss-empty-state empty?) + (s/def ::ss-idle-state (produce ::ss-state :of {::m :idle ::q :some})) + (s/def ::ss-idle-buffer-empty-state (produce ::ss-state :of {::m :idle ::q :empty})) + (s/def ::ss-busy-state (produce ::ss-state :of {::m :busy ::q :some})) + (s/def ::ss-busy-buffer-empty-state (produce ::ss-state :of {::m :busy ::q :empty})) + + (s/def ::initialize #{:initialize}) + (s/def ::arrive #{:arrive}) + (s/def ::load #{:load}) + (s/def ::unload #{:unload}) + + (require '[protocol55.step.alpha :refer [stepdef]] :reload-all) + + (stepdef ::ss-step + (::ss-empty-state + ::initialize (::ss-idle-state)) + (::ss-idle-state + ::arrive (::ss-idle-state) + ::load (::ss-busy-state ::ss-busy-buffer-empty-state)) + (::ss-idle-buffer-empty-state + ::arrive (::ss-idle-state)) + (::ss-busy-state + ::arrive (::ss-busy-state) + ::unload (::ss-busy-state ::ss-idle-state ::ss-idle-buffer-empty-state)) + (::ss-busy-buffer-empty-state + ::arrive (::ss-busy-state) + ::unload (::ss-idle-buffer-empty-state))) + + (s/conform ::ss-step (gen/generate (s/gen ::ss-step))) + (s/valid? ::ss-step [{::m 0 ::q 0} :unload {::m 1 ::q 0}]) + + ) diff --git a/src/protocol55/step_cljs/alpha.cljc b/src/protocol55/step_cljs/alpha.cljc index f022bf8..e69a9e6 100644 --- a/src/protocol55/step_cljs/alpha.cljc +++ b/src/protocol55/step_cljs/alpha.cljc @@ -20,16 +20,16 @@ [forms & {:keys [restrict]}] `(s/or ~@(mapcat (fn [[state & {:as action->states'}]] - (let [state-qualifier (namespace state)] - `(~(keyword state-qualifier) + (let [state-qualifier state] + `(~state-qualifier (s/or ~@(mapcat (fn [[action states']] - `(~(-> action name keyword) + `(~action (s/or ~@(mapcat (fn [state'] - (let [state'-qualifier (namespace state')] - `(~(keyword state'-qualifier) + (let [state'-qualifier state'] + `(~state'-qualifier ~(case restrict :no-state' `(s/tuple ~state ~action) @@ -49,9 +49,9 @@ Forms are of the shape: - (:in-between/state - ::drink (:in-between/state :empty/state) - ::fill (:in-between/state :full/state)) + (::in-between-state + ::drink (::in-between-state ::empty-state) + ::fill (::in-between-state ::full-state)) where all keywords are registered specs. @@ -74,13 +74,13 @@ {:extra-defs [::state-action :no-state' ::states :only-states ::action :only-action]} - (:in-between/state - ::drink (:in-between/state :empty/state) - ::fill (:in-between/state :full/state)) - (:empty/state - ::fill (:in-between/state)) - (:full/state - ::drink (:in-between/state))) + (::in-between-state + ::drink (::in-between-state ::empty-state) + ::fill (::in-between-state ::full-state)) + (::empty-state + ::fill (::in-between-state)) + (::full-state + ::drink (::in-between-state))) will define ::step, ::state-action, ::states, and ::action specs. @@ -90,25 +90,27 @@ (stepdef ::step {:data-def step-data} - (:in-between/state - ::drink (:in-between/state :empty/state) - ::fill (:in-between/state :full/state)) - (:empty/state - ::fill (:in-between/state)) - (:full/state - ::drink (:in-between/state))) + (::in-between-state + ::drink (::in-between-state ::empty-state) + ::fill (::in-between-state ::full-state)) + (::empty-state + ::fill (::in-between-state)) + (::full-state + ::drink (::in-between-state))) will define both the ::step spec and (def step-data - {:in-between/state - {::drink #{:in-between/state :empty/state} - ::fill #{:in-between/state :full/state}} - :empty/state - {::fill #{:in-between/state}} - :full/state - {::drink #{:in-between/state}}}) - " + {::in-between-state + {::drink #{::in-between-state ::empty-state} + ::fill #{::in-between-state ::full-state}} + + ::empty-state + {::fill #{::in-between-state}} + + ::full-state + {::drink #{::in-between-state}}}) + " [k & forms] (let [[opts forms] (if (map? (first forms)) [(first forms) (rest forms)] diff --git a/test/protocol55/step/tests/alpha.cljc b/test/protocol55/step/tests/alpha.cljc index c1c4019..f512fa9 100644 --- a/test/protocol55/step/tests/alpha.cljc +++ b/test/protocol55/step/tests/alpha.cljc @@ -3,6 +3,7 @@ :cljs [protocol55.step-cljs.alpha :refer [stepdef]]) #?(:clj protocol55.step.alpha.specs :cljs protocol55.step-cljs.alpha.specs) + [protocol55.step.state.alpha :as state] [clojure.spec.alpha :as s] [clojure.spec.test.alpha :as st] [clojure.spec.gen.alpha :as gen] @@ -13,66 +14,96 @@ (s/def ::drink (s/tuple #{:drink})) (s/def ::fill (s/tuple #{:fill})) -(s/def :in-between/v (s/int-in 1 10)) -(s/def :in-between/state (s/keys :req-un [:in-between/v])) +(s/def ::v (state/cases :empty zero? :in-between (s/int-in 1 10) :full #{10})) -(s/def :empty/v #{0}) -(s/def :empty/state (s/keys :req-un [:empty/v])) +;; Qualified -(s/def :full/v #{10}) -(s/def :full/state (s/keys :req-un [:full/v])) +(s/def ::state (s/keys :req [::v])) -(s/def ::event (s/or :drink ::drink :fill ::fill)) +(s/def ::in-between-state (state/produce ::state :of {::v :in-between})) +(s/def ::empty-state (state/produce ::state :of {::v :empty})) +(s/def ::full-state (state/produce ::state :of {::v :full})) -(s/def ::state (s/or :in-between :in-between/state - :empty :empty/state - :full :full/state)) (stepdef ::step {:extra-defs [::state-action :no-state' ::states :only-states ::action :only-action] :data-def step-data} - (:in-between/state - ::drink (:in-between/state :empty/state) - ::fill (:in-between/state :full/state)) - (:empty/state - ::fill (:in-between/state)) - (:full/state - ::drink (:in-between/state))) + (::in-between-state + ::drink (::in-between-state ::empty-state) + ::fill (::in-between-state ::full-state)) + (::empty-state + ::fill (::in-between-state)) + (::full-state + ::drink (::in-between-state))) (defn next-state [state action] (case (first action) - :drink (update state :v dec) - :fill (update state :v inc))) + :drink (update state ::v dec) + :fill (update state ::v inc))) (s/fdef next-state :args ::state-action :ret ::state) +;; Unqualified + +(s/def ::state-un (s/keys :req-un [::v])) + +(s/def ::in-between-state-un (state/produce ::state-un :of-un {::v :in-between})) +(s/def ::empty-state-un (state/produce ::state-un :of-un {::v :empty})) +(s/def ::full-state-un (state/produce ::state-un :of-un {::v :full})) + +(stepdef ::step-un + {:extra-defs [::state-action-un :no-state']} + (::in-between-state-un + ::drink (::in-between-state-un ::empty-state-un) + ::fill (::in-between-state-un ::full-state-un)) + (::empty-state-un + ::fill (::in-between-state-un)) + (::full-state-un + ::drink (::in-between-state-un))) + +(defn next-state-un [state action] + (case (first action) + :drink (update state :v dec) + :fill (update state :v inc))) + +(s/fdef next-state-un + :args ::state-action-un + :ret ::state-un) + (deftest test-step - (is (s/valid? ::step [{:v 0} [:fill] {:v 1}])) - (is (not (s/valid? ::step [{:v 0} [:drink] {:v 1}])))) + (is (s/valid? ::step [{::v 0} [:fill] {::v 1}])) + (is (not (s/valid? ::step [{::v 0} [:drink] {::v 1}])))) + +(deftest test-step-un + (is (s/valid? ::step-un [{:v 0} [:fill] {:v 1}])) + (is (not (s/valid? ::step-un [{:v 0} [:drink] {:v 1}])))) (deftest test-extra-defs - (is (s/valid? ::state-action [{:v 0} [:fill]])) - (is (not (s/valid? ::state-action [{:v 0} [:drink]]))) - (is (s/valid? ::states [{:v 0} {:v 1}])) - (is (not (s/valid? ::states [{:v 0} {:v 0}])))) + (is (s/valid? ::state-action [{::v 0} [:fill]])) + (is (not (s/valid? ::state-action [{::v 0} [:drink]]))) + (is (s/valid? ::states [{::v 0} {::v 1}])) + (is (not (s/valid? ::states [{::v 0} {::v 0}])))) (deftest test-data-def (is (= step-data - {:in-between/state - {::drink #{:in-between/state :empty/state} - ::fill #{:in-between/state :full/state}} + {::in-between-state + {::drink #{::in-between-state ::empty-state} + ::fill #{::in-between-state ::full-state}} - :empty/state - {::fill #{:in-between/state}} + ::empty-state + {::fill #{::in-between-state}} - :full/state - {::drink #{:in-between/state}}}))) + ::full-state + {::drink #{::in-between-state}}}))) (defn passes-check? [results] (= 1 (:check-passed (st/summarize-results results)))) (deftest test-check (is (passes-check? (st/check `next-state)))) + +(deftest test-check-un + (is (passes-check? (st/check `next-state-un))))