git » blacktide.git » commit a6ce116

Add a runnable compiler tutorial and unwrap Markdown prose

author Alan Dipert
2026-11-07 02:57:32 UTC
committer Alan Dipert
2026-11-07 02:57:32 UTC
parent 1e1e3a32cf4edc7212421b3d46ba158c663f7735

Add a runnable compiler tutorial and unwrap Markdown prose

README.md +15 -31
doc/compiler.md +12 -27
doc/language.md +21 -58
doc/nanopass.md +18 -41
doc/tutorial.md +560 -0
examples/blacktide/tutorial.clj +60 -0
test/com/tailrecursion/blacktide/backend_test.clj +18 -0

diff --git a/README.md b/README.md
index b72fcbf..46abce3 100644
--- a/README.md
+++ b/README.md
@@ -4,10 +4,7 @@
 
 # Blacktide
 
-Blacktide is a Clojure-to-Clojure compiler library. Its `loophole` macro adds
-mutable lexical locals and explicit jumps within a bounded region of code.
-It compiles that region to ordinary Clojure, without mutable cells or an
-interpreter for local state.
+Blacktide is a Clojure-to-Clojure compiler library. Its `loophole` macro adds mutable lexical locals and explicit jumps within a bounded region of code. It compiles that region to ordinary Clojure, without mutable cells or an interpreter for local state.
 
 ```clojure
 (require '[com.tailrecursion.blacktide :refer [loophole setq psetq label goto]])
@@ -31,36 +28,29 @@ Both assignment forms work in expressions:
 | `setq` | Evaluate and assign each pair in order. | Last value, or nil for no pairs. |
 | `psetq` | Evaluate all right-hand sides, then assign all targets, each in pair order. | nil |
 
-Labels belong to the region's outer statement sequence. Jumps can leave nested
-statement bodies, but every jump needs an explicit label.
+Labels belong to the region's outer statement sequence. Jumps can leave nested statement bodies, but every jump needs an explicit label.
 
 The region supports a defined subset of Clojure:
 
-- **Forms:** `let`, destructuring, `if`, `case`, `loop`/`recur`, calls,
-  collections and Java interop.
-- **Macros:** ordinary macros such as `when`, `cond`, `dotimes` and `doseq`,
-  provided their entire expansion uses supported forms.
-- **Exclusions:** function forms, `letfn`, `try`, native `set!` and nested
-  `loophole`. Define functions and manage resources outside the region.
+- **Forms:** `let`, destructuring, `if`, `case`, `loop`/`recur`, calls, collections and Java interop.
+- **Macros:** ordinary macros such as `when`, `cond`, `dotimes` and `doseq`, provided their entire expansion uses supported forms.
+- **Exclusions:** function forms, `letfn`, `try`, native `set!` and nested `loophole`. Define functions and manage resources outside the region.
 
-See the [language reference](doc/language.md) for the supported forms and their
-semantics. The [algorithm examples](examples/blacktide/algorithms.clj) show
-partitioning and parsing. Blacktide makes no general speedup guarantee.
+See the [language reference](doc/language.md) for the supported forms and their semantics. The [algorithm examples](examples/blacktide/algorithms.clj) show partitioning and parsing. Blacktide makes no general speedup guarantee.
+
+For an executable tour of the compiler, read [Mutable Locals in Clojure, the Hard Way](doc/tutorial.md).
 
 ## Use
 
-Requires Clojure 1.12.6 and JDK 17 or later. To use a neighboring checkout,
-add this dependency to your project's `deps.edn`:
+Requires Clojure 1.12.6 and JDK 17 or later. To use a neighboring checkout, add this dependency to your project's `deps.edn`:
 
 ```clojure
 {:deps {com.tailrecursion/blacktide {:local/root "../blacktide"}}}
 ```
 
-Run `clojure -X:deps prep` in the client project to compile the bundled JVM
-keyword-cache helper. Library JARs already include the helper.
+Run `clojure -X:deps prep` in the client project to compile the bundled JVM keyword-cache helper. Library JARs already include the helper.
 
-Clojure is the only runtime dependency. Typed Clojure checks compiler passes
-during development.
+Clojure is the only runtime dependency. Typed Clojure checks compiler passes during development.
 
 ## Develop
 
@@ -72,21 +62,15 @@ during development.
 | `bin/check` | Run all three steps above; accepts CLI `-S` options. |
 | `clojure -T:build jar` | Build `target/blacktide-0.1.0-SNAPSHOT.jar`. |
 
-Select a JDK with `JAVA_CMD=/path/to/jdk/bin/java bin/check`. Development targets
-JDK 25/27; 17/21 are compatibility targets. Forgejo CI tests all four JDKs,
-checks pass types on 25/27, and builds the JAR.
+Select a JDK with `JAVA_CMD=/path/to/jdk/bin/java bin/check`. Development targets JDK 25/27; 17/21 are compatibility targets. Forgejo CI tests all four JDKs, checks pass types on 25/27, and builds the JAR.
 
-Generated tests use `test.check`, fixed seeds and independent models.
-Failures report the seed and shrunk input. Shrinking preserves lexical scope
-and iteration bounds. Focused regressions use ordinary `clojure.test` examples.
+Generated tests use `test.check`, fixed seeds and independent models. Failures report the seed and shrunk input. Shrinking preserves lexical scope and iteration bounds. Focused regressions use ordinary `clojure.test` examples.
 
 To narrow or reproduce a test run:
 
 - Pass test namespace names to `clojure -M:test` for a targeted run.
-- At a test REPL, call a `defspec` function with a trial count and `:seed`,
-  for example `(generated-nested-loops 1000 :seed 161803)`.
+- At a test REPL, call a `defspec` function with a trial count and `:seed`, for example `(generated-nested-loops 1000 :seed 161803)`.
 
-See the [compiler architecture](doc/compiler.md) and [nanopass guide](doc/nanopass.md)
-for implementation contracts and extension guidance.
+See the [compiler architecture](doc/compiler.md) and [nanopass guide](doc/nanopass.md) for implementation contracts and extension guidance.
 
 Copyright © 2026 Alan Dipert. Distributed under the [MIT License](LICENSE).
diff --git a/doc/compiler.md b/doc/compiler.md
index b15f1e6..5cb50a5 100644
--- a/doc/compiler.md
+++ b/doc/compiler.md
@@ -1,11 +1,10 @@
 # Compiler architecture
 
-Blacktide represents each compiler stage as immutable Clojure data. Languages
-defined with `com.tailrecursion.nanopass` describe that data; passes transform it.
-Compiler namespaces and intermediate representations are internal.
+Blacktide represents each compiler stage as immutable Clojure data. Languages defined with `com.tailrecursion.nanopass` describe that data; passes transform it. Compiler namespaces and intermediate representations are internal.
 
-Typed Clojure checks production pass bodies during development. Ordinary library
-use does not load the checker. Namespace docstrings state each pass's contract.
+Typed Clojure checks production pass bodies during development. Ordinary library use does not load the checker. Namespace docstrings state each pass's contract.
+
+For a walkthrough with actual inputs, intermediate data and generated code, see [Mutable Locals in Clojure, the Hard Way](tutorial.md).
 
 ## Pipeline
 
@@ -29,41 +28,27 @@ Bound → Classified → Sequential → Assigned → Cases → Collections
       → Prepared → Ordered → Symbolic → CFG → Machine
 ```
 
