Skip to content

Instantly share code, notes, and snippets.

@lynaghk
Created November 2, 2012 20:30
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save lynaghk/4004131 to your computer and use it in GitHub Desktop.
Save lynaghk/4004131 to your computer and use it in GitHub Desktop.
Questions about fancy map rewriting via core.logic
(rewrite-layer
{:mapping {:y :mpg}
:stat (stat/quantiles)})
;; =>
{:mapping {:upper :q75, :min :min, :lower :q25, :max :max, :middle :q50, :y :mpg}
:stat #com.keminglabs/c2po$stat$quantiles {:dimension :mpg}
:geom #com.keminglabs/c2po$geom$boxplot {}}
;;I'd like to use core.logic to rewrite "colloquial" plot specifications into full, correct specs from which the C2PO compiler can generate graphics.
;;A full C2PO plot spec looks like:
{:data [...] ;;A seq of maps
:group :identity
:stat :identity
:mapping {} ;;A map from a geom's aesthetic to a key in the data maps, e.g., {:x :weight, :y :miles-per-gallon}
:geom :point
:scales {} ;;A map of aesthetic keywords to scales, e.g., {:x :linear}
}
;;Right now the rewrite system uses core.logic to bind lvars onto a rewrite map that is deep-merged into the original spec.
;;For instance the [rule rewrite] pair
[{:mapping {:x ?x, :y ?y}, :geom :boxplot, :group !_}
{:mapping {:x :group/midpoint, :width :group/bin-width, :y !_}
:geom :boxplot, :group #group.bin {:dimension ?x}, :stat #stat.quantiles {:dimension ?y}}]
;;matches any spec that has an x and y mapping with a geom :boxplot and does NOT have a group key specified.
;;The rewrite builds a spec for the user's intent---to group the data along the x-dimension into bins and then draw a boxplot for each bin.
;;;;;;;;;;;;;;;;;;
;;Specifying OR
;;It would be nice if the match template could "OR" at certain locations:
{:group (or !_ #group.bin{:dimension !_})}
;;should match a map where there is no :group key, or where the :group key has a #group.bin {} record without a specified dimension.
;;The reason we need this is because users who write
{:data mtcars
:mapping {:x :weight :y :mpg}
:geom :boxplot}
;;will, after getting boxplots for 0--2 tons, 2--4 tons, ... try to make the bins smaller by saying:
{:data mtcars
:mapping {:x :weight :y :mpg}
:geom :boxplot
:group #bin {:bin-size 0.5}}
;;and if this didn't work they'd be (rightfully) surprised/upset.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;Constraints outside of current subtree
;;An ideal syntax for predicates on the data (e.g., the type of data: numeric, categorical, ordinal) would be to put the constraints directly on the lvars in the mapping:
{:mapping {:x (numeric? ?x)}}
;;should only match specs where whatever is mapped to x is a numeric value.
;;This match should succeed for:
{:data [{:this 1 :that "grr"} ...]
:mapping {:x :this}
...}
;;but not for
{:data [{:this 1 :that "grr"} ...]
:mapping {:x :that}
...}
;;To actually implement this, the constraint would need to have access to the toplevel spec, not just the subtree currently being unified.
;;An implementation might look like:
(defn numeric? [var spec]
(number? (get-in spec [:data 0 var])))
;;;;;;;;;;;;;;;;;;;;;;;;;;
;;Keyword/Record matching
;;The group, stat, and geom can all be specified using keywords as short hands or fully as a record of the appropriate type, e.g., #geom.point {}.
;;Ideally rules will hook into an existing multimethod lookup system so they can handle this transparently.
;;I.e., the match
{:geom :point}
;;should use the fact that
(geom/lookup :point) ;;=> #geom.point {}
;;to also match maps like
{:geom #geom.point {}}
;;If a record is used in the match, then the match should only succeed if the target has a record of the same type.
;;If values are specified in the match record, they should be considered as well:
{:geom #geom.point {:fill "black"}}
;;should only match points with a black fill.
;;A confounding factor here is that c2po's record constructors have defaults, so #geom.point{:fill "black"} will actually init as
#geom.point {:x 0, :y 0, :radius 10, :fill "black", :stroke nil, :opacity nil}
;;but in the match we only want to consider what was explicitly provided, :fill "black".
;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;Specify rewrite priority
;;Conceptually it'd be really nice to specify all the defaults in the rewrite system.
;;If a group isn't specified, the spec should use the identity grouper:
[{:group !_} {:group #group.identity {}}]
;;However, if this rule runs before, say, the histogram rule:
[{:mapping {:x ?x, :y ?y}, :geom :boxplot, :group !_}
{:mapping {:x :group/midpoint, :width :group/bin-width, :y !_}
:geom :boxplot, :group #group.bin {:dimension ?x}}]
;;then the histogram rule will never match (even though it totally should).
;;Is there a clean way to specify priorities in core.logic, or will this need to be done manually by separating the rules into different buckets and iterating over them manually rather than with membero?
(ns ccplot.logic.core
(:refer-clojure :exclude [==])
(:use [clojure.core.logic :only [== fresh membero run]]
[ccplot.logic.rules :only [rules]]))
;;Deep merge taken from Leiningen.
(declare deep-merge-item)
(defn deep-merge [& ms]
(apply merge-with deep-merge-item ms))
(defn deep-merge-item [a b]
(if (and (map? a) (map? b))
(deep-merge a b)
b))
(defn match-rewrite
"Returns first matching rule/rewrite pair for the given layer spec.
If no rules match, returns nil."
([layer] (match-rewrite layer rules))
([layer rules]
(when-let [[[rule rewrite]]
(run 1 [q]
(fresh [rule match rewrite]
(membero {:rule rule :match match :rewrite rewrite}
rules)
(== layer match)
(== q [rule rewrite])))]
{:rule rule :rewrite rewrite})))
(defn rewrite-layer-once
"Applies first matching rewrite rule on layer spec."
[layer]
(if-let [{:keys [rewrite rule]} (match-rewrite layer)]
(-> (deep-merge layer rewrite)
(vary-meta update-in [:rewrites] concat [rule]))
layer))
(defn rewrite-layer
"Repeatedly applies matching rewrites until layer no longer changes.
Modified from simplify fn in Kibit."
[layer]
(->> layer
(iterate rewrite-layer-once)
(partition 2 1)
(drop-while #(apply not= %))
(ffirst)))
(ns ccplot.logic.rules
(:require [clojure.core.logic :as l]
[ccplot.stat.core :as stat]
[ccplot.group.core :as group]
[ccplot.geom.core :as geom]
[ccplot.scale :as scale]))
;;Behaves like core.logic's partial-map, but only unifies with objects that implement the target class.
(defrecord PMatch [target-class]
l/IUninitialized
(-uninitialized [_]
(PMatch. nil))
l/IUnifyWithMap
(unify-with-map [v u s]
(when (= target-class (type u))
(l/unify-with-pmap* (dissoc v :target-class)
u s)))
l/IUnifyTerms
(l/unify-terms [v u s]
(l/unify-with-map v u s))
l/IWalkTerm
(walk-term [v f]
(l/walk-record-term v f)))
;;Macro to make writing rewrite rules a bit easier.
;;Total hack, sorry.
(defmacro with-symbols [syms & body]
`(let ~(vec (interleave syms (map #(list 'quote %) syms)))
~@body))
(defn compile-match-term
"Converts any plain maps into partial maps, and instantiates partial-map-like PMatch record on any maps that are metadata annotated with the class to unify against.
E.g., converts ^::my.Class {:a 1} into something that unifies only against a my.Class record with an :a key equal to 1."
[x]
(if (and (map? x)
(not (instance? clojure.lang.IRecord x)))
(if-let [m (meta x)]
;;then assume it's a class-annotated partial-map and build a record with appropriate unification semantics
(letfn [(meta->class [m]
(-> (first (keys m)) str
(.replaceAll ":" "")
(.replaceAll "/" ".")
(Class/forName)))]
(when-not (= 1 (count (keys m)))
(throw (Error. "Matches can have only one key/value in their metadata.")))
(PMatch. (meta->class m)))
;;otherwise just turn into a partial map
(l/partial-map x))
;;if it's not a map, leave it alone
x))
(defn valid-constraint-map? [cm]
(->> (keys cm)
(reduce (fn [res x]
(if (coll? x)
(into res x)
(conj res x))) [])
(every? l/lvar?)))
(defn compile-rule [r]
(let [[match rewrite constraints] (l/prep r)]
(when constraints
(assert (valid-constraint-map? constraints)
"All keys in constraint map must be lvars or collections of lvars"))
{:rule r
:match (l/partial-map (l/walk-term match compile-match-term))
:constraints constraints
:rewrite rewrite}))
(defn compile-ruleset [& rules]
(map compile-rule rules))
(def !_
"Prevent unification with maps that have a key in the same position as this symbol."
::l/not-found)
(def default-rules
(compile-ruleset
[{:stat !_} {:stat (stat/identity)}]
[{:group !_} {:group (group/identity)}]))
(def general-rules
"Simple pairings between stats and geoms."
(with-symbols [?data ?mapping ?x ?y ?z]
(compile-ruleset
[{:geom ^::geom/Boxplot {} :stat !_} {:stat (stat/quantiles)}]
[{:geom !_ :stat ^::stat/Quantiles {}} {:geom (geom/boxplot)}])))
(def specific-rules
(with-symbols [?data ?mapping ?x ?y ?z]
(compile-ruleset
;;Histogram
[{:mapping {:x ?x :y !_} :geom ^::geom/Bar {} :stat ^::stat/Sum {} :group !_}
{:mapping {:x (comp first :group) :y :stat/sum :width :group/width}
:group (group/bin :dimension ?x :num-bins 30)
:aesthetic-labels {:x ?x :y "Count"}}]
;;Boxplot's aesthetics
[{:geom ^::geom/Boxplot {} :stat ^::stat/Quantiles {}
:mapping {:min !_ :lower !_ :middle !_ :upper !_ :max !_}}
{:mapping {:min :min :lower :q25 :middle :q50 :upper :q75 :max :max}}]
;;Boxplot meets histogram
[{:mapping {:x ?x :y ?y} :geom ^::geom/Boxplot {} :group !_}
{:mapping {:x :group/midpoint :y ?y :width :group/width}
:group (group/bin :dimension ?x :num-bins 30)
:aesthetic-labels {:x ?x :y ?y}}]
[{:stat ^::stat/Quantiles {} :mapping {:y ?y}}
{:stat {:dimension ?y}}]
)))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment