Repository: chrovis/cljam
Pull request #277 (2023-06-12 10:16:44)

Add validator for writing vcf.
I add VCF4.4 validator for writing.
see also #271

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/chrovis/cljam/pull/277?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chrovis) Report
> Merging [#277](https://app.codecov.io/gh/chrovis/cljam/pull/277?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chrovis) (7114690) into [master](https://app.codecov.io/gh/chrovis/cljam/commit/edecb9d42dfc82e85a9660060864ad11fd889f83?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chrovis) (edecb9d) will **increase** coverage by `0.31%`.
> The diff coverage is `77.88%`.

```diff
@@            Coverage Diff             @@
##           master     #277      +/-   ##
==========================================
+ Coverage   88.85%   89.17%   +0.31%     
==========================================
  Files          78       79       +1     
  Lines        6496     6778     +282     
  Branches      456      499      +43     
==========================================
+ Hits         5772     6044     +272     
- Misses        268      273       +5     
- Partials      456      461       +5     
```


| [Impacted Files](https://app.codecov.io/gh/chrovis/cljam/pull/277?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chrovis) | Coverage Δ | |
|---|---|---|
| [src/cljam/io/vcf/util/validator.clj](https://app.codecov.io/gh/chrovis/cljam/pull/277?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chrovis#diff-c3JjL2NsamFtL2lvL3ZjZi91dGlsL3ZhbGlkYXRvci5jbGo=) | `77.88% <77.88%> (ø)` | |

... and [5 files with indirect coverage changes](https://app.codecov.io/gh/chrovis/cljam/pull/277/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chrovis)


athos: JFYI : If we really need to have a  function that returns a map containing error information, I’d prefer adding another "verb" for the different semantics. For example:

- `(validate-variant <validator> <variant>)`: Returns a map that contains error information if any. Otherwise returns nil.
- `(check-variant <validator> <variant>)`: Throws an error if the variant is invalid.
niyarin: I renamed the api and change `make-validator` to  public.
athos: Thanks for the updates!

And sorry that I carelessly suggested using the word "check" without paying attention to the code you already wrote, but currently the word "check" is used interchangeably with the word "validate" (like `check-base-records`, `check-entry-type` etc.). We should draw a clear line between how we use those two words.
athos: Just rewrote the validator implementation.

It should provide basic validation features, but I think it's a bit vulnerable to broken definitions of Info and Genotype fields themselves, so we might want to work on that area as a future work.
matsutomo81: Thank you for creating this PR and I apologize for the significant delay in responding to your review request 🙏 
I haven't been able to review all of the code yet, but I commented on a few points I've noticed so far.

Patch:
diff --git a/src/cljam/io/vcf/util/validator.clj b/src/cljam/io/vcf/util/validator.clj
new file mode 100644
index 0000000..00b76ee
--- /dev/null
+++ b/src/cljam/io/vcf/util/validator.clj
@@ -0,0 +1,341 @@
+(ns cljam.io.vcf.util.validator
+  (:require [cljam.io.vcf.util :as vcf.util]))
+
+(defrecord VCFValidator [ploidy file-type defs samples])
+
+(defn- error [path msg & args]
+  {:errors {path [(apply format msg args)]}})
+
+(defn- warn [path msg & args]
+  {:warnings {path [(apply format msg args)]}})
+
+(defn- merge-validation-results
+  ([] nil)
+  ([res] res)
+  ([res1 res2]
+   (letfn [(rec [x y]
+             (cond (nil? x) y
+                   (nil? y) x
+                   (map? x) (merge-with rec x y)
+                   :else (into x y)))]
+     (rec res1 res2)))
+  ([res1 res2 res3 & more]
+   (reduce merge-validation-results res1 (list* res2 res3 more))))
+
+(defn- prepend-keys [res keys]
+  ;; takes {:errors {[:chr] ["foo"]}}
+  ;; returns {:errors {[key ... :chr] ["foo"]}}
+  (when-not (nil? res)
+    (into {}
+          (map (fn [[kind m]]
+                 [kind
+                  (into {} (map (fn [[path messages]]
+                                  [(into keys path) messages]))
+                        m)]))
+          res)))
+
+(defn- error-on-bcf [validator path & args]
+  (let [f (if (= :bcf (:file-type validator)) error warn)]
+    (apply f path args)))
+
+(defn- prep-contig-defs [_validator contigs]
+  (into {} (map (juxt :id identity)) contigs))
+
+(def ^:private ^:const contig-pattern
+  #"[0-9A-Za-z!#$%&+./:;?@^_|~-][0-9A-Za-z!#$%&*+./:;=?@^_|~-]*")
+
+(defn- validate-chrom [validator {:keys [chr]}]
+  (if (and (string? chr) (re-matches contig-pattern chr))
+    (when-not (get-in validator [:defs :contig chr])
+      (error-on-bcf validator [:chr] "Contig %s not defined in meta info" chr))
+    (error [:chr]
+           (str ":chr must be a non-empty string that consists of characters "
+                "other than whitespace, commas or angle brackets, but got "
+                (pr-str chr)))))
+
+(defn- validate-pos [_validator {:keys [pos]}]
+  (when-not (integer? pos)
+    (error [:pos] (str ":pos must be integer, but got " (pr-str pos)))))
+
+(defn- validate-ref [_validator {r :ref}]
+  (when-not (and (string? r) (re-matches #"(?i)[ATGCN]+" r))
+    (error [:ref]
+           (str ":ref must be a non-empty string consisting of A/C/G/T/N "
+                "(case insensitive), but got " (pr-str r)))))
+
+(defn- validate-alt [_validator {:keys [alt] :as variant}]
+  (if (or (nil? alt) (sequential? alt))
+    (some->> alt
+             (keep-indexed
+              (fn [i allele]
+                (cond
+                  (and (not (nil? allele))
+                       (or (not (string? allele)) (= allele "")))
+                  {[:alt i]
+                   [(str "Every allele in :alt must be a non-empty string or nil, "
+                         "but got " (pr-str allele))]}
+
+                  (and (string? (:ref variant))
+                       (= :other (:type (vcf.util/inspect-allele (:ref variant) allele))))
+                  {[:alt i] [(str "Unexpected allele found: " allele)]})))
+             seq
+             (apply merge)
+             (array-map :errors))
+    (error [:alt]
+           (str ":alt must be a sequence of strings or nil, but got " (pr-str alt)))))
+
+(defn- validate-qual [_validator {:keys [qual]}]
+  (when-not (or (nil? qual) (number? qual))
+    (error [:qual]
+           (str ":qual must be a number or nil, but got " (pr-str qual)))))
+
+(defn- prep-filter-defs [_validator filters]
+  (into {} (map (juxt (comp keyword :id) identity)) filters))
+
+(defn- validate-filter [validator {filt :filter}]
+  (if (sequential? filt)
+    (->> (for [[i filt] (map-indexed vector filt)]
+           (if (and (keyword? filt)
+                    (re-matches #"[^ \t;]+" (name filt)))
+             (when-not (get-in validator [:defs :filter filt])
+               (error-on-bcf validator
+                             [:filter i]
+                             "Filter %s not defined in meta info" filt))
+             (error [:filter i]
+                    (str "Every filter in :filter must be a keyword consisting "
+                         "of characters other than whitespace or semicolons, "
+                         "but got " (pr-str filt)))))
+         (apply merge-validation-results))
+    (when-not (or (nil? filt) (= filt :PASS))
+      (error [:filter]
+             (str ":filter must be a sequence of keywords or nil, but got "
+                  (pr-str filt))))))
+
+(defn- make-field-type-validator [{:keys [id type]}]
+  (let [f (case type
+            "Integer" #(and (integer? %)
+                            (< (+ Integer/MIN_VALUE 7) (long %))
+                            (<= (long %) Integer/MAX_VALUE))
+            "Float" number?
+            "Character" char?
+            "String" string?
+            "Flag" (constantly true)
+            (constantly false))]
+    (fn [field]
+      (->> field
+           (keep-indexed
+            (fn [i v]
+              (when-not (f v)
+                (let [id' (keyword id)]
+                  (error [id' i]
+                         "Wrong type of value: %s expects %s, but got %s"
+                         id' type (pr-str v))))))
+           (apply merge-validation-results)))))
+
+(defn- make-field-number-validator [ploidy {:keys [id number]}]
+  (let [f (when (not= number 0)
+            (case number
+              "A" identity
+              "R" inc
+              "G" #(count (vcf.util/genotype-seq ploidy %))
+              nil nil
+              (constantly number)))]
+    (fn [num-alts field]
+      (let [expected (when f (f num-alts))
+            actual (count field)]
+        (when (and expected (not= expected actual))
+          (let [id' (keyword id)]
+            (error [id']
+                   "Wrong number of values: %s expects %s value(s), but got %d value(s)"
+                   id' expected actual)))))))
+
+(defn- make-field-validator [ploidy field-def]
+  (let [type-validator (make-field-type-validator field-def)
+        number-validator (make-field-number-validator ploidy field-def)]
+    (fn [num-alts field]
+      (merge-validation-results
+       (type-validator field)
+       (number-validator num-alts field)))))
+
+(defn- make-info-field-validator [ploidy info-field-def]
+  (let [field-validator (make-field-validator ploidy info-field-def)]
+    (fn [info-key variant]
+      (let [info (get variant :info)]
+        (when-let [[_ field] (find info info-key)]
+          (let [field (if (and (some? field) (not (sequential? field)))
+                        [field]
+                        field)]
+            (-> (field-validator (count (:alt variant)) field)
+                (prepend-keys [:info]))))))))
+
+(defn- prep-info-field-defs [{:keys [ploidy] :as _validator} info]
+  (into {} (map (fn [info-field-def]
+                  (let [f (make-info-field-validator ploidy info-field-def)]
+                    [(keyword (:id info-field-def))
+                     (assoc info-field-def :fn f)])))
+        info))
+
+(defn- validate-info-fields [validator variant]
+  (->> (:info variant)
+       keys
+       (map (fn [info-key]
+              (if-let [f (get-in validator [:defs :info info-key :fn])]
+                (f info-key variant)
+                (error [:info info-key]
+                       "Info key %s not defined in meta info" info-key))))
+       (apply merge-validation-results)))
+
+(defn- make-gt-field-validator [ploidy gt-field-def]
+  (let [field-validator (make-field-validator ploidy gt-field-def)]
+    (fn [sample-name gt-key variant]
+      (let [sample (get variant sample-name)]
+        (when-let [[_ field] (find sample gt-key)]
+          (let [field (if (and (some? field) (not (sequential? field)))
+                        [field]
+                        field)]
+            (-> (field-validator (count (:alt variant)) field)
+                (prepend-keys [sample-name]))))))))
+
+(defn- prep-format-defs [{:keys [ploidy] :as _validator} fmt]
+  (into {} (map (fn [gt-field-def]
+                  (let [f (make-gt-field-validator ploidy gt-field-def)]
+                    [(keyword (:id gt-field-def))
+                     (assoc gt-field-def :fn f)])))
+        fmt))
+
+(defn- validate-format [validator {fmt :FORMAT}]
+  (if (or (nil? fmt) (sequential? fmt))
+    (->> (for [[i key] (map-indexed vector fmt)]
+           (if (keyword? key)
+             (merge-validation-results
+              (when (not (get-in validator [:defs :format key]))
+                (error-on-bcf validator
+                              [:FORMAT i]
+                              "Genotype key %s not defined in meta info" key))
+              (when (and (= i 0) (not= key :GT))
+                (error [:FORMAT i]
+                       (str "First genotype key must be :GT, but got " key))))
+             (error [:FORMAT i]
+                    (str "Every genotype key in :FORMAT must be keyword, "
+                         "but got " (pr-str key)))))
+         (apply merge-validation-results))
+    (error [:FORMAT]
+           (str ":FORMAT must be a sequence of keywords or nil, but got "
+                (pr-str fmt)))))
+
+(defn- validate-samples [validator variant]
+  (when (sequential? (:FORMAT variant))
+    (let [fs (into {} (keep (fn [gt-key]
+                              (when-let [f (get-in validator [:defs :format gt-key :fn])]
+                                [gt-key f])))
+                   (:FORMAT variant))]
+      (->> (for [sample-name (:samples validator)
+                 [gt-key f] fs]
+             (f sample-name gt-key variant))
+           (apply merge-validation-results)))))
+
+(defn- validate-data-record [validator variant]
+  (if (map? variant)
+    (let [f (juxt validate-chrom
+                  validate-pos
+                  validate-ref
+                  validate-alt
+                  validate-qual
+                  validate-filter
+                  validate-info-fields
+                  validate-format
+                  validate-samples)]
+      (apply merge-validation-results (f validator variant)))
+    (error [] (str "Variant must be a map, but got " (pr-str variant)))))
+
+(defn make-validator
+  "Creates a vcf validator that is necessary for variant validation.
+  Takes the following three arguments:
+   - meta-info: VCF's meta info
+   - header: VCF's header columns (including mandatory columns)
+   - options: Validation options
+  The available validation options are:
+   - :file-type  Specify the file type (either of :vcf and :bcf).
+                 Defaults to :vcf.
+   - :ploidy     Specify the ploidy of sample genotypes"
+  ([meta-info header] (make-validator meta-info header {}))
+  ([{:keys [contig filter info format]}
+    header
+    {:keys [file-type ploidy] :or {file-type :vcf, ploidy 2}}]
+   (let [samples (into [] (drop 8) header)
+         validator (map->VCFValidator
+                    {:ploidy ploidy :file-type file-type :samples samples})]
+     (assoc validator :defs
+            {:contig (prep-contig-defs validator contig)
+             :filter (prep-filter-defs validator filter)
+             :info (prep-info-field-defs validator info)
+             :format (prep-format-defs validator format)}))))
+
+(defn validate-variant
+  "Checks if the given variant data is in the format cljam expects, and returns
+  a validation result map pointing out the problematic portion of data that does
+  not conform to the format. Otherwise returns nil.
+  The validation result map looks like:
+   {:errors {[:chr] [\"...\"]
+    :warnings {[:pos] [\"...\"]}
+    :variant { ... variant data ...}}}
+  "
+  [validator variant]
+  (let [res (validate-data-record validator variant)]
+    (when (seq res)
+      (assoc res :variant variant))))
+
+(defn validate-variants
+  "Applies `validation-variant` to each element of the given sequence and collects
+  non-nil validation results into a lazy sequence.
+  Returns a transducer if `variants` is not specified."
+  ([validator]
+   (keep (partial validate-variant validator)))
+  ([validator variants]
+   (sequence (validate-variants validator) variants)))
+
+(defn- stringify-validation-result-messages [m]
+  (with-out-str
+    (doseq [[i [path msgs]] (map-indexed vector m)
+            :let [path' (str path)
+                  indent (apply str (repeat (+ (count path') 4) \space))]]
+      (when (not= i 0) (newline))
+      (printf " - %s: %s" path (first msgs))
+      (doseq [msg (rest msgs)]
+        (newline)
+        (printf "%s %s" indent msg)))))
+
+(defn check-variant
+  "Checks if the given variant data is in the format cljam expects, and throws
+  an error if it doesn't conform to the format. Otherwise returns the input variant
+  data.
+  Also, if any validation warning is found, it will be reported to stderr."
+  [validator variant]
+  (let [{:keys [warnings errors] v :variant :as res} (validate-variant validator variant)]
+    (when warnings
+      (binding [*out* *err*]
+        (printf "Variant validation warning at %s\n%s"
+                (pr-str (cond-> v
+                          (map? v)
+                          (select-keys [:chr :pos :id :ref :alt])))
+                (stringify-validation-result-messages warnings))
+        (newline)))
+    (when errors
+      (let [msg (format "Variant validation error at %s\n%s"
+                        (pr-str (cond-> v
+                                  (map? v)
+                                  (select-keys [:chr :pos :id :ref :alt])))
+                        (stringify-validation-result-messages errors))]
+        (throw (ex-info msg res))))
+    variant))
+
+(defn check-variants
+  "Applies `check-variant` to each element of the given sequence.
+  Returns a lazy sequence of the same elements of the input if there are no
+  invalid variant. The validation is evaluated lazily and throws an exception
+  at the first invalid variant.
+  Returns a transducer if `variants` is not specified."
+  ([validator]
+   (map (partial check-variant validator)))
+  ([validator variants]
+   (sequence (check-variants validator) variants)))


Test patch:
diff --git a/test/cljam/io/vcf/util/validator_test.clj b/test/cljam/io/vcf/util/validator_test.clj
new file mode 100644
index 0000000..962afb6
--- /dev/null
+++ b/test/cljam/io/vcf/util/validator_test.clj
@@ -0,0 +1,484 @@
+(ns cljam.io.vcf.util.validator-test
+  (:require
+   [clojure.test :refer [deftest is are testing]]
+   [cljam.io.vcf.util.validator :as v]))
+
+(deftest merge-validation-results-test
+  (are [res1 res2 expected] (= expected (#'v/merge-validation-results res1 res2))
+    nil nil nil
+    nil (#'v/error [:chr] "bad contig") {:errors {[:chr] ["bad contig"]}}
+    (#'v/error [:chr] "bad contig") nil {:errors {[:chr] ["bad contig"]}}
+
+    (#'v/error [:chr] "bad contig")
+    (#'v/error [:pos] "bad pos")
+    {:errors
+     {[:chr] ["bad contig"]
+      [:pos] ["bad pos"]}}
+
+    (#'v/error [:chr] "bad contig")
+    (#'v/error [:chr] "not defined")
+    {:errors {[:chr] ["bad contig" "not defined"]}}))
+
+(deftest prepend-keys-test
+  (are [res keys expected] (= expected (#'v/prepend-keys res keys))
+    nil [:foo] nil
+    (#'v/error [:chr] "bad contig") [:foo] {:errors {[:foo :chr] ["bad contig"]}}))
+
+(deftest prep-contig-defs-test
+  (is (= {"1" {:id "1" :length 248956422}
+          "2" {:id "2" :length 242193529}}
+         (#'v/prep-contig-defs nil
+                               [{:id "1" :length 248956422}
+                                {:id "2" :length 242193529}]))))
+
+(deftest validate-chrom-test
+  (testing "file-type :vcf"
+    (let [validator (v/map->VCFValidator {})
+          prepped (#'v/prep-contig-defs validator
+                                        [{:id "1" :length 248956422}
+                                         {:id "2" :length 242193529}
+                                         {:id "HLA-A*01:01:01:01" :length 3503}])
+          validator (assoc validator :defs {:contig prepped})]
+      (are [variant expected]
+           (= expected (#'v/validate-chrom validator variant))
+        {:chr "1"} nil
+        {:chr "HLA-A*01:01:01:01"} nil
+
+        {:chr 1}
+        {:errors
+         {[:chr]
+          [(str ":chr must be a non-empty string that consists of characters "
+                "other than whitespace, commas or angle brackets, but got 1")]}}
+
+        {:CHR "1"}
+        {:errors
+         {[:chr]
+          [(str ":chr must be a non-empty string that consists of characters "
+                "other than whitespace, commas or angle brackets, but got nil")]}}
+
+        {:chr "chr 1"}
+        {:errors
+         {[:chr]
+          [(str ":chr must be a non-empty string that consists of characters "
+                "other than whitespace, commas or angle brackets, but got "
+                "\"chr 1\"")]}}
+
+        {:chr "X"}
+        {:warnings {[:chr] ["Contig X not defined in meta info"]}})))
+  (testing "file-type :bcf"
+    (let [validator (v/map->VCFValidator {})
+          prepped (#'v/prep-contig-defs validator
+                                        [{:id "1" :length 248956422}
+                                         {:id "2" :length 242193529}])
+          validator (assoc validator :defs {:contig prepped})]
+      (is (= {:warnings {[:chr] ["Contig X not defined in meta info"]}}
+             (#'v/validate-chrom validator {:chr "X"}))))))
+
+(deftest validate-pos-test
+  (are [variant expected] (= expected (#'v/validate-pos nil variant))
+    {:pos 1} nil
+
+    {:pos "1"}
+    {:errors {[:pos] [":pos must be integer, but got \"1\""]}}
+
+    {:POS 1}
+    {:errors {[:pos] [":pos must be integer, but got nil"]}}))
+
+(deftest validate-ref-test
+  (are [variant expected] (= expected (#'v/validate-ref nil variant))
+    {:ref "A"} nil
+    {:ref "ctg"} nil
+
+    {:ref \A}
+    {:errors
+     {[:ref]
+      [(str ":ref must be a non-empty string consisting of A/C/G/T/N "
+            "(case insensitive), but got \\A")]}}
+
+    {:ref "B"}
+    {:errors
+     {[:ref]
+      [(str ":ref must be a non-empty string consisting of A/C/G/T/N "
+            "(case insensitive), but got \"B\"")]}}
+
+    {:REF "A"}
+    {:errors
+     {[:ref]
+      [(str ":ref must be a non-empty string consisting of A/C/G/T/N "
+            "(case insensitive), but got nil")]}}))
+
+(deftest validate-alt-test
+  (are [variant expected] (= expected (#'v/validate-alt nil variant))
+    {:ref "A" :alt ["G"]} nil
+    {:ref "A" :alt ["G" "<DEL>"]} nil
+    {:ref "G" :alt ["G]17:198982]" "]13:123456]T"]} nil
+
+    {:ref "A" :alt "G"}
+    {:errors
+     {[:alt] [":alt must be a sequence of strings or nil, but got \"G\""]}}
+
+    {:ref "A" :alt ["G" \C]}
+    {:errors
+     {[:alt 1] [(str "Every allele in :alt must be a non-empty string or nil, "
+                     "but got \\C")]}}
+
+    {:ref "A" :alt ["G" \C ""]}
+    {:errors
+     {[:alt 1] [(str "Every allele in :alt must be a non-empty string or nil, "
+                     "but got \\C")]
+      [:alt 2] [(str "Every allele in :alt must be a non-empty string or nil, "
+                     "but got \"\"")]}}))
+
+(deftest validate-qual-test
+  (are [variant expected] (= expected (#'v/validate-qual nil variant))
+    {:qual nil} nil
+    {:qual 60} nil
+
+    {:qual "60"}
+    {:errors {[:qual] [":qual must be a number or nil, but got \"60\""]}}))
+
+(deftest prep-filter-defs-test
+  (is (= {:q10 {:id "q10"}
+          :s50 {:id "s50"}}
+         (#'v/prep-filter-defs nil [{:id "q10"} {:id "s50"}]))))
+
+(deftest validate-filter-test
+  (testing "file-type :vcf"
+    (let [validator (v/map->VCFValidator {})
+          prepped (#'v/prep-filter-defs validator [{:id "q10"} {:id "s50"}])
+          validator (assoc validator :defs {:filter prepped})]
+      (are [variant expected] (= expected (#'v/validate-filter validator variant))
+        {:filter nil} nil
+        {:filter [:q10 :s50]} nil
+
+        {:filter :q10}
+        {:errors
+         {[:filter] [":filter must be a sequence of keywords or nil, but got :q10"]}}
+
+        {:filter [:q10 "s50"]}
+        {:errors
+         {[:filter 1]
+          [(str "Every filter in :filter must be a keyword consisting "
+                "of characters other than whitespace or semicolons, "
+                "but got \"s50\"")]}}
+
+        {:filter ["q10" :unknown]}
+        {:warnings
+         {[:filter 1] ["Filter :unknown not defined in meta info"]}
+         :errors
+         {[:filter 0]
+          [(str "Every filter in :filter must be a keyword consisting "
+                "of characters other than whitespace or semicolons, "
+                "but got \"q10\"")]}})))
+  (testing "file-type :bcf"
+    (let [validator (v/map->VCFValidator {:file-type :bcf})
+          prepped (#'v/prep-filter-defs validator [{:id "q10"} {:id "s50"}])
+          validator (assoc validator :defs {:filter prepped})]
+      (is (= {:errors
+              {[:filter 0]
+               [(str "Every filter in :filter must be a keyword consisting "
+                     "of characters other than whitespace or semicolons, "
+                     "but got \"q10\"")]
+               [:filter 1] ["Filter :unknown not defined in meta info"]}}
+             (#'v/validate-filter validator {:filter ["q10" :unknown]}))))))
+
+(deftest make-field-type-validator
+  (are [field-def field-val expected]
+       (= expected
+          (let [f (#'v/make-field-type-validator field-def)]
+            (f field-val)))
+    {:id "AC" :type "Integer" :number "A"} [50] nil
+    {:id "AC" :type "Integer" :number "A"} ["50"]
+    {:errors
+     {[:AC 0] ["Wrong type of value: :AC expects Integer, but got \"50\""]}}
+
+    {:id "AC" :type "Integer" :number "A"} [(bit-shift-left 1 31)]
+    {:errors
+     {[:AC 0] [(str "Wrong type of value: :AC expects Integer, but got "
+                    (bit-shift-left 1 31))]}}
+
+    {:id "BQ" :type "Float" :number 1} [30.0] nil
+    {:id "BQ" :type "Float" :number 1} ["30.0"]
+    {:errors
+     {[:BQ 0] ["Wrong type of value: :BQ expects Float, but got \"30.0\""]}}
+
+    {:id "TYPE" :type "Character" :number 1} [\A] nil
+    {:id "TYPE" :type "Character" :number 1} [(int \A)]
+    {:errors
+     {[:TYPE 0] ["Wrong type of value: :TYPE expects Character, but got 65"]}}
+
+    {:id "CIGAR" :type "String" :number "A"} ["150M"] nil
+
+    {:id "CIGAR" :type "String" :number "A"} [150M]
+    {:errors
+     {[:CIGAR 0] ["Wrong type of value: :CIGAR expects String, but got 150M"]}}
+
+    {:id "VALIDATED" :type "Flag" :number 0} [true] nil))
+
+(deftest make-field-number-validator
+  (are [field-def num-alts field-val expected]
+       (= expected
+          (let [f (#'v/make-field-number-validator 2 field-def)]
+            (f num-alts field-val)))
+    {:id "BQ" :type "Float" :number 1} 1 [30.0] nil
+    {:id "BQ" :type "Float" :number 1} 1 [30.0 40.0]
+    {:errors
+     {[:BQ] ["Wrong number of values: :BQ expects 1 value(s), but got 2 value(s)"]}}
+
+    {:id "HQ" :type "Integer" :number 2} 1 [30 40] nil
+    {:id "HQ" :type "Integer" :number 2} 1 [30]
+    {:errors
+     {[:HQ] ["Wrong number of values: :HQ expects 2 value(s), but got 1 value(s)"]}}
+
+    {:id "AC" :type "Integer" :number "A"} 1 [50] nil
+    {:id "AC" :type "Integer" :number "A"} 1 [50 60]
+    {:errors
+     {[:AC] ["Wrong number of values: :AC expects 1 value(s), but got 2 value(s)"]}}
+
+    {:id "AD" :type "Integer" :number "R"} 1 [50 60] nil
+    {:id "AD" :type "Integer" :number "R"} 1 [50]
+    {:errors
+     {[:AD] ["Wrong number of values: :AD expects 2 value(s), but got 1 value(s)"]}}
+
+    {:id "GP" :type "Float" :number "G"} 1 [0.5 0.3 0.2] nil
+    {:id "GP" :type "Float" :number "G"} 1 [0.5 0.3]
+    {:errors
+     {[:GP] ["Wrong number of values: :GP expects 3 value(s), but got 2 value(s)"]}}))
+
+(deftest make-info-field-validator-test
+  (are [variant expected] (= expected
+                             (let [f (#'v/make-info-field-validator
+                                      2
+                                      {:id "AC" :type "Integer" :number "A"})]
+                               (f :AC variant)))
+    {:alt ["A"] :info nil} nil
+    {:alt ["A"] :info {}} nil
+    {:alt ["A"] :info {:AC [10]}} nil
+    {:alt ["A" "C"] :info {:AC [10 20]}} nil
+
+    {:alt ["A"] :info {:AC ["10"]}}
+    {:errors
+     {[:info :AC 0] ["Wrong type of value: :AC expects Integer, but got \"10\""]}}
+
+    {:alt ["A"] :info {:AC [20 10]}}
+    {:errors
+     {[:info :AC] ["Wrong number of values: :AC expects 1 value(s), but got 2 value(s)"]}}))
+
+(deftest prep-info-field-defs-test
+  (is (= {:DP {:id "DP" :type "Integer" :number 1 :fn true}
+          :AF {:id "AF" :type "Float" :number "A" :fn true}}
+         (into {}
+               (map (fn [[id field-def]] [id (update field-def :fn fn?)]))
+               (#'v/prep-info-field-defs {:ploidy 2}
+                                         [{:id "DP" :type "Integer" :number 1}
+                                          {:id "AF" :type "Float" :number "A"}])))))
+
+(deftest validate-info-fields-test
+  (let [validator (v/map->VCFValidator {:ploidy 2})
+        prepped (#'v/prep-info-field-defs validator
+                                          [{:id "DP" :type "Integer" :number 1}
+                                           {:id "AF" :type "Float" :number "A"}])
+        validator (assoc validator :defs {:info prepped})]
+    (are [variant expected] (= expected (#'v/validate-info-fields validator variant))
+      {:alt ["A"] :info {:DP 10 :AF [0.5]}} nil
+      {:alt ["A"] :info {:DP 10}} nil
+      {:alt ["A"] :info {:AF [0.5]}} nil
+
+      {:alt ["A"] :info {:DP [10 "20"] :AF ["0.5"]}}
+      {:errors
+       {[:info :DP] ["Wrong number of values: :DP expects 1 value(s), but got 2 value(s)"]
+        [:info :DP 1] ["Wrong type of value: :DP expects Integer, but got \"20\""]
+        [:info :AF 0] ["Wrong type of value: :AF expects Float, but got \"0.5\""]}}
+
+      {:alt ["A"] :info {:AD [10]}}
+      {:errors
+       {[:info :AD] ["Info key :AD not defined in meta info"]}})))
+
+(deftest make-gt-field-validator-test
+  (are [variant expected] (= expected
+                             (let [f (#'v/make-gt-field-validator
+                                      2
+                                      {:id "AD" :type "Integer" :number "R"})]
+                               (f :sample01 :AD variant)))
+    {:alt ["A"] :sample01 nil} nil
+    {:alt ["A"] :sample01 {}} nil
+    {:alt ["A"] :sample01 {:AD [30 10]}} nil
+    {:alt ["A" "C"] :sample01 {:AD [30 20 10]}} nil
+
+    {:alt ["A"] :sample01 {:AD [30 "10"]}}
+    {:errors
+     {[:sample01 :AD 1] ["Wrong type of value: :AD expects Integer, but got \"10\""]}}
+
+    {:alt ["A"] :sample01 {:AD [30 20 10]}}
+    {:errors
+     {[:sample01 :AD] ["Wrong number of values: :AD expects 2 value(s), but got 3 value(s)"]}}))
+
+(deftest prep-format-defs-test
+  (is (= {:AD {:id "AD" :type "Integer" :number "R" :fn true}
+          :GP {:id "GP" :type "Float" :number "G" :fn true}
+          :GT {:id "GT" :type "String" :number 1 :fn true}}
+         (into {}
+               (map (fn [[id field-def]] [id (update field-def :fn fn?)]))
+               (#'v/prep-format-defs {:ploidy 2}
+                                     [{:id "AD" :type "Integer" :number "R"}
+                                      {:id "GP" :type "Float" :number "G"}
+                                      {:id "GT" :type "String" :number 1}])))))
+
+(deftest validate-format-test
+  (testing "file-type :vcf"
+    (let [validator (v/map->VCFValidator {:ploidy 2})
+          prepped (#'v/prep-format-defs validator
+                                        [{:id "AD" :type "Integer" :number "R"}
+                                         {:id "GP" :type "Float" :number "G"}
+                                         {:id "GT" :type "String" :number 1}])
+          validator (assoc validator :defs {:format prepped})]
+      (are [variant expected] (= expected (#'v/validate-format validator variant))
+        {} nil
+        {:FORMAT []} nil
+        {:FORMAT [:GT :AD :GP]} nil
+
+        {:FORMAT :AD}
+        {:errors
+         {[:FORMAT] [":FORMAT must be a sequence of keywords or nil, but got :AD"]}}
+
+        {:FORMAT [:AD]}
+        {:errors
+         {[:FORMAT 0] ["First genotype key must be :GT, but got :AD"]}}
+
+        {:FORMAT [:GT :AD "DP"]}
+        {:errors
+         {[:FORMAT 2] ["Every genotype key in :FORMAT must be keyword, but got \"DP\""]}}
+
+        {:FORMAT [:GT :AD :UNKNOWN]}
+        {:warnings
+         {[:FORMAT 2] ["Genotype key :UNKNOWN not defined in meta info"]}})))
+  (testing "file-type :bcf"
+    (let [validator (v/map->VCFValidator {:ploidy 2 :file-type :bcf})
+          prepped (#'v/prep-format-defs validator
+                                        [{:id "AD" :type "Integer" :number "R"}
+                                         {:id "GP" :type "Float" :number "G"}
+                                         {:id "GT" :type "String" :number 1}])
+          validator (assoc validator :defs {:format prepped})]
+      (is (= {:errors
+              {[:FORMAT 2] ["Genotype key :UNKNOWN not defined in meta info"]}}
+             (#'v/validate-format validator {:FORMAT [:GT :AD :UNKNOWN]}))))))
+
+(deftest validate-samples-test
+  (let [validator (v/map->VCFValidator {:ploidy 2
+                                        :samples [:sample01 :sample02]})
+        prepped (#'v/prep-format-defs validator
+                                      [{:id "AD" :type "Integer" :number "R"}
+                                       {:id "GP" :type "Float" :number "G"}
+                                       {:id "GT" :type "String" :number 1}])
+        validator (assoc validator :defs {:format prepped})]
+    (are [variant expected] (= expected (#'v/validate-samples validator variant))
+      {:alt ["A"] :FORMAT [:GT :AD :GP]} nil
+
+      {:alt ["A"]
+       :FORMAT [:GT :AD :GP]
+       :sample01 {:GT "0/1" :AD [30 10] :GP [0.5 0.3 0.2]}
+       :sample02 {:GT "0/1" :AD [20 20] :GP [0.2 0.6 0.2]}}
+      nil
+
+      {:alt ["A"]
+       :FORMAT [:GT :AD :GP]
+       :sample01 {:GT "0/0" :AD [30] :GP [0.3 0.2 0.1]}
+       :sample02 {:GT "0/1" :AD [20 20] :GP [0.2 0.6 "0.2"]}}
+      {:errors
+       {[:sample01 :AD] ["Wrong number of values: :AD expects 2 value(s), but got 1 value(s)"]
+        [:sample02 :GP 2] ["Wrong type of value: :GP expects Float, but got \"0.2\""]}})))
+
+(def mandatory-header-columns
+  ["CHROM" "POS" "ID" "REF" "ALT" "QUAL" "FILTER" "INFO"])
+
+(def test-validator
+  (v/make-validator {:contig [{:id "1" :length 248956422}
+                              {:id "2" :length 242193529}]
+                     :filter [{:id "q10"} {:id "s50"}]
+                     :info [{:id "DP" :type "Integer" :number 1}
+                            {:id "AF" :type "Float" :number "A"}]
+                     :format [{:id "AD" :type "Integer" :number "R"}
+                              {:id "GP" :type "Float" :number "G"}
+                              {:id "GT" :type "String" :number 1}]}
+                    (conj mandatory-header-columns
+                          :FORMAT :sample01 :sample02)))
+
+(def test-variant
+  {:chr "1" :pos 12345 :filter :PASS :ref "A" :alt ["G"] :qual 60
+   :info {:DP 50 :AF [0.5]} :FORMAT [:GT :AD :GP]
+   :sample01 {:GT "0/1" :AD [30 20] :GP [0.5 0.3 0.2]}})
+
+(deftest validate-data-record-test
+  (are [variant expected] (= expected
+                             (-> (#'v/validate-data-record test-validator variant)
+                                 (get :errors)
+                                 keys
+                                 set))
+    test-variant #{}
+
+    (update test-variant :chr #(Long/parseLong %))
+    #{[:chr]}
+
+    (update test-variant :pos str)
+    #{[:pos]}
+
+    (update test-variant :filter name)
+    #{[:filter]}
+
+    (update test-variant :ref first)
+    #{[:ref]}
+
+    (update test-variant :alt first)
+    #{[:alt]}
+
+    (update test-variant :qual str)
+    #{[:qual]}
+
+    (update-in test-variant [:info :AF] rest)
+    #{[:info :AF]}
+
+    (update test-variant :FORMAT first)
+    #{[:FORMAT]}
+
+    (update-in test-variant [:sample01 :AD] rest)
+    #{[:sample01 :AD]})
+
+  (is (= {:errors {[] ["Variant must be a map, but got \"foo\""]}}
+         (#'v/validate-data-record test-validator "foo"))))
+
+(deftest validate-variant-test
+  (is (nil? (v/validate-variant test-validator test-variant)))
+  (let [broken-variant (update test-variant :pos str)
+        res (v/validate-variant test-validator broken-variant)]
+    (is (= #{[:pos]} (set (keys (:errors res)))))
+    (is (= broken-variant (:variant res)))))
+
+(deftest validate-variants
+  (let [broken-variant (update test-variant :pos (comp str inc))
+        variants [test-variant broken-variant]
+        res (v/validate-variants test-validator variants)]
+    (is (= 1 (count res)))
+    (is (= broken-variant (:variant (first res))))
+    (is (= #{[:pos]} (set (keys (:errors (first res))))))))
+
+(deftest check-variant-test
+  (is (= test-variant (v/check-variant test-validator test-variant)))
+  (let [broken-variant (update test-variant :pos str)
+        ex (try
+             (v/check-variant test-validator broken-variant)
+             (catch Exception e e))
+        res (ex-data ex)]
+    (is (= broken-variant (:variant res)))
+    (is (= #{[:pos]} (set (keys (:errors res)))))))
+
+(deftest check-variants-test
+  (let [variants [test-variant (update test-variant :pos inc)]]
+    (is (= variants (doall (v/check-variants test-validator variants)))))
+  (let [broken-variant (update test-variant :pos (comp str inc))
+        variants [test-variant broken-variant]
+        ex (try
+             (doall (v/check-variants test-validator variants))
+             (catch Exception e e))
+        res (ex-data ex)]
+    (is (= broken-variant (:variant res)))
+    (is (= #{[:pos]} (set (keys (:errors res)))))))