Search code examples
clojureclojure.spec

clojure spec for hash-map with interdependent values?


I want to write a clojure spec for a hash-map wherein the value of one of the keys is constrained to be equal to the sum of the values of two other keys. I know one way to write a test generator for such a spec by hand:

(ns my-domain)
(require '[clojure.test           :refer :all     ]
         '[clojure.spec.alpha     :as s           ]
         '[clojure.spec.gen.alpha :as gen         ]
         '[clojure.pprint         :refer (pprint) ])

(s/def ::station-id string?)
(s/def ::sim-time (s/double-in :infinite? true, :NaN? false))
(s/def ::reserved-counts (s/and int? #(not (neg? %))))
(s/def ::free-counts     (s/and int? #(not (neg? %))))

(def counts-preimage (s/gen (s/keys :req [::station-id
                                          ::sim-time
                                          ::reserved-counts
                                          ::free-counts])))

(pprint (gen/generate
         (gen/bind
          counts-preimage
          #(gen/return
            (into % {::total-counts
                     (+ (::reserved-counts %)
                        (::free-counts %))})))))
#:my-domain{:station-id "sHN8Ce0tKWSdXmRd4e46fB",
            :sim-time -3.4619293212890625,
            :reserved-counts 58,
            :free-counts 194,
            :total-counts 252}

But I haven't figured out how to write a spec for it, let alone a spec that produces a similar generator. The gist of the problem is that I lack, in the space of specs, a way to get hold of the "preimage" in the spec, that is, I lack an analogue to bind from the space of generators. Here is a failed attempt:

(s/def ::counts-partial-hash-map
  (s/keys :req [::station-id
                ::sim-time
                ::reserved-counts
                ::free-counts]))
(s/def ::counts-attempted-hash-map
  (s/and ::counts-partial-hash-map
         #(into % {::total-counts (+ (::reserved-counts %)
                                     (::free-counts %))})))

(pprint (gen/generate (s/gen ::counts-attempted-hash-map)))
#:my-domain{:station-id "ls5qBUoF",
            :sim-time ##Inf,
            :reserved-counts 56797960,
            :free-counts 17}

The generated sample conforms to the spec because #(into % {...}) is truthy, but the result doesn't contain the new attribute with the key ::total-counts.

I'd be grateful for any guidance.

EDIT: Today I Learned about s/with-gen, which will allow me to attach my (working) test generator to my "preimage" or "partial" spec. Perhaps that's the best way forward?


Solution

  • You could use the nat-int? predicate (for which there's a built-in spec, thanks @glts) for the count keys, and add a ::total-counts spec too:

    (s/def ::reserved-counts nat-int?)
    (s/def ::free-counts nat-int?)
    (s/def ::total-counts nat-int?)
    
    (s/def ::counts-partial-hash-map
      (s/keys :req [::station-id
                    ::sim-time
                    ::reserved-counts
                    ::free-counts]))
    

    spec for a hash-map wherein the value of one of the keys is constrained to be equal to the sum of the values of two other keys

    To add this assertion you can s/and a predicate function with the keys spec (or in this example the merge spec that merges the partial map spec with a ::total-count keys spec):

    (s/def ::counts-attempted-hash-map
      (s/with-gen
        ;; keys spec + sum-check predicate
        (s/and
          (s/merge ::counts-partial-hash-map (s/keys :req [::total-counts]))
          #(= (::total-counts %) (+ (::reserved-counts %) (::free-counts %))))
        ;; custom generator
        #(gen/fmap
           (fn [m]
             (assoc m ::total-counts (+ (::reserved-counts m) (::free-counts m))))
           (s/gen ::counts-partial-hash-map))))
    

    This also uses with-gen to associate a custom generator with the spec that sets ::total-count to the sum of the other count keys.

    (gen/sample (s/gen ::counts-attempted-hash-map) 1)
    => (#:user{:station-id "", :sim-time 0.5, :reserved-counts 1, :free-counts 1, :total-counts 2})
    

    The generated sample conforms to the spec because #(into % {...}) is truthy, but the result doesn't contain the new attribute with the key ::total-counts.

    I'd recommend against using specs to calculate/add ::total-counts to the map. Specs generally shouldn't be used for data transformation.