-The outer bindings enter as one lexical scope. `Native` contains analyzed
-subtrees without DSL operations. Both backends share graph validation:
+The outer bindings enter as one lexical scope. `Native` contains analyzed subtrees without DSL operations. Both backends share graph validation:
 
 ```text
 Dispatcher:  Machine → Code
 Structured:  Machine → Analysis → Planned → Joined → Stored → Compact → Code
 ```
 
-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`.
+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
 
-Assignments, normalization and graph construction are pure. Locals become
-lexical bindings and loop parameters, never mutable cells. A generated
-`RT.box(Object)` is an identity operation that preserves an inference boundary;
-its appearance alone does not imply allocation.
+Assignments, normalization and graph construction are pure. Locals become lexical bindings and loop parameters, never mutable cells. A generated `RT.box(Object)` is an identity operation that preserves an inference boundary; its appearance alone does not imply allocation.
 
-Control recovery must not duplicate Java method values, functional-interface
-adapters or keyword lookup sites. Keyword calls with effectful targets save
-the cache thunk before evaluating the target.
+Control recovery must not duplicate Java method values, functional-interface adapters or keyword lookup sites. Keyword calls with effectful targets save the cache thunk before evaluating the target.
 
-The `runtime.KeywordSite` helper implements the native cache protocol. An internal
-macro allocates each site during final Clojure analysis. The helper targets
-JDK 17 and is bundled in JARs.
+The `runtime.KeywordSite` helper implements the native cache protocol. An internal macro allocates each site during final Clojure analysis. The helper targets JDK 17 and is bundled in JARs.
 
 ## Extend the compiler
 
-1. Change the language and affected passes. Prefer a compiler-local transformation
-   over new nanopass machinery.
-2. Add coverage for the source semantics or invariant. For generated tests, use
-   fixed seeds, shrinking and an independent model or ordinary Clojure as the
-   reference. Backend comparisons supplement those independent checks.
+1. Change the language and affected passes. Prefer a compiler-local transformation over new nanopass machinery.
+2. Add coverage for the source semantics or invariant. For generated tests, use fixed seeds, shrinking and an independent model or ordinary Clojure as the reference. Backend comparisons supplement those independent checks.
 3. Run `bin/check` from the repository root.
 
-The check runner discovers compiler source namespaces. It registers language
-and pass contracts from their metadata, so new files need no registration list.
+The check runner discovers compiler source namespaces. It registers language and pass contracts from their metadata, so new files need no registration list.
diff --git a/doc/language.md b/doc/language.md
index f3662c9..1ace9c6 100644
--- a/doc/language.md
+++ b/doc/language.md
@@ -1,17 +1,12 @@
 # Language reference
 
-The public namespace is `com.tailrecursion.blacktide`. It provides `loophole`,
-`setq`, `psetq`, `label` and `goto`. A `loophole` form defines a region where
-assignment and jumps are allowed. The other four macros report an error outside
-that region.
+The public namespace is `com.tailrecursion.blacktide`. It provides `loophole`, `setq`, `psetq`, `label` and `goto`. A `loophole` form defines a region where assignment and jumps are allowed. The other four macros report an error outside that region.
 
 Qualified calls and namespace aliases work. Lexical bindings can shadow macro names.
 
 ## Bindings and assignment
 
-`loophole` takes an even vector of simple symbols and initializers, followed by
-a body. Initializers run once, sequentially, and may read or assign earlier
-bindings. The result is the last body value, or nil for an empty body.
+`loophole` takes an even vector of simple symbols and initializers, followed by a body. Initializers run once, sequentially, and may read or assign earlier bindings. The result is the last body value, or nil for an empty body.
 
 ```clojure
 (require '[com.tailrecursion.blacktide :refer [loophole setq psetq label goto]])
@@ -30,26 +25,18 @@ bindings. The result is the last body value, or nil for an empty body.
 
 These rules also apply to repeated targets.
 
-Nested assignments take effect while right-hand sides are evaluated. If one
-throws an exception, pending parallel writes are abandoned.
+Nested assignments take effect while right-hand sides are evaluated. If one throws an exception, pending parallel writes are abandoned.
 
-Only bindings introduced by `loophole` are assignable. Shadowing with an ordinary
-`let` makes the inner binding immutable. Native `set!` is not allowed.
+Only bindings introduced by `loophole` are assignable. Shadowing with an ordinary `let` makes the inner binding immutable. Native `set!` is not allowed.
 
 ## Statements and jumps
 
 The placement rules distinguish labels from jumps:
 
-- **`label`:** a unique simple symbol in the **outer statement sequence**;
-  evaluates to nil. Nested `do` forms are transparent after macroexpansion.
-  Labels cannot appear inside `let`, `loop`, conditional arms, operands or
-  initializers.
-- **`goto`:** a statement that may occur in `if`/`case` arms and `let`/`loop`
-  bodies. It jumps to an explicit outer label in the same region. Execution
-  continues after that label.
+- **`label`:** a unique simple symbol in the **outer statement sequence**; evaluates to nil. Nested `do` forms are transparent after macroexpansion. Labels cannot appear inside `let`, `loop`, conditional arms, operands or initializers.
+- **`goto`:** a statement that may occur in `if`/`case` arms and `let`/`loop` bodies. It jumps to an explicit outer label in the same region. Execution continues after that label.
 
-A jump cannot occur anywhere within an operand or initializer. This includes
-discarded expressions nested in `do`.
+A jump cannot occur anywhere within an operand or initializer. This includes discarded expressions nested in `do`.
 
 ```clojure
 (loophole [x 0]
@@ -62,8 +49,7 @@ discarded expressions nested in `do`.
   (label done) x) ; => 3
 ```
 
-Backward jumps rerun initializers they encounter. They cannot enter a lexical
-scope or bypass its initialization. These forms are errors:
+Backward jumps rerun initializers they encounter. They cannot enter a lexical scope or bypass its initialization. These forms are errors:
 
 ```clojure
 (loophole [] (let [] (label inside)))
@@ -87,22 +73,16 @@ External functions called from the region can use unrestricted Clojure.
 
 ### Macro expansion
 
-Macros expand in the caller namespace with lexical shadowing and visibility
-checks. The macro environment, `&env`, provides binding names, hints and inferred
-immutable types. It does not provide private `Compiler$LocalBinding` objects.
+Macros expand in the caller namespace with lexical shadowing and visibility checks. The macro environment, `&env`, provides binding names, hints and inferred immutable types. It does not provide private `Compiler$LocalBinding` objects.
 
-Common supported macros include `when`, `when-not`, `cond`, `and`, `or`, `if-let`,
-`when-let`, threading, `dotimes`, `while` and `doseq`. A macro works only when
-its entire expansion uses supported forms. Quoted data is never analyzed as code.
+Common supported macros include `when`, `when-not`, `cond`, `and`, `or`, `if-let`, `when-let`, threading, `dotimes`, `while` and `doseq`. A macro works only when its entire expansion uses supported forms. Quoted data is never analyzed as code.
 
 ### Forms that belong outside the region
 
 The region rejects these forms, including forms produced by macroexpansion:
 
-- **Functions and deferred execution:** `fn`/`fn*`, `letfn`/`letfn*`, `delay`,
-  `lazy-seq` and `future`.
-- **Exceptions, resources and dynamic scope:** `try`/`catch`/`finally`, monitor
-  operations, `binding`, `locking`, `with-open` and `with-redefs`.
+- **Functions and deferred execution:** `fn`/`fn*`, `letfn`/`letfn*`, `delay`, `lazy-seq` and `future`.
+- **Exceptions, resources and dynamic scope:** `try`/`catch`/`finally`, monitor operations, `binding`, `locking`, `with-open` and `with-redefs`.
 - **Definitions:** namespace, Var and type definitions, and `reify`.
 - **Other excluded forms:** native `set!` and nested `loophole`.
 
@@ -132,12 +112,9 @@ Original `recur` preserves:
 
 `recur` cannot target a loop outside the region.
 
-Source primitive inference is preserved. This includes int→long and float→double
-initializer promotion. Incompatible backedges, where a recur returns to its loop,
-require boxing. Even an unreachable recur contributes to inference.
+Source primitive inference is preserved. This includes int→long and float→double initializer promotion. Incompatible backedges, where a recur returns to its loop, require boxing. Even an unreachable recur contributes to inference.
 
-`case` preserves Clojure's constant matching, grouping, hash collisions and
-missing-default exception:
+`case` preserves Clojure's constant matching, grouping, hash collisions and missing-default exception:
 
 - The dispatch expression runs once; only the selected arm runs.
 - Each source arm is analyzed once in written order.
@@ -156,24 +133,15 @@ missing-default exception:
 
 ### Calls and compilation
 
-Source facts determine overloads and conversion order. Clojure 1.12 qualified
-method values, functional-interface adaptation and `:param-tags` are supported.
-Arithmetic respects `*unchecked-math*`. Calls respect `:direct-linking` and direct
-protocol dispatch.
+Source facts determine overloads and conversion order. Clojure 1.12 qualified method values, functional-interface adaptation and `:param-tags` are supported. Arithmetic respects `*unchecked-math*`. Calls respect `:direct-linking` and direct protocol dispatch.
 
-Unreachable code still receives syntax and JVM compilation checks. Very large
-regions remain subject to JVM method-size limits.
+Unreachable code still receives syntax and JVM compilation checks. Very large regions remain subject to JVM method-size limits.
 
 ### Identity and lookup sites
 
-Mutable locals retain reference identity through assignment and immutable
-snapshots. Ordinary primitive locals retain Clojure's semantics: separate Object
-uses may box separately.
+Mutable locals retain reference identity through assignment and immutable snapshots. Ordinary primitive locals retain Clojure's semantics: separate Object uses may box separately.
 
-Literal pooling is unspecified. Embedded constants, macro-supplied quoted data,
-regex literals and inlined `^:const` values may share differently from ordinary
-Clojure. Bind a reference once when its identity matters. Runtime construction
-sites and cached lookup sites are not duplicated.
+Literal pooling is unspecified. Embedded constants, macro-supplied quoted data, regex literals and inlined `^:const` values may share differently from ordinary Clojure. Bind a reference once when its identity matters. Runtime construction sites and cached lookup sites are not duplicated.
 
 ```clojure
 (let [token (Object.)]
@@ -182,15 +150,10 @@ sites and cached lookup sites are not duplicated.
     (identical? token y))) ; => true
 ```
 
-Literal keyword calls retain Clojure's cached `IKeywordLookup` behavior, including
-reentrant target evaluation. An assigned target uses a bundled cache helper;
-that cache never stores mutable region locals.
+Literal keyword calls retain Clojure's cached `IKeywordLookup` behavior, including reentrant target evaluation. An assigned target uses a bundled cache helper; that cache never stores mutable region locals.
 
 ### Errors
 
-Explicit reference conversions may use `Class.cast`. Its exception message
-differs from JVM `checkcast`. Invalid protocol fallback roots can also produce
-different error text. Failing types and evaluation order are preserved.
+Explicit reference conversions may use `Class.cast`. Its exception message differs from JVM `checkcast`. Invalid protocol fallback roots can also produce different error text. Failing types and evaluation order are preserved.
 
-DSL errors carry `:kind`, `:form`, `:file`, available `:line`/`:column`, and relevant
-binding or label data. Clojure normally wraps them in `CompilerException`.
+DSL errors carry `:kind`, `:form`, `:file`, available `:line`/`:column`, and relevant binding or label data. Clojure normally wraps them in `CompilerException`.
diff --git a/doc/nanopass.md b/doc/nanopass.md
index b7ebd3f..8ad08c4 100644
--- a/doc/nanopass.md
+++ b/doc/nanopass.md
@@ -1,13 +1,10 @@
 # Nanopass library
 
-The nanopass library defines languages and passes over immutable Clojure data.
-A language describes the data's shape; a pass transforms data or computes a
-result. Definitions use Typed Clojure syntax without loading the checker.
+The nanopass library defines languages and passes over immutable Clojure data. A language describes the data's shape; a pass transforms data or computes a result. Definitions use Typed Clojure syntax without loading the checker.
 
 ## Define a language
 
-A nonterminal is a named grammar type, such as `Expr`. When a language is derived,
-references to local nonterminals are rebound recursively to the derived language:
+A nonterminal is a named grammar type, such as `Expr`. When a language is derived, references to local nonterminals are rebound recursively to the derived language:
 
 ```clojure
 (require '[com.tailrecursion.nanopass :as np]
@@ -25,27 +22,21 @@ references to local nonterminals are rebound recursively to the derived language
 
 Language descriptions are immutable values. Derivation accepts three operations:
 
-| Option | Effect |
-| --- | --- |
-| `:remove` | Remove tagged productions from a nonterminal. |
-| `:add` | Add tagged productions to a nonterminal. |
+| Option     | Effect                                            |
+| ---------- | ------------------------------------------------- |
+| `:remove`  | Remove tagged productions from a nonterminal.     |
+| `:add`     | Add tagged productions to a nonterminal.          |
 | `:replace` | Replace or add a complete nonterminal definition. |
 
-Structural types support `U`, closed keyword-field `HMap`, fixed `HVec`, `Vec`,
-`Map`, `Set`, and `Option`. Use explicit `HMap`/`HVec` constructors rather than
-quoted collection shorthand. The `clojure.core.typed` spelling is also accepted.
+Structural types support `U`, closed keyword-field `HMap`, fixed `HVec`, `Vec`, `Map`, `Set`, and `Option`. Use explicit `HMap`/`HVec` constructors rather than quoted collection shorthand. The `clojure.core.typed` spelling is also accepted.
 
-Other types are opaque terminals. They cannot contain local nonterminal
-references. Quoted singleton data and `Val`/`Value` payloads stay untouched.
-Declare complex polymorphic terminal types as external aliases.
+Other types are opaque terminals. They cannot contain local nonterminal references. Quoted singleton data and `Val`/`Value` payloads stay untouched. Declare complex polymorphic terminal types as external aliases.
 
-Tagged productions require an `:op` field with a singleton keyword type.
-For example, `':negate` identifies the production removed above.
+Tagged productions require an `:op` field with a singleton keyword type. For example, `':negate` identifies the production removed above.
 
 ## Define a pass
 
-Write clauses for productions that change. The library generates structural
-traversal for unchanged productions:
+Write clauses for productions that change. The library generates structural traversal for unchanged productions:
 
 ```clojure
 (np/defpass lower {:from [Source Expr] :to [Core Expr]}
@@ -55,10 +46,7 @@ traversal for unchanged productions:
            :right (lower (:value node))})
 ```
 
-Automatic clauses require matching fields and structural containers. Removed
-productions and incompatible fields need explicit code. A generated bottom-type
-guard lets the checker detect missing cases. Use `macroexpand-1` to inspect the
-generated code.
+Automatic clauses require matching fields and structural containers. Removed productions and incompatible fields need explicit code. A generated bottom-type guard lets the checker detect missing cases. Use `macroexpand-1` to inspect the generated code.
 
 ## Pass options and recursive processors
 
@@ -70,19 +58,13 @@ generated code.
 | `:args [ContextType]` | Types of leading arguments, forwarded unchanged by generated traversal. |
 | `:body true` | Supply one ordinary expression instead of tagged clauses. |
 
-Group mutually recursive processors with `np/defpasses`. Each entry uses
-`defpass` syntax without the macro name. See the
-[recursive traversal example](../test/com/tailrecursion/nanopass/passes.clj).
+Group mutually recursive processors with `np/defpasses`. Each entry uses `defpass` syntax without the macro name. See the [recursive traversal example](../test/com/tailrecursion/nanopass/passes.clj).
 
-Expansion produces one lexical `letfn` group. Its functions are exported with
-individual signatures. The checker checks every body, and the group is redefined
-as a unit.
+Expansion produces one lexical `letfn` group. Its functions are exported with individual signatures. The checker checks every body, and the group is redefined as a unit.
 
-Write explicit clauses when lexical context changes. Passes may return results
-other than trees, including state/result tuples.
+Write explicit clauses when lexical context changes. Passes may return results other than trees, including state/result tuples.
 
-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.
+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.
 
 ## Traversal rules
 
@@ -90,19 +72,14 @@ Structural traversal:
 
 - Preserves metadata and optional-field presence, including present nil values.
 - Shares unchanged fields and leaves quoted payloads opaque.
-- Treats maps and sets structurally. Use vectors and explicit clauses when
-  order or scope has semantic significance.
+- Treats maps and sets structurally. Use vectors and explicit clauses when order or scope has semantic significance.
 
 ## 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.
+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.
 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.
+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.
 
 Run `bin/check` from the repository root to check both bodies and contracts.
diff --git a/doc/tutorial.md b/doc/tutorial.md
new file mode 100644
index 0000000..2d2b7cf
--- /dev/null
+++ b/doc/tutorial.md
@@ -0,0 +1,560 @@
+# Mutable Locals in Clojure, the Hard Way
+
+I've been thinking about mutable local variables in Clojure.
+
+Clojure has explained the situation to us. Immutable values. Persistent data structures. A more civilized age. I like this philosophy. I would also like to increment a local variable.
+
+The JVM has no moral objection to overwriting a [local-variable slot](https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-2.html#jvms-2.6.1). Clojure's own compiler reuses those slots to implement `loop` and `recur`. You may not assign to an ordinary lexical local yourself. You may submit a complete set of replacement values to an approved recursion point. This is an important philosophical distinction.
+
+Then there's functional programming's dirtiest implementation secret: wanton manipulation of that giant mutable variable otherwise known as "the stack." On the JVM it's [one per thread](https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-2.html#jvms-2.5.2), with mutable frames pushed and popped as methods run. We have outsourced the sin. But I digress.
+
+Mutable local variables to the people!
+
+`loop` and `recur` work well for most iterative algorithms. The inconvenience is that `recur` can only appear in tail position, and every value that changes has to be passed explicitly to the recursion point.
+
+I would like to write this:
+
+```clojure
+(loophole [x 0]
+  (dotimes [i 10]
+    (setq x (+ x i)))
+  x)
+;; => 45
+```
+
+There are several good ways to write this in Clojure already. I'm aware of them. What interests me is whether mutable locals can be implemented entirely as a macro, without storing them in atoms, arrays or other mutable reference cells, and without changing Clojure's compiler.
+
+This brings us back to our earlier observation: Clojure already knows how to update JVM locals. [`recur`](https://clojure.org/reference/special_forms#recur) evaluates its arguments, rebinds the loop locals, and transfers control back to the loop header. In [Clojure's compiler](https://github.com/clojure/clojure/blob/clojure-1.12.6/src/jvm/clojure/lang/Compiler.java#L7156-L7227), that becomes argument evaluation followed by local-variable stores and a jump.
+
+If I can arrange for control-flow edges to become appropriate `recur` forms, I can carry updated values along those edges. Straight-line assignments can become sequential lexical bindings between transfers.
+
+Doing this for a useful subset of Clojure turns out to involve writing a small compiler. Apparently I'm willing to do that.
+
+The result is Blacktide's `loophole` macro. This walkthrough uses the compiler that's in the repository, including its intermediate data and generated code.
+
+## Follow along
+
+From a checkout, with Clojure 1.12.6 and JDK 17 or later:
+
+```sh
+clojure -T:build compile-java
+clojure -M -i examples/blacktide/tutorial.clj -r
+```
+
+Then enter the example namespace:
+
+```clojure
+(in-ns 'blacktide.tutorial)
+```
+
+The [companion file](../examples/blacktide/tutorial.clj) imports the public macros and provides two inspection functions:
+
+| Function | What it does |
+| --- | --- |
+| `stages` | Runs the internal pipeline on a quoted, closed `loophole` form in the current namespace. Returns the bound tree, ordered tree, graph, allocated machine, dispatcher code and normal compiler output. |
+| `readable` | Produces a display-only projection: generated locals become `v0`, `v1`, etc.; expression source/type wrappers are omitted and local descriptors become their IDs. |
+
+The compiler represents the trees and graphs below with ordinary Clojure data, mostly maps and vectors. These are _intermediate representations_, or IRs: data that passes can inspect and transform on the way from source to output. An `:op` key identifies a node's kind; its other fields describe the operands, bindings or destinations that kind needs.
+
+The examples were run with Clojure 1.12.6 on JDK 25. Outputs are pretty-printed; `pprint`'s trailing nil return is omitted. Generated names and internal data can change as the compiler evolves. Evaluate the original stage values, which retain all metadata, rather than copying the display projections back into the compiler.
+
+The helper uses an empty enclosing lexical environment. It is enough for these closed examples; the public macro also receives the caller's `&env`.
+
+## The language
+
+The macro is called `loophole`. It introduces mutable lexical locals, with two assignment forms:
+
+- `setq` evaluates and assigns pairs sequentially, returning the last value.
+- `psetq` evaluates all right-hand sides before writing the targets, in pair order, and returns nil.
+
+Both work in expressions. For example:
+
+```clojure
+(loophole [x 1 y 2]
+  (psetq x y y x)
+  [(setq x (+ x 10)) y])
+;; => [12 1]
+```
+
+There are also two control-flow forms:
+
+- `(label name)` establishes a jump target in the outer statement sequence.
+- `(goto name)` transfers control to an explicitly written label in the same region.
+
+Falling through the body evaluates its final expression. To leave early, write a label and jump to it:
+
+```clojure
+(loophole [i 0 sum 0]
+  (label again)
+  (when (= i 10)
+    (goto done))
+  (setq sum (+ sum i)
+        i (inc i))
+  (goto again)
+  (label done)
+  sum)
+;; => 45
+```
+
+Labels belong to `loophole`; there is no additional body form around them. A jump can leave a nested statement body, but cannot target the inside of a lexical scope. Labels are restricted to the outer statement sequence, including transparent `do` forms. Jumps are forbidden anywhere inside operands or initializers.
+
+The interesting part is that the body can contain ordinary Clojure constructs, including macros like `dotimes`, `when` and `while`. I don't want to implement those again. Their expansions must fit the [supported subset](language.md). Functions, `letfn`, `try` and resource-management forms belong outside the region. Calling an external function from inside it is fine.
+
+## A state machine
+
+If you've used [core.async's `go` macro](https://clojure.org/news/2013/06/28/clojure-clore-async-channels#_go_blocks_and_ioc_threads), you've already used a compiler inside a macro. Its state-machine implementation rewrites the block so a channel operation such as `<!` can park and later resume, preserving the local values and the point where execution should continue.
+
+`loophole` uses the same broad technique: analyze a lexical region, rewrite its control flow, and emit Clojure. Here the purpose is assignment and jumps. `loophole` runs synchronously on the calling thread and returns the body result; `go` returns a channel. Both transformations have to preserve lexical bindings and evaluation order as they rearrange control flow.
+
+One way to compile the body is to turn it into a [control-flow graph](https://www.cs.cornell.edu/courses/cs4120/2026sp/notes.html?id=traces), then emit a single `loop` and `case`. The graph's nodes are blocks of straight-line work; its edges describe possible transfers between them. Loop bindings hold the state; another binding, `pc`, identifies the block to execute next.
+
+Before looking at the compiler's output, here is that idea written by hand:
+
+```clojure
+(loop [pc 0 i 0 sum 0]
+  (case pc
+    0 (if (= i 10)
+        (recur 2 i sum)
+        (recur 1 i sum))
+    1 (let [sum (+ sum i)
+            i (inc i)]
+        (recur 0 i sum))
+    2 sum))
+;; => 45
+```
+
+This is ordinary Clojure. The program counter and state are loop bindings. No mutable reference object is needed to hold `sum`.
+
+The current compiler has this dispatcher backend, but it can also recover structured control and emit ordinary lexical bindings and loops. It chooses a backend before emitting Clojure; it doesn't generate a dispatcher and then rewrite that generated source.
+
+The outline is:
+
+```text
+Source analysis: macroexpansion, lexical resolution, JVM facts
+                          ↓
+Assignment and expression normalization
+                          ↓
+Control-flow graph and jump resolution
+                          ↓
+Initialization checks, liveness, state-slot allocation
+                          ↓
+Structured recovery when eligible; dispatcher otherwise
+                          ↓
+Ordinary Clojure → Clojure compiler → JVM
+```
+
+### Small passes, explicit languages
+
+The real [pipeline](compiler.md) follows the [nanopass approach](https://nanopass.org/): many small transformations, with explicit input and output languages. In Clojure terms, a transformation pass is a function from one immutable data representation to another. An analysis pass computes facts about that data. A _language_ specifies which node shapes are allowed at a particular stage and how they can nest.
+
+For example, assignment lowering takes two passes:
+
+| Pass | Input → output language | What the next pass can rely on |
+| --- | --- | --- |
+| Lower sequential assignment | `Classified` → `Sequential` | `:setq` nodes are gone; their pairs have become individual `:write` nodes. |
+| Lower parallel assignment | `Sequential` → `Assigned` | `:psetq` nodes are gone; each batch's right-hand-side values are saved before its pending writes. |
+
+Later passes can work with individual writes without having to understand the two assignment forms again. The [language definitions](../src/com/tailrecursion/blacktide/compiler/languages.clj) record that progression: `Sequential` removes `:setq` and adds `:write`; `Assigned` then removes `:psetq`. These are contracts the machinery can use.
+
+Blacktide's [nanopass library](nanopass.md) generates traversal for matching, unchanged productions and derives Typed Clojure aliases from the language descriptions. Development checks verify pass bodies against those contracts; ordinary macro use does not load the type checker. A pass can still produce a beautifully well-typed wrong answer, so we also need semantic tests.
+
+Andy Keep's [Writing a Nanopass Compiler](https://www.youtube.com/watch?v=Os7FE3J-U5Q), from Clojure/conj 2013, introduces the approach with a small Scheme-to-C compiler. His paper with R. Kent Dybvig, [A Nanopass Framework for Commercial Compiler Development](https://andykeep.com/pubs/np-preprint.pdf), describes the framework and its use in Chez Scheme.
+
+I'll walk through the original `dotimes` example to see what the important steps do.
+
+## 1. Macroexpansion
+
+The companion file defines the quoted input as `sum-form`. Quoting lets us hand it to the compiler without executing it first:
+
+```clojure
+(pprint sum-form)
+```
+
+```text
+(loophole [x 0] (dotimes [i 10] (setq x (+ x i))) x)
+```
+
+We can inspect one ordinary [macro expansion](https://clojure.org/reference/macros) directly:
+
+```clojure
+(pprint (macroexpand-1 '(dotimes [i 10] (setq x (+ x i)))))
+```
+
+```text
+(clojure.core/let
+ [n__6111__auto__ (clojure.core/long 10)]
+ (clojure.core/loop
+  [i 0]
+  (clojure.core/when
+   (clojure.core/< i n__6111__auto__)
+   (setq x (+ x i))
+   (recur (clojure.core/unchecked-inc i)))))
+```
+
+This is one expansion, so `let`, `loop` and `when` are still present. The source pass continues expanding macros as it analyzes their children. It also recognizes Clojure's inline arithmetic expansions, which explains the JVM operations we'll see later.
+
+A naive recursive application of `macroexpand-1` isn't sufficient. A lexical binding can shadow a macro name, and macros may inspect [`&env`](https://clojure.org/reference/macros#_special_variables). Blacktide's source pass expands forms while discovering bindings, in the caller's namespace. It recognizes the DSL markers before they can expand into their outside-region errors. Quoted data stays data.
+
+Macroexpansion and lexical resolution therefore happen together. I'm separating them here to explain the jobs, not describing two independent walks over the source.
+
+## 2. Lexical resolution
+
+You already use lexical scope whenever nested `let` forms bind the same name. _Lexical resolution_ is the compiler working out which binding each use refers to; Bob Nystrom's [Resolving and Binding](https://craftinginterpreters.com/resolving-and-binding.html#static-scope) walks through the problem. Here, we record that answer with unique binding identities.
+
+Run the actual passes and keep their results:
+
+```clojure
+(def compilation (stages sum-form))
+(def bound (readable (:bound compilation)))
+
+[(get-in bound [:bindings 0 :local])
+ (get-in bound [:body :forms 0 :bindings 0 :local])
+ (get-in bound [:body :forms 0 :body :forms 0 :bindings 0 :local])]
+;; => [v0 v1 v3]
+```
+
+These are the three source bindings:
+
+| Display ID | Source binding | Role |
+| --- | --- | --- |
+| `v0` | `x` | Mutable local introduced by `loophole`. |
+| `v1` | `n__6111__auto__` | Immutable limit introduced by `dotimes`. |
+| `v3` | `i` | Loop binding updated by the original `recur`. |
+
+Why no `v2`? The loop itself has an identity too:
+
+```clojure
+(def source-loop (get-in bound [:body :forms 0 :body :forms 0]))
+(pprint (select-keys source-loop [:op :id :bindings]))
+```
+
+```text
+{:op :loop,
+ :id v2,
+ :bindings [{:local v3, :init {:op :literal, :value 0}}]}
+```
+
+The source `recur` records that target explicitly:
+
+```clojure
+(pprint (get-in source-loop [:body :forms 0 :then :forms 1 :target]))
+```
+
+```text
+{:id v2, :bindings [v3]}
+```
+
+This matters because the generated program may have a different enclosing loop. Leaving a source `recur` in flattened code would give it the wrong target. The compiler resolves binding identities and recursion targets before lowering them into graph operations.
+
+The source pass also enforces label placement. Once all labels have been found, a later pass resolves each `goto` to its block. An undefined target is an error, even if the jump is in unreachable source.
+
+## 3. A-normal form
+
+I want assignment to work inside expressions, not just in statement position. Consider:
+
+```clojure
+(loophole [x 0]
+  (+ (do (setq x 10) x)
+     (do (setq x 20) x)))
+;; => 30
+```
+
+The first operand's value must be saved before the second operand changes `x`. A-normalization makes that ordering explicit with temporary bindings. Think of the compiler introducing `let` bindings to name intermediate results. Matt Might's [A-Normalization: Why and How](https://matt.might.net/articles/a-normalization/) gives an approachable account, with Scheme code.
+
+Here is the actual ordered tree for the addition, with the source/type wrappers removed by `readable`:
+
+```clojure
+(def operands
+  (stages '(loophole [x 0]
+             (+ (do (setq x 10) x)
+                (do (setq x 20) x)))))
+(pprint (get-in (readable (:ordered operands)) [:body :forms 0]))
+```
+
+```text
+{:op :let,
+ :bindings
+ [{:local v1,
+   :init
+   {:op :do,
+    :forms
+    [{:op :do,
+      :forms [{:op :write, :target v0, :value {:op :literal, :value 10}}]}
+     {:op :local, :local v0}]}}
+  {:local v2,
+   :init
+   {:op :do,
+    :forms
+    [{:op :do,
+      :forms [{:op :write, :target v0, :value {:op :literal, :value 20}}]}
+     {:op :local, :local v0}]}}],
+ :body
+ {:op :host-call,
+  :operation
+  {:op :static, :class clojure.lang.Numbers, :member add, :tags nil},
+  :args [v1 v2]}}
+```
+
+The sequential assignment pass has already turned `setq` into `:write`. The ordered bindings save 10 in `v1` before evaluating the expression that saves 20 in `v2`. The addition consumes those saved values.
+
+The nested `:do` nodes are real. This is an intermediate representation, before later compaction. Nobody has promised it will be handsome at every stage.
+
+A value saved this way only needs a machine state slot if it must survive a control-flow edge. Two assignments in one straight-line expression do not, by themselves, require two program-counter states.
+
+The classic paper is [The Essence of Compiling with Continuations](https://www.cs.rice.edu/~javaplt/411/24-spring/Readings/essence-retro.pdf#page=3), by Flanagan, Sabry, Duba and Felleisen. The linked copy includes the authors' later retrospective, followed by the original paper.
+
+## 4. Control-flow graph
+
+Now return to the summation. We can ask the actual graph for its entry block and terminators:
+
+```clojure
+(def cfg (:cfg compilation))
+(:entry cfg)
+;; => 0
+
+(pprint
+  (readable
+    (into (sorted-map)
+          (map (fn [[id block]] [id (:term block)]))
+          (:blocks cfg))))
+```
+
+```text
+{0 {:op :jump, :target 1},
+ 1 {:op :branch, :test {:op :local, :id v34}, :yes 2, :no 3},
+ 2 {:op :jump, :target 1},
+ 3 {:op :jump, :target 4},
+ 4 {:op :return, :value {:op :local, :id v47}},
+ 5 {:op :jump, :target 4}}
+```
+
+A block has sequential operations in `:ops` and exactly one terminator in `:term`. The operations include saved operands and conversions, so there are more of them than a hand-drawn algorithm would suggest:
+
+```clojure
+(into (sorted-map)
+      (map (fn [[id block]] [id (count (:ops block))]))
+      (:blocks cfg))
+;; => {0 7, 1 17, 2 23, 3 1, 4 1, 5 1}
+```
+
+| Block | Work | Next step |
+| --- | --- | --- |
+| 0 | Initialize `x`, the `dotimes` limit and `i`. | Jump to 1. |
+| 1 | Read and convert operands; compare `i` with the limit. | True to 2, false to 3. |
+| 2 | Add `i` to `x`, then compute and install the next `i`. | Jump to 1. |
+| 3 | Supply nil as the completed `when` result. | Jump to 4. |
+| 4 | Read and return `x`. | Return. |
+| 5 | Unreachable continuation created after the source `recur`. | Jump to 4. |
+
+```mermaid
+flowchart TD
+    B0["0: initialize"] --> B1{"1: i < limit?"}
+    B1 -->|true| B2["2: update x and i"]
+    B2 --> B1
+    B1 -->|false| B3["3: when result"]
+    B3 --> B4["4: return x"]
+    B5["5: unreachable continuation"] -.-> B4
+```
+
+Block 5 has no incoming edge. The graph retains unreachable blocks so native Clojure syntax and JVM checks are not silently skipped. Being dead is no excuse for sloppy work.
+
+Structured recovery can omit harmless dead copies like this one; it falls back when doing so would lose required validation.
+
+The original `loop*` has become initialization, a header and a backward edge. This loop needs lowering because it assigns the outer `x`. Loops without DSL effects can instead remain native Clojure subtrees.
+
+Notice that updating `x` and implementing the source `recur` both happen in block 2. Assignment doesn't inherently introduce a control-flow branch. Explicit `goto` forms also become edges; they don't need a separate kind of runtime jump.
+
+Nested loops that need lowering become cycles in the graph. Their source nesting is no longer needed by the dispatcher, although reconstructing structured control from the graph is a separate problem.
+
+## 5. Liveness and state slots
+
+A value is live at a block's entry if some path needs that value before replacing it. [Liveness analysis](https://www.cs.cornell.edu/courses/cs4120/2026sp/notes.html?id=livevar) tells the dispatcher which values must survive transitions. Unlike tenure, liveness requires evidence of future usefulness.
+
+```clojure
+(def machine (:machine compilation))
+(pprint
+  (readable
+    (into (sorted-map)
+          (map (fn [[id live]] [id (vec (sort live))]))
+          (:live machine))))
+```
+
+```text
+{0 [],
+ 1 [v0 v1 v3],
+ 2 [v0 v1 v3],
+ 3 [v0],
+ 4 [v0],
+ 5 [v0]}
+```
+
+The allocated state slots are:
+
+```clojure
+(readable (:slots machine))
+;; => [v0 v1 v3]
+```
+
+So `x`, the limit and `i` cross edges. The many operand temporaries used within blocks do not need persistent state slots. Liveness is already part of the compiler, not an optimization left for later. The analysis includes retained unreachable blocks; only reachable paths undergo definite-initialization checks.
+
+One subtle problem concerns ordinary `let` bindings. Their storage may need to survive transitions, but moving their storage must not move their initialization.
+
+Here's a small program that enters the same scope twice:
+
+```clojure
+(loophole [i 0 visits 0]
+  (label again)
+  (let [limit (do (println :enter visits) (+ i 2))]
+    (while (< i limit)
+      (setq i (inc i))))
+  (setq visits (inc visits))
+  (when (< visits 2) (goto again))
+  [i visits])
+```
+
+```text
+:enter 0
+:enter 1
+[4 2]
+```
+
+Each visit evaluates the initializer once, at the original entry point. Hoisting storage is not permission to run an initializer earlier, or to reuse its old value after reentry. Shadowed bindings must also keep distinct identities.
+
+## 6. Code generation
+
+The companion file asks the dispatcher emitter for code even though this example is eligible for structured recovery. That lets us inspect and execute both paths.
+
+The generated dispatcher loop's binding vector is:
+
+```clojure
+(binding [*print-meta* true]
+  (pprint (readable (second (second (:dispatcher compilation))))))
+```
+
+```text
+[pc
+ (^{:param-tags [java.lang.Object]} clojure.lang.RT/box '0)
+ v0
+ nil
+ v1
+ nil
+ v3
+ nil]
+```
+
+The slots start as nil. Block 0 performs the source initializers. The generated `loop*` and `let*` are Clojure special forms, rather than the macros that expand to them.
+
+The actual edge emitter shows what happens on two transitions:
+
+```clojure
+(pprint (readable (emit/edge-form (:slots machine) (:live machine) 1)))
+(pprint (readable (emit/edge-form (:slots machine) (:live machine) 4)))
+```
+
+```text
+(recur 1 v0 v1 v3)
+(recur 4 v0 nil nil)
+```
+
+Every `recur` has the same arity: the PC plus all three slots. Values that are not live at the destination are replaced by nil. Within a block, operations become sequential `let*` bindings.
+
+To see the complete dispatcher, evaluate `(pprint (readable (:dispatcher compilation)))`. It is considerably longer than the handwritten machine. It also preserves the source's JVM conversions and value semantics instead of assuming everything is a primitive long. I have achieved this without the assistance of a committee.
+
+Now execute the original emitted form:
+
+```clojure
+(eval (:dispatcher compilation))
+;; => 45
+```
+
+The mutable local is represented by lexical bindings and a loop parameter. The macro introduces no mutable cell for `x`. Arithmetic may still allocate boxed values; this is not a general allocation-free guarantee.
+
+## 7. Recovering a loop
+
+The normal compiler output for this example is more interesting. Here it is with symbol [metadata](https://clojure.org/reference/metadata) visible, since those hints participate in compilation:
+
+```clojure
+(binding [*print-meta* true]
+  (pprint (readable (:code compilation))))
+```
+
+```text
+(^{:param-tags [java.lang.Object]} clojure.lang.RT/box
+ (let*
+  [v0
+   (^{:param-tags [java.lang.Object]} clojure.lang.RT/box '0)
+   v25
+   (clojure.lang.RT/longCast '10)]
+  (loop*
+   [v0
+    (^{:param-tags [java.lang.Object]} clojure.lang.RT/box v0)
+    v3
+    '0]
+   (if
+    (clojure.lang.Numbers/lt v3 v25)
+    (let*
+     [v41
+      (^{:param-tags [java.lang.Object]} clojure.lang.RT/box
+       (clojure.lang.Numbers/add ^java.lang.Object v0 v3))]
+     (recur v41 (clojure.lang.Numbers/unchecked_inc v3)))
+    v0))))
+```
+
+There is no program counter or dispatch. The limit lives outside the loop; the loop carries the accumulator and index. All `recur` arguments are evaluated before either loop binding is rebound.
+
+This is actual output, not the ideal form I'd like the compiler to produce. The casts and Object boundaries are still there. In particular, [`RT.box(Object)`](https://github.com/clojure/clojure/blob/clojure-1.12.6/src/jvm/clojure/lang/RT.java#L1037-L1039) is an identity operation used to preserve an inference boundary; its appearance does not itself imply allocation. The accumulator keeps reference semantics, while the index can use primitive storage.
+
+The backend's main jobs are:
+
+| Pass group | Purpose |
+| --- | --- |
+| Feasibility and value facts | Fold literal branches and determine conservative value representations. |
+| Join planning and structured recovery | Recover acyclic control or a single-header loop, subject to validation and duplication limits. |
+| Invariants and storage | Move safe loop invariants out and select proven primitive storage. |
+| Compaction and emission | Remove safe intermediates and emit lexical Clojure. |
+
+Graphs outside the structured backend's supported shapes use the dispatcher. A graph with nested cycles does not automatically get reconstructed as nested Clojure loops.
+
+Finally, compare the two emissions with the public macro and an ordinary Clojure reference:
+
+```clojure
+[(reduce + (range 10))
+ (eval sum-form)
+ (eval (:dispatcher compilation))
+ (eval (:code compilation))]
+;; => [45 45 45 45]
+```
+
+Which means the compiler has converted a perfectly reasonable four-line `dotimes` into a control-flow graph, computed liveness, allocated state slots, and recovered a perfectly reasonable loop before emitting it.
+
+All to avoid manually writing `loop` and `recur`.
+
+## Why bother?
+
+The example is intentionally simple. It demonstrates the transformation, not the motivation.
+
+The algorithms I'm interested in have less regular control flow: binary parsers, codecs, in-place array algorithms, and algorithms with several nested loops or early exits. Translating them into Clojure can mean manually threading updated values through several levels of `loop` and `recur`.
+
+That can obscure an otherwise straightforward algorithm, particularly when translating existing C or Java code. The attraction of `loophole` is that it can preserve that source control-flow structure while representing the local state with lexical bindings and loop parameters.
+
+I'm not claiming this is more idiomatic Clojure. It plainly isn't. I just think there are places where direct assignment to local variables is useful, and I'd like it available without introducing mutable reference cells.
+
+The performance question is separate. Structured recovery removes dispatch for this example. It doesn't establish a general speedup over ordinary or hand-tuned Clojure. The dispatcher also shares the frontend with the structured backend, so agreement between them is useful evidence, not an independent correctness oracle. The test suite additionally uses ordinary Clojure and independent models.
+
+The boundaries matter. A closure over a mutable location is not generally interchangeable with a closure over one iteration's loop binding. Function construction, including `letfn`, is excluded inside the region. Helpers defined outside it are ordinary Clojure functions.
+
+Likewise, jumps through `try`/`finally` would have to preserve cleanup, and Clojure [doesn't permit `recur` across a `try` boundary](https://github.com/clojure/clojure/blob/clojure-1.12.6/src/jvm/clojure/lang/Compiler.java#L7249-L7253). Exception and resource boundaries stay outside `loophole`; the [language reference](language.md) gives the exact rules. The revolution will respect `finally`.
+
+## Next steps
+
+Try changing the input to use fewer iterations, another accumulator, or an early exit. Run `stages` again, inspect the graph, and evaluate both emitted forms. For less artificial examples, see the [tokenizer, decimal parser and array partition](../examples/blacktide/algorithms.clj).
+
+If you want to change the compiler itself, the [architecture guide](compiler.md) locates the passes and the [nanopass guide](nanopass.md) explains their contracts. The language descriptions and type checks constrain the shapes of intermediate data. Semantic tests still have to establish evaluation order, binding behavior and the meaning of those shapes.
+
+The peculiar thing about the idea is that nothing new is required from Clojure itself. The language already has a mechanism for updating local state. Blacktide lets me use that mechanism without organizing all my source code around it.
+
+I suppose this is the sort of thing that happens when you like Clojure enough to go to considerable trouble to avoid writing Java, but not quite enough to give up mutable locals.
+
+---
+
+Adapted from [the original design sketch](https://gist.github.com/alandipert/0881611c54722eb4737c2b2ba7058077).
diff --git a/examples/blacktide/tutorial.clj b/examples/blacktide/tutorial.clj
new file mode 100644
index 0000000..e6c7d60
--- /dev/null
+++ b/examples/blacktide/tutorial.clj
@@ -0,0 +1,60 @@
+(ns blacktide.tutorial
+  "Inspect the closed examples in doc/tutorial.md using the internal pipeline.
+  Stage values are unchanged; readable produces display-only projections."
+  (:require [clojure.pprint :refer [pprint]]
+            [clojure.walk :as walk]
+            [com.tailrecursion.blacktide :refer [loophole setq psetq label goto]]
+            [com.tailrecursion.blacktide.compiler :as compiler]
+            [com.tailrecursion.blacktide.compiler.passes.source :as source]
+            [com.tailrecursion.blacktide.compiler.passes.boundaries :as boundaries]
+            [com.tailrecursion.blacktide.compiler.passes.source-values :as source-values]
+            [com.tailrecursion.blacktide.compiler.passes.assignment :as assignment]
+            [com.tailrecursion.blacktide.compiler.passes.cases :as cases]
+            [com.tailrecursion.blacktide.compiler.passes.collections :as collections]
+            [com.tailrecursion.blacktide.compiler.passes.prepare :as prepare]
+            [com.tailrecursion.blacktide.compiler.passes.order :as order]
+            [com.tailrecursion.blacktide.compiler.passes.lower :as lower]
+            [com.tailrecursion.blacktide.compiler.passes.transfers :as transfers]
+            [com.tailrecursion.blacktide.compiler.passes.graph :as graph]
+            [com.tailrecursion.blacktide.compiler.passes.emit :as emit]
+            [com.tailrecursion.blacktide.compiler.passes.host-emit :as host]))
+
+(def sum-form
+  '(loophole [x 0]
+     (dotimes [i 10] (setq x (+ x i)))
+     x))
+
+(defn stages [form]
+  (let [ctx {:locals {} :macro-env {}
+             :markers {#'loophole :loophole #'setq :setq #'psetq :psetq #'label :label #'goto :goto}
+             :outer? true :transfer? true :recur nil :statement? true :tail? false
+             :site {:form form :file nil :line nil :column nil}}
+        [supply bound] (source/parse-scope {:prefix "demo" :next 0} ctx true (vec form))
+        classified (boundaries/classify bound)
+        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)]
+    {:bound bound :ordered ordered :cfg cfg :machine machine
+     :dispatcher (host/box-form (emit/program-form machine))
+     :code (host/box-form (compiler/compile-program machine))}))
+
+(defn readable [value]
+  (walk/postwalk
+    (fn [x]
+      (let [fields (when (map? x) (set (keys x)))]
+        (cond
+          (symbol? x)
+          (if-let [[_ n] (re-matches #"demo__(\d+)__.*" (str x))]
+            (with-meta (symbol (str "v" n)) (meta x))
+            (if (re-matches #"pc__\d+" (str x)) 'pc x))
+
+          (and fields (every? fields [:site :jvm :node])) (:node x)
+          (and fields (every? fields [:id :mutable? :external?])) (:id x)
+          :else x))) value))
diff --git a/test/com/tailrecursion/blacktide/backend_test.clj b/test/com/tailrecursion/blacktide/backend_test.clj
index 19e0309..746e463 100644
--- a/test/com/tailrecursion/blacktide/backend_test.clj
+++ b/test/com/tailrecursion/blacktide/backend_test.clj
@@ -1,6 +1,7 @@
 (ns com.tailrecursion.blacktide.backend-test
   "Backend decisions, primitive storage, reference identity and conversion order."
   (:require [clojure.test :refer [deftest is]]
+            [blacktide.tutorial :as tutorial]
             [clojure.test.check :as tc]
             [clojure.test.check.generators :as gen]
             [clojure.test.check.properties :as prop]
@@ -18,6 +19,23 @@
             [com.tailrecursion.blacktide.compiler.passes.lower :as lower]
             [com.tailrecursion.blacktide.machine-test :as m]))
 
+(deftest tutorial-pipeline
+  (binding [*ns* (the-ns 'blacktide.tutorial)]
+    (doseq [[form expected trace]
+            [[tutorial/sum-form 45 ""]
+             ['(loophole [x 0] (+ (do (setq x 10) x) (do (setq x 20) x))) 30 ""]
+             ['(loophole [i 0 visits 0]
+                 (label again)
+                 (let [limit (do (println :enter visits) (+ i 2))]
+                   (while (< i limit) (setq i (inc i))))
+                 (setq visits (inc visits))
+                 (when (< visits 2) (goto again))
+                 [i visits]) [4 2] ":enter 0\n:enter 1\n"]]]
+      (let [{:keys [dispatcher code]} (tutorial/stages form)]
+        (doseq [compiled [form dispatcher code]]
+          (let [out (java.io.StringWriter.) value (binding [*out* out] (eval compiled))]
+            (is (= [expected trace] [value (str out)]) (pr-str form))))))))
+
 (defn compile-program [form mode]
   (binding [*ns* (the-ns 'com.tailrecursion.blacktide.backend-test)]
     (with-redefs [compiler/compile-program (if (= mode :dispatcher)