From YAML to a plan
You wrote customer_etl.yaml: a few nodes and some CXL. But YAML is just text. In
reading a plan with --explain, --explain printed an execution
plan instead. Something turned your text into that. This lesson follows that
transformation, shallowly. The deep version is Planning & Expressions.
You’ll build a miniature of the transformation in code so you can predict
its shape rather than memorize it.
What you’ll be able to do
Section titled “What you’ll be able to do”- Name the steps that turn YAML into a runnable plan, in order.
- Trace a single value from source text to the plan, and say what
Spannedadds along the way. - Predict which stage rejects each kind of mistake: a cycle, an unknown column, a nonsense expression.
- Explain why Clinker compiles to a
CompiledPlanbefore it runs, in terms of what the runtime is handed.
New terms in this lesson (each is also expanded inline at the point you first need it):
- node: one step in the pipeline graph; a
type:in yournodes:list. - DAG: a directed acyclic graph: nodes wired by edges, with no path that loops back on itself. The plan’s nodes form one.
- validation: the stage that checks the config is well-formed, before any data moves: no cycles, every input exists, paths are allowed.
Spanned: a wrapper that pairs a parsed value with where it came from in the source (line & column), so an error can point at the exact spot.
Predict first
Section titled “Predict first”The journey from text to plan
Section titled “The journey from text to plan”Before a single record moves, your YAML goes through a pipeline of its own:
YAML text │ parse (span-aware — every value remembers its line & column) ▼config (typed nodes, still just "what you asked for") │ validate (no cycles? all inputs exist? paths allowed?) ▼ │ typecheck the CXL (does `lifetime_value.to_int()` make sense?) ▼ │ lower to a graph ▼CompiledPlan ──► handed to the runtimeEach arrow is a stage that can reject your pipeline, and each rejects a different class of mistake:
- parse catches malformed YAML: a bad indent, an unterminated string.
- validate catches structural mistakes: a cycle in the graph, a node that reads an input no other node produces, a path you’re not allowed to write.
- typecheck catches nonsense expressions, like
to_int()on something that can never be an integer. - lower turns the validated config into the execution graph the runtime understands.
The parse step is span-aware: each parsed value carries its source location, so a
mistake can be reported with a precise line and column rather than a vague “something’s
wrong.” That location-carrying wrapper is Spanned:
clinker-plan ·yaml.rs ·Spanned type @19acdcb4
pub struct Spanned<T> { pub value: T, pub referenced: Location, // where this value is used in the source pub defined: Location, // where it was defined (e.g. a YAML anchor)}The output is a CompiledPlan
Section titled “The output is a CompiledPlan”Everything upstream converges on one typed artifact, the CompiledPlan. It bundles the
lowered graph, the validated config, and the typechecked CXL:
clinker-plan ·compiled.rs ·CompiledPlan type @19acdcb4
pub struct CompiledPlan { dag: ExecutionPlanDag, // the lowered execution graph config: PipelineConfig, // the validated configuration // …plus the validated compile outputs the runtime reads (typechecked CXL, bound schemas) // ...}When you ran --explain, you saw the engine do all of this (parse, validate,
typecheck, lower) and then print the result instead of executing. The execution
plan you read was this CompiledPlan, rendered as text. Its dag field holds the
nodes
you counted in that output (DAG nodes: 4), now wired into a graph with no cycles: a DAG.
Why plan first?
Section titled “Why plan first?”Two reasons, both of which you can already feel:
- Errors surface before any data moves. If a column name is misspelled or a CXL expression is nonsense, you find out at plan time, not halfway through a million-row file with a half-written output.
- The runtime gets a proof, not a wish. The executor never receives raw YAML.
It only ever accepts a
CompiledPlan, an artifact that, by existing, proves the pipeline is well-formed. The boundary between “planning” and “running” is one of Clinker’s defining design decisions; we open it up in Planning & Expressions.
On the Rust side, this whole stage runs on
Result:
parsing, validation, and typechecking each return success or a typed error rather than
throwing. Errors are values you handle, and they roll up into one error vocabulary
(PipelineError, covered in the error-handling lesson).
Build the pipeline in miniature
Section titled “Build the pipeline in miniature”You’ll understand the transformation best by building a small version of it. Each rung below is a self-contained, runnable Rust program. We stage one new idea at a time: first a span that travels with a value, then the stages as fallible steps, then the whole compile as a function you write.
Worked: a value that remembers where it came from
Section titled “Worked: a value that remembers where it came from”The first thing parse does is wrap every value in its source location, so a later stage
can blame the right line. Here is Spanned in miniature: a value paired with a Location,
and a parse step that attaches one. Run it and watch the location ride along.
> output appears here — press Run
The takeaway: the span is not metadata stored off to one side. It is glued to the value by the type. Every stage downstream still has it, so a validation error three steps later can still point at line 7.
Completion: validate the graph
Section titled “Completion: validate the graph”Now the validation stage. Below, a config is a list of nodes, each naming the input it reads. Validation must reject a node whose input no other node produces, the exact mistake from the Predict-first opener. Two checks are written; complete the third arm that flags an unknown input.
> output appears here — press Run
💡 Hint 1
if !produced branch, so you already know the input is missing. Return an Err(String) naming both node.name and input. That’s the precise message a span would let the real engine attach to a line.Show solution
return Err(format!( "node {:?} reads {:?}, but no node produces it", node.name, input,));This is validation in one line of judgment: walk the declared graph, and the moment a node
reads something nothing produces, refuse before a record exists. The real engine reports
the same class of error, plus the line number that the Spanned wrapper carried this far.
Faded: compile the whole thing
Section titled “Faded: compile the whole thing”Your turn with the stages wired together. Write compile, which runs the journey from the
diagram: parse → validate → lower, returning Result<CompiledPlan, String>. Each helper
already returns a Result; chain them so that any stage failing aborts the compile with
its error, and only an all-green run produces a plan. (The ? operator does exactly this
early-return-on-Err; if it’s new, the match form works too.)
> output appears here — press Run
Show solution
fn compile(src: &str) -> Result<CompiledPlan, String> { let nodes = parse(src)?; // parse fails -> return its Err here validate(&nodes)?; // validate fails -> return its Err here Ok(lower(&nodes)) // both passed -> lower can't fail; build the plan}That’s the engine’s spine in three lines: each stage is a fallible Result-returning step,
? short-circuits on the first failure, and a CompiledPlan is produced only when every
stage succeeded. The real pipeline adds a typecheck stage and far richer types, but the
shape (sequence fallible stages, emit a proof at the end) is exactly this.
What the compiler enforces
Section titled “What the compiler enforces”Read the cited CompiledPlan again with the ladder behind you. Notice what its existence
guarantees: the runtime’s entry point takes a CompiledPlan, not a string of YAML. So a
caller cannot hand the executor an unvalidated pipeline. There is no constructor for
CompiledPlan that skips the stages. The type is the proof.
What a junior might misread: thinking --explain is “just a linter” that scans syntax.
It runs the entire compile (parse, validate, typecheck, lower), and the plan it prints is
the genuine CompiledPlan the runtime would consume. The only thing it skips is execution.
Why-bridge: the plan is a boundary, not a formality
Section titled “Why-bridge: the plan is a boundary, not a formality”Why does the engine bother producing a separate, typed CompiledPlan at all, instead of
interpreting the YAML as it goes? Because the plan is a boundary: everything that can be
checked statically is checked once, up front, and the result is an artifact that carries
that guarantee forward. The runtime never re-validates, never re-parses; it trusts the
CompiledPlan because nothing else can construct one.
That is why a broken column reference fails at --explain time and not at row one million:
the failure happens in the stage that can see the whole graph, before the boundary, while
there’s still nothing to clean up.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You can now name the stages that turn YAML into a plan, trace a value through them with its span, predict which stage rejects which mistake, and build a miniature compile that emits a plan only when every stage passes. Next: how a record actually travels through the graph that plan describes.
Go deeper on the Rust (optional, one-directional; the same Result/? mechanics taught
from first principles in The Rust Book):
Glossary terms used: node, Result, record.