git » blacktide.git » commit 0cc633c

Compose compiler stages through typed unary entry points

author Alan Dipert
2026-11-07 05:34:20 UTC
committer Alan Dipert
2026-11-07 05:34:20 UTC
parent 31924cf3d63e8aa766681aa372f3808e451f87a7

Compose compiler stages through typed unary entry points

Give each tree language a Compilation nonterminal containing its immutable
fresh-name supply and expression. Adapt recursive processors at their stage
boundaries so the driver is a direct thread of named unary transformations.
Keep source setup, shared analyses and backend selection explicit.

Add a polymorphic map-value adapter and static probes for incompatible stage
orderings and incomplete lowering. Document the contracts and composition
with an executable nanopass example.

doc/compiler.md +6 -0
doc/nanopass.md +28 -2
src/com/tailrecursion/blacktide/compiler.clj +27 -25
src/com/tailrecursion/blacktide/compiler/languages.clj +1 -0
src/com/tailrecursion/blacktide/compiler/passes/assignment.clj +11 -0
src/com/tailrecursion/blacktide/compiler/passes/boundaries.clj +6 -0
src/com/tailrecursion/blacktide/compiler/passes/cases.clj +6 -0
src/com/tailrecursion/blacktide/compiler/passes/collections.clj +6 -0
src/com/tailrecursion/blacktide/compiler/passes/lower.clj +5 -0
src/com/tailrecursion/blacktide/compiler/passes/order.clj +5 -0
src/com/tailrecursion/blacktide/compiler/passes/prepare.clj +6 -0
src/com/tailrecursion/blacktide/compiler/passes/source_values.clj +6 -0
src/com/tailrecursion/nanopass/pipeline.clj +11 -0
test/com/tailrecursion/blacktide/annotations.clj +2 -0
test/com/tailrecursion/blacktide/check.clj +16 -1
test/com/tailrecursion/nanopass/annotations.clj +2 -0
test/com/tailrecursion/nanopass/check.clj +22 -0
test/com/tailrecursion/nanopass/pass_test.clj +17 -0

diff --git a/doc/compiler.md b/doc/compiler.md
index 5cb50a5..beec5b4 100644
--- a/doc/compiler.md
+++ b/doc/compiler.md
@@ -28,6 +28,10 @@ Bound → Classified → Sequential → Assigned → Cases → Collections
       → Prepared → Ordered → Symbolic → CFG → Machine
 ```
 
+The linear driver uses ordinary `->` composition of unary stages. Each tree language has a `Compilation` nonterminal: a `[supply tree]` pair containing the immutable fresh-name supply and that language's `Expr`. Stage entry points live beside their recursive processors. They use `pipeline/map-value` to preserve the supply or `apply` to pass both components to a processor that allocates names. Graph construction consumes the pair and returns a graph.
+
+Typed Clojure checks each stage's input and output, including the tree inside `Compilation`. Incompatible language transitions are type errors; semantic ordering constraints within the same language still require tests. Recursive processors keep their own precise arguments and results.
+
 The outer bindings enter as one lexical scope. `Native` contains analyzed subtrees without DSL operations. Both backends share graph validation:
 
 ```text
@@ -35,6 +39,8 @@ Dispatcher:  Machine → Code
 Structured:  Machine → Analysis → Planned → Joined → Stored → Compact → Code
 ```
 
+Backend selection keeps its branching and shared analyses explicit. Compaction returns each simplified subtree with its free-read counts, avoiding repeated analysis of unchanged bodies. Emitters complete their compiler work before returning generated forms; quoted source data remains opaque.
+
 Language derivation describes how grammars relate. It does not set pass order: `Symbolic` extends `CFG`, which extends `Machine`, while lowering visits `Symbolic`, then `CFG`, then `Machine`.
 
 ## Runtime boundaries
diff --git a/doc/nanopass.md b/doc/nanopass.md
index 8ad08c4..0c8a217 100644
--- a/doc/nanopass.md
+++ b/doc/nanopass.md
@@ -11,7 +11,8 @@ A nonterminal is a named grammar type, such as `Expr`. When a language is derive
          '[typed.clojure :as-alias t])
 
 (np/deflanguage Source
-  {Expr (t/U (t/HMap :mandatory {:op ':literal :value t/Any} :complete? true)
+  {Compilation (t/HVec [t/Int Expr])
+   Expr (t/U (t/HMap :mandatory {:op ':literal :value t/Any} :complete? true)
              (t/HMap :mandatory {:op ':negate :value Expr} :complete? true))})
 
 (np/derive-language Core Source
@@ -66,6 +67,31 @@ Write explicit clauses when lexical context changes. Passes may return results o
 
 Use qualified aliases such as `languages/Core-Expr` in `:args` and explicit result types. Aliases used only for checking need not exist as runtime Vars.
 
+## Compose passes
+
+Use ordinary `->` to compose unary stage entry points. A `Compilation` nonterminal packages auxiliary state with the tree; derivation rebinds its `Expr` to each language. Recursive processors need not share the stage interface.
+
+In this example, the state is an integer counter. `pipeline/map-value` adapts the existing `lower` processor, preserving the counter:
+
+```clojure
+(require '[com.tailrecursion.nanopass.pipeline :as pipeline])
+
+(np/defpass lower-stage
+  {:from [Source Compilation] :to [Core Compilation] :body true}
+  [compilation]
+  (pipeline/map-value lower compilation))
+
+(-> [0 {:op :negate :value {:op :literal :value 7}}]
+    lower-stage
+    second)
+;; => {:op :subtract :left {:op :literal :value 0}
+;;                   :right {:op :literal :value 7}}
+```
+
+For a processor that consumes or updates state, the entry point can use `(apply processor compilation)`. A stage can also leave the pair representation, for example by returning a control-flow graph. Keep branching and shared analysis results explicit with ordinary `let` and `if`.
+
+The helper preserves distinct types for state, input and output. Register its signature with `(np/ann-passes pipeline/map-value)` when checking a client.
+
 ## Traversal rules
 
 Structural traversal:
@@ -77,7 +103,7 @@ Structural traversal:
 ## Check languages and pass bodies
 
 1. In an annotation-only namespace, require `typed.clojure` and call `(np/ann-languages Source Core)`. This generates aliases `Source-Expr` and `Core-Expr` in the languages' defining namespace.
-2. Register `(np/ann-passes lower)` in the same checking-only namespace.
+2. Register `(np/ann-passes lower lower-stage pipeline/map-value)` in the same checking-only namespace.
 3. Check the implementation namespace with `typed.clojure/check-ns-clj`.
 
 Ordinary language loading does not register types. Generated signatures state the contracts; function bodies must also be checked. Blacktide's check runner discovers its compiler languages and passes and registers them automatically.
diff --git a/src/com/tailrecursion/blacktide/compiler.clj b/src/com/tailrecursion/blacktide/compiler.clj
index c4e3f66..62d8a1e 100644
--- a/src/com/tailrecursion/blacktide/compiler.clj
+++ b/src/com/tailrecursion/blacktide/compiler.clj
@@ -31,33 +31,36 @@
 (np/defpass compile-program
   {:from [l/Machine Program] :to [l/Machine Code] :body true}
   [machine]
-  (let [cfg (feasible/program {:entry (:entry machine) :blocks (:blocks machine)})
-        reduced (graph/analyze cfg)
-        analysis (value-facts/analyze reduced)
-        planned (joins/plan analysis)
-        decision (structure/recover planned)]
+  (let [analysis (-> {:entry (:entry machine) :blocks (:blocks machine)}
+                     feasible/program
+                     graph/analyze
+                     value-facts/analyze)
+        decision (-> analysis joins/plan structure/recover)]
     (if (= :structured (:op decision))
-      (let [joined (invariants/expression #{} (:body decision))
-            stored (storage/select-storage analysis joined)
-            compact (compact/program stored)]
+      (let [compact (->> (:body decision)
+                         (invariants/expression #{})
+                         (storage/select-storage analysis)
+                         compact/program)]
         (structured/expression (:types compact) (:body compact)))
       (emit/program-form machine))))
 
 (np/defpass compile-tree
-  {:from [l/Bound Expr] :to [l/Machine Code] :args [l/Bound-Supply] :body true}
-  [supply expr]
-  (let [classified (boundaries/classify expr)
-        values (source-values/expression classified)
-        sequential (assignment/sequential values)
-        [supply assigned] (assignment/parallel supply sequential)
-        selected (cases/normalize assigned)
-        constructed (collections/normalize selected)
-        prepared (prepare/prepare constructed)
-        [supply ordered] (order/normalize supply prepared)
-        symbolic (lower/compile-graph supply ordered)
-        cfg (transfers/resolve-program symbolic)
-        machine (graph/analyze cfg)]
-    (host/box-form (compile-program machine))))
+  {:from [l/Bound Compilation] :to [l/Machine Code] :body true}
+  [compilation]
+  (-> compilation
+      boundaries/stage
+      source-values/stage
+      assignment/sequential-stage
+      assignment/parallel-stage
+      cases/stage
+      collections/stage
+      prepare/stage
+      order/stage
+      lower/stage
+      transfers/resolve-program
+      graph/analyze
+      compile-program
+      host/box-form))
 
 (np/defpass caller-locals
   {:from [l/Bound Context] :to [l/Bound Context] :body true}
@@ -74,6 +77,5 @@
   (let [_ (assert (seq? form) "Expected a loophole macro form")
         site (errors/site {:form form :file *file* :line nil :column nil} form)
         ctx (caller-locals {:locals {} :macro-env env :markers markers :outer? true :transfer? true
-                            :recur nil :site site :statement? true :tail? false})
-        [supply expr] (source/parse-scope {:prefix (str (gensym "loophole__")) :next 0} ctx true (vec form))]
-    (compile-tree supply expr)))
+                            :recur nil :site site :statement? true :tail? false})]
+    (compile-tree (source/parse-scope {:prefix (str (gensym "loophole__")) :next 0} ctx true (vec form)))))
diff --git a/src/com/tailrecursion/blacktide/compiler/languages.clj b/src/com/tailrecursion/blacktide/compiler/languages.clj
index 75fe9fb..e8eb822 100644
--- a/src/com/tailrecursion/blacktide/compiler/languages.clj
+++ b/src/com/tailrecursion/blacktide/compiler/languages.clj
@@ -84,6 +84,7 @@
              (t/HMap :mandatory {:op ':loop :id t/Sym :bindings (t/Vec Binding) :body Expr} :complete? true)
              (t/HMap :mandatory {:op ':recur :target RecurTarget :args (t/Vec Expr)} :complete? true))
    Supply (t/HMap :mandatory {:prefix t/Str :next t/Int} :complete? true)
+   Compilation (t/HVec [Supply Expr])
    Context (t/HMap :mandatory {:locals (t/Map t/Sym Local) :macro-env (t/Map t/Sym Raw)
                                :markers (t/Map t/AnyVar t/Kw) :outer? t/Bool :transfer? t/Bool
                                :site Site
diff --git a/src/com/tailrecursion/blacktide/compiler/passes/assignment.clj b/src/com/tailrecursion/blacktide/compiler/passes/assignment.clj
index 118510f..e53aa06 100644
--- a/src/com/tailrecursion/blacktide/compiler/passes/assignment.clj
+++ b/src/com/tailrecursion/blacktide/compiler/passes/assignment.clj
@@ -2,6 +2,7 @@
   "Lower sequential batches to writes, then parallel batches to saved RHSs and
   writes. Pending parallel writes do not execute if an RHS abandons evaluation."
   (:require [com.tailrecursion.nanopass :as np]
+            [com.tailrecursion.nanopass.pipeline :as pipeline]
             [com.tailrecursion.blacktide.compiler.languages :as l]
             [com.tailrecursion.blacktide.compiler.passes.source :as source]
             [typed.clojure :as-alias t]))
@@ -121,3 +122,13 @@
      :args [l/Bound-Supply] :body true}
     [supply expr]
     (let [[supply node] (parallel-node supply (:node expr))] [supply (assoc expr :node node)])))
+
+(np/defpass sequential-stage
+  {:from [l/Classified Compilation] :to [l/Sequential Compilation] :body true}
+  [compilation]
+  (pipeline/map-value sequential compilation))
+
+(np/defpass parallel-stage
+  {:from [l/Sequential Compilation] :to [l/Assigned Compilation] :body true}
+  [compilation]
+  (apply parallel compilation))
diff --git a/src/com/tailrecursion/blacktide/compiler/passes/boundaries.clj b/src/com/tailrecursion/blacktide/compiler/passes/boundaries.clj
index ef7596d..cfc82f1 100644
--- a/src/com/tailrecursion/blacktide/compiler/passes/boundaries.clj
+++ b/src/com/tailrecursion/blacktide/compiler/passes/boundaries.clj
@@ -3,6 +3,7 @@
   exclude DSL effects; lowered loops materialize their source representations."
   (:require [com.tailrecursion.blacktide.compiler.passes.effects :refer [effects]]
             [com.tailrecursion.nanopass :as np]
+            [com.tailrecursion.nanopass.pipeline :as pipeline]
             [com.tailrecursion.blacktide.compiler.languages :as l]
             [com.tailrecursion.blacktide.compiler.passes.loop-facts :as loops]
             [typed.clojure :as-alias t]))
@@ -74,3 +75,8 @@
         (assoc expr :node {:op :native :value (native expr)})
         (= :loop (:op node)) (let [expr (loops/lower expr)] (assoc expr :node (classify-node (:node expr))))
         :else (assoc expr :node (classify-node node))))))
+
+(np/defpass stage
+  {:from [l/Bound Compilation] :to [l/Classified Compilation] :body true}
+  [compilation]
+  (pipeline/map-value classify compilation))
diff --git a/src/com/tailrecursion/blacktide/compiler/passes/cases.clj b/src/com/tailrecursion/blacktide/compiler/passes/cases.clj
index 2718523..d2150b5 100644
--- a/src/com/tailrecursion/blacktide/compiler/passes/cases.clj
+++ b/src/com/tailrecursion/blacktide/compiler/passes/cases.clj
@@ -2,6 +2,7 @@
   "Separate source case from ordinal arm selection after native constant dispatch.
   Each source arm occurs once, including grouped and hash-collision alternatives."
   (:require [com.tailrecursion.nanopass :as np]
+            [com.tailrecursion.nanopass.pipeline :as pipeline]
             [com.tailrecursion.blacktide.compiler.languages :as l]))
 
 (np/defpasses
@@ -23,3 +24,8 @@
 
   (normalize {:from [l/Assigned Expr] :to [l/Cases Expr] :body true}
     [expr] (assoc expr :node (normalize-node (:node expr)))))
+
+(np/defpass stage
+  {:from [l/Assigned Compilation] :to [l/Cases Compilation] :body true}
+  [compilation]
+  (pipeline/map-value normalize compilation))
diff --git a/src/com/tailrecursion/blacktide/compiler/passes/collections.clj b/src/com/tailrecursion/blacktide/compiler/passes/collections.clj
index 04fbc12..d11a478 100644
--- a/src/com/tailrecursion/blacktide/compiler/passes/collections.clj
+++ b/src/com/tailrecursion/blacktide/compiler/passes/collections.clj
@@ -2,6 +2,7 @@
   "Preserve native constant collections and construction checks. Metadata keeps
   construction dynamic and is evaluated only after that construction succeeds."
   (:require [com.tailrecursion.nanopass :as np]
+            [com.tailrecursion.nanopass.pipeline :as pipeline]
             [com.tailrecursion.blacktide.compiler.languages :as l]
             [com.tailrecursion.blacktide.compiler.passes.host-emit :as host]
             [typed.clojure :as-alias t]))
@@ -67,3 +68,8 @@
     {:from [l/Cases Expr] :to [l/Collections Expr] :body true}
     [expr]
     (assoc expr :node (normalize-node (:node expr)))))
+
+(np/defpass stage
+  {:from [l/Cases Compilation] :to [l/Collections Compilation] :body true}
+  [compilation]
+  (pipeline/map-value normalize compilation))
diff --git a/src/com/tailrecursion/blacktide/compiler/passes/lower.clj b/src/com/tailrecursion/blacktide/compiler/passes/lower.clj
index b170b65..4e4efc4 100644
--- a/src/com/tailrecursion/blacktide/compiler/passes/lower.clj
+++ b/src/com/tailrecursion/blacktide/compiler/passes/lower.clj
@@ -137,3 +137,8 @@
   [supply expr]
   (let [[builder value] (lower {:supply supply :ops [] :next 1 :current 0 :blocks {} :labels {} :loops {}} expr)]
     {:entry 0 :labels (:labels builder) :blocks (:blocks (seal builder {:op :return :value value}))}))
+
+(np/defpass stage
+  {:from [l/Ordered Compilation] :to [l/Symbolic Program] :body true}
+  [compilation]
+  (apply compile-graph compilation))
diff --git a/src/com/tailrecursion/blacktide/compiler/passes/order.clj b/src/com/tailrecursion/blacktide/compiler/passes/order.clj
index 1d9d83c..0ffe501 100644
--- a/src/com/tailrecursion/blacktide/compiler/passes/order.clj
+++ b/src/com/tailrecursion/blacktide/compiler/passes/order.clj
@@ -113,3 +113,8 @@
      :args [l/Bound-Supply] :body true}
     [supply expr]
     (normalize-node supply expr (:node expr))))
+
+(np/defpass stage
+  {:from [l/Prepared Compilation] :to [l/Ordered Compilation] :body true}
+  [compilation]
+  (apply normalize compilation))
diff --git a/src/com/tailrecursion/blacktide/compiler/passes/prepare.clj b/src/com/tailrecursion/blacktide/compiler/passes/prepare.clj
index b524474..c7acd77 100644
--- a/src/com/tailrecursion/blacktide/compiler/passes/prepare.clj
+++ b/src/com/tailrecursion/blacktide/compiler/passes/prepare.clj
@@ -2,6 +2,7 @@
   "Make function casts and resolved JVM argument conversions explicit before
   ordering operands. A failing conversion must prevent subsequent evaluation."
   (:require [com.tailrecursion.nanopass :as np]
+            [com.tailrecursion.nanopass.pipeline :as pipeline]
             [com.tailrecursion.blacktide.compiler.languages :as l]
             [com.tailrecursion.blacktide.compiler.jvm :as jvm]
             [typed.clojure :as-alias t]))
@@ -42,3 +43,8 @@
     {:from [l/Collections Expr] :to [l/Prepared Expr] :body true}
     [expr]
     (assoc expr :node (prepare-node (:node expr)))))
+
+(np/defpass stage
+  {:from [l/Collections Compilation] :to [l/Prepared Compilation] :body true}
+  [compilation]
+  (pipeline/map-value prepare compilation))
diff --git a/src/com/tailrecursion/blacktide/compiler/passes/source_values.clj b/src/com/tailrecursion/blacktide/compiler/passes/source_values.clj
index 2705187..77fa5ed 100644
--- a/src/com/tailrecursion/blacktide/compiler/passes/source_values.clj
+++ b/src/com/tailrecursion/blacktide/compiler/passes/source_values.clj
@@ -4,6 +4,7 @@
   slot must not turn those reads into aliases of one box. Native trees already
   preserve this behavior, and mutable locals have reference semantics."
   (:require [com.tailrecursion.nanopass :as np]
+            [com.tailrecursion.nanopass.pipeline :as pipeline]
             [com.tailrecursion.blacktide.compiler.languages :as l]
             [typed.clojure :as-alias t]))
 
@@ -36,3 +37,8 @@
         (assoc expr :node {:op :host :operation {:op :static :class clojure.lang.RT
                                                :member (symbol (str (.getName ^Class type) "Cast")) :tags [type]}
                            :args [expr]}) expr))))
+
+(np/defpass stage
+  {:from [l/Classified Compilation] :to [l/Classified Compilation] :body true}
+  [compilation]
+  (pipeline/map-value expression compilation))
diff --git a/src/com/tailrecursion/nanopass/pipeline.clj b/src/com/tailrecursion/nanopass/pipeline.clj
new file mode 100644
index 0000000..be92406
--- /dev/null
+++ b/src/com/tailrecursion/nanopass/pipeline.clj
@@ -0,0 +1,11 @@
+(ns ^:typed.clojure com.tailrecursion.nanopass.pipeline
+  "Adapt a value transformation to a [state value] compilation pair.
+  Typed unary stages compose with ordinary ->.")
+
+(defn map-value
+  "Transform the value, preserving state. Each stage retains its own value type."
+  {:com.tailrecursion.nanopass/signature
+   '(typed.clojure/All [s a b]
+      [[a :-> b] (typed.clojure/HVec [s a]) :-> (typed.clojure/HVec [s b])])}
+  [pass [state value]]
+  [state (pass value)])
diff --git a/test/com/tailrecursion/blacktide/annotations.clj b/test/com/tailrecursion/blacktide/annotations.clj
index 9810341..abed95e 100644
--- a/test/com/tailrecursion/blacktide/annotations.clj
+++ b/test/com/tailrecursion/blacktide/annotations.clj
@@ -4,6 +4,7 @@
   (:require [clojure.java.io :as io]
             [typed.clojure]
             [clojure.core.typed :as ct]
+            [com.tailrecursion.nanopass.pipeline :as pipeline]
             [com.tailrecursion.nanopass :as np]))
 
 (def compiler-namespaces
@@ -25,6 +26,7 @@
          (np/ann-passes ~@(names ::np/signature)))))
 
 (annotate-compiler)
+(np/ann-passes pipeline/map-value)
 
 ;; Var.toSymbol delegates to Symbol.create; its successful result is non-null.
 (ct/non-nil-return clojure.lang.Var/toSymbol :all)
diff --git a/test/com/tailrecursion/blacktide/check.clj b/test/com/tailrecursion/blacktide/check.clj
index 7779b34..a850174 100644
--- a/test/com/tailrecursion/blacktide/check.clj
+++ b/test/com/tailrecursion/blacktide/check.clj
@@ -3,13 +3,28 @@
   (:require [clojure.test :refer [deftest is]]
             [typed.clojure :as t]
             [com.tailrecursion.nanopass.check :as probes]
-            [com.tailrecursion.blacktide.annotations :as annotations]))
+            [com.tailrecursion.blacktide.annotations :as annotations]
+            [com.tailrecursion.blacktide.compiler.languages :as-alias l]
+            [com.tailrecursion.blacktide.compiler.passes.boundaries :as boundaries]
+            [com.tailrecursion.blacktide.compiler.passes.assignment :as assignment]))
 
 (deftest compiler-passes
   (doseq [namespace annotations/compiler-namespaces
           :when (not= namespace 'com.tailrecursion.blacktide.compiler.languages)]
     (is (t/check-ns-clj namespace) (str namespace))))
 
+(deftest compilation-stage-contracts
+  (doseq [[accepted stages]
+          [[true `[boundaries/stage assignment/sequential-stage assignment/parallel-stage]]
+           [false `[boundaries/stage assignment/parallel-stage assignment/sequential-stage]]
+           [false `[boundaries/stage assignment/sequential-stage]]]]
+    (let [form `(fn [compilation#] (-> compilation# ~@stages))
+          {:keys [ex delayed-errors]}
+          (probes/check-form form `[l/Bound-Compilation :-> l/Assigned-Compilation])
+          errors (concat delayed-errors (:errors (ex-data ex)))]
+      (is (or (nil? ex) (= :top-level-error (:type-error (ex-data ex)))) (str ex))
+      (is (= accepted (empty? errors)) (pr-str [form errors])))))
+
 (deftest assignment-stage-exclusions
   (let [expr (fn [node] {:site {:form nil :file nil :line nil :column nil}
                          :jvm {:hint nil :var-meta {} :type :unknown :primitive? false :hint-error nil}
diff --git a/test/com/tailrecursion/nanopass/annotations.clj b/test/com/tailrecursion/nanopass/annotations.clj
index b7aa96e..1c5a9dd 100644
--- a/test/com/tailrecursion/nanopass/annotations.clj
+++ b/test/com/tailrecursion/nanopass/annotations.clj
@@ -2,6 +2,7 @@
   "Checking-only declarations, generated from the ordinary language values."
   (:require [typed.clojure]
             [com.tailrecursion.nanopass :as np]
+            [com.tailrecursion.nanopass.pipeline :as pipeline]
             [com.tailrecursion.nanopass.fixtures :as f]
             [com.tailrecursion.nanopass.passes :as p]
             [com.tailrecursion.nanopass.clients.assignment :as assignment]
@@ -9,6 +10,7 @@
             [com.tailrecursion.nanopass.clients.flow-languages :as flow-languages]))
 
 (np/ann-languages f/Source f/Core f/Literals f/Mutual f/Stopped)
+(np/ann-passes pipeline/map-value)
 (np/ann-passes p/lower p/lower-binding p/rename p/rename-binding p/count-step p/lower-envelope)
 (np/ann-languages assignment/Source assignment/Core)
 (np/ann-passes assignment/lower assignment/sequential-writes assignment/parallel-writes)
diff --git a/test/com/tailrecursion/nanopass/check.clj b/test/com/tailrecursion/nanopass/check.clj
index 8c8149a..9bdfb0d 100644
--- a/test/com/tailrecursion/nanopass/check.clj
+++ b/test/com/tailrecursion/nanopass/check.clj
@@ -4,6 +4,7 @@
             [clojure.core.typed :as checker]
             [typed.clojure :as t]
             [com.tailrecursion.nanopass :as np]
+            [com.tailrecursion.nanopass.pipeline :as pipeline]
             [com.tailrecursion.nanopass.annotations]
             [com.tailrecursion.nanopass.fixtures :as f]
             [com.tailrecursion.nanopass.passes :as p]
@@ -55,10 +56,31 @@
   (accepts? 'f/Stopped-A {:op :a :next {:op :b :next nil}} false))
 
 (deftest checked-passes
+  (is (t/check-ns-clj 'com.tailrecursion.nanopass.pipeline))
   (is (t/check-ns-clj 'com.tailrecursion.nanopass.passes))
   (is (t/check-ns-clj 'com.tailrecursion.nanopass.clients.assignment))
   (is (t/check-ns-clj 'com.tailrecursion.nanopass.clients.flow)))
 
+(deftest pipeline-contracts
+  (doseq [[accepted form]
+          [[true '(fn [state tree]
+                    (->> [state tree]
+                         (pipeline/map-value p/lower)
+                         (apply p/count-step)))]
+           [false '(fn [state tree]
+                     (->> [state tree]
+                          (pipeline/map-value p/lower)
+                          (pipeline/map-value p/lower)))]
+           [false '(fn [state tree]
+                     (->> [state tree]
+                          (pipeline/map-value p/lower)
+                          (apply (fn [state tree] [tree state]))))]]]
+    (let [{:keys [ex delayed-errors]}
+          (check-form form '[t/Int f/Source-Expr :-> (t/HVec [t/Int f/Core-Expr])])
+          errors (concat delayed-errors (:errors (ex-data ex)))]
+      (is (or (nil? ex) (= :top-level-error (:type-error (ex-data ex)))) (str ex))
+      (is (= accepted (empty? errors)) (pr-str [form errors])))))
+
 (defn generated-function [clause]
   (let [[_ _ _ args body]
         (binding [*ns* (the-ns 'com.tailrecursion.nanopass.passes)]
diff --git a/test/com/tailrecursion/nanopass/pass_test.clj b/test/com/tailrecursion/nanopass/pass_test.clj
index 2a835da..20f6e27 100644
--- a/test/com/tailrecursion/nanopass/pass_test.clj
+++ b/test/com/tailrecursion/nanopass/pass_test.clj
@@ -2,8 +2,25 @@
   "Generated traversal must preserve structure, terminal data, and context."
   (:require [clojure.test :refer [deftest is]]
             [com.tailrecursion.nanopass :as np]
+            [com.tailrecursion.nanopass.pipeline :as pipeline]
             [com.tailrecursion.nanopass.passes :as p]))
 
+(deftest pipeline-state-and-evaluation
+  (let [events (atom [])
+        step (fn [state value]
+               (swap! events conj [state value])
+               [(inc state) (conj value state)])]
+    (is (= [2 [0 1]]
+           (->> [0 []]
+                (apply step)
+                (pipeline/map-value identity)
+                (apply step))))
+    (is (= [[0 []] [1 [0]]] @events)))
+  (doseq [value [nil false]]
+    (is (= [:state value] (pipeline/map-value identity [:state value]))))
+  (is (thrown-with-msg? clojure.lang.ExceptionInfo #"pass failed"
+                       (pipeline/map-value (fn [_] (throw (ex-info "pass failed" {}))) [nil nil]))))
+
 (deftest recursive-traversal
   (let [literal {:op :literal :value '{:op :assign :name x}}
         input (with-meta {:op :many :items {:a [literal {:op :read :name 'x}]}