Skip to content

CXL: a staged language

A clinker config can contain expressions: coalesce(email, "n/a"), age > 18, a computed column. Each runs on every record, millions of times. That puts two demands in tension. A typo or a nonsense comparison (a_string > a_date) must be caught before the job starts, so a three-hour run doesn’t die at row nine million. And evaluation must be fast per record. CXL, Clinker’s expression language, meets both by being a staged language: it separates “understand the formula” from “run the formula,” and does the understanding exactly once. This is the whole of Planning & Expressions in one subsystem, so it’s a fitting place to finish.

  • Name CXL’s three teaching stages (parse, typecheck, eval) and the distinct Rust type each one produces.
  • Trace a formula like price + 10 through those stages and say what each boundary type proves about the stage before it.
  • Explain why typechecking runs once at plan time before any record, and what makes that possible (the closed Value/Type set).
  • Distinguish “compile once to closures” from a tree-walking interpreter, and describe what per-record evaluation actually does.
  • Write a small compile-once-to-closures evaluator in plain Rust that mirrors CompiledExpr.

New terms in this lesson (each is also expanded inline at the point you first need it):

  • CXL: Clinker’s expression language; here, seen as a staged language.
  • staged language: a language processed in distinct, ordered phases (parse → typecheck → eval), where each phase’s output is a separate type that the compiler will not let you skip past.
  • parse / typecheck / eval: the three stages. Turn text into a tree (parse), assign every node a type and reject nonsense (typecheck), then run the tree against data (eval).
  • expression (AST): a formula’s structure as a tree of Expr nodes (an abstract syntax tree), not its source text.
  • Value: the closed set of cell kinds a CXL Type mirrors (from The Value cell).

A formula moves through CXL as a sequence of stages, each producing a distinct type:

"price + 10" ──parse──▶ Program ──typecheck──▶ TypedProgram ──compile──▶ CompiledProgram
(an AST) (AST + types) (closures)

Those aren’t just phases in a function; they’re different Rust types, and the compiler enforces the order. You cannot hand an un-typechecked Program to the evaluator, because the evaluator’s input type is TypedProgram, which only the typechecker produces. It’s the same proof-handle idea as ValidatedPath (Newtype proof tokens) and CompiledPlan (The plan/runtime boundary): each stage’s output type is evidence that the stage ran.

Parsing turns the formula text into an abstract syntax tree: a tree of Expr nodes. This is the closed-enum story from Data & Representation, now describing a language rather than a value:

cxl ·ast.rs ·Expr type @19acdcb4
/// CXL expression — the core of the language. All variants carry a NodeId and Span.
pub enum Expr {
Binary { op: BinOp, lhs: Box<Expr>, rhs: Box<Expr>, /* … */ },
Unary { op: UnaryOp, operand: Box<Expr>, /* … */ },
Literal { value: LiteralValue, /* … */ },
FieldRef { name: Box<str>, /* … */ },
MethodCall { receiver: Box<Expr>, method: Box<str>, args: Vec<Expr>, /* … */ },
Coalesce { lhs: Box<Expr>, rhs: Box<Expr>, /* … */ },
// ... ~20 variants in all
}

Two Data & Representation ideas show up at once. It’s a closed enum, a fixed set of expression shapes, so every later stage can match exhaustively and the compiler guarantees no shape is forgotten (Reading a value with match). And the children are Box<Expr>: a tree is recursive, and a recursive type needs the indirection a Box provides, or its size would be infinite (the Value-cell boxing, smart pointers). price + 10 parses to Binary { op: Add, lhs: FieldRef("price"), rhs: Literal(10) }, a little tree. Every node also carries a Span, so a later error can point back at the source (Span-preserving parse).

Stage 2: typecheck, before any record runs

Section titled “Stage 2: typecheck, before any record runs”

Now the formula has structure but no guarantees. Typechecking walks the AST against the record schema and assigns every node a type, drawn from a closed set that mirrors the nine Value shapes:

cxl ·types.rs ·Type type @19acdcb4
pub enum Type {
Null, Bool, Int, Float, String, Date, DateTime, Array, Map,
Numeric, // the int/float union
Any, // unknown
Nullable(Box<Type>),
}

The typechecker’s signature is the pedagogical crux. On success it yields a TypedProgram; on failure, a list of diagnostics and nothing runnable:

cxl ·pass.rs ·type_check fn @19acdcb4
pub fn type_check(
resolved: ResolvedProgram,
schema: &Row,
) -> Result<TypedProgram, Vec<TypeDiagnostic>> { /* … */ }

Read what the Result guarantees. If your formula compares a String to a Date, type_check returns Err(...). No TypedProgram is ever built. And since the evaluator only accepts a TypedProgram, evaluation of a type-incorrect formula is literally unreachable: not “checked again at run time,” but unrepresentable. That’s how the closed Value set you met in the Value-cell lesson pays off: because the shapes are fixed and known, a formula over them can be fully typechecked at plan time, and a type error fails the job before record one, exactly the “validate once at the boundary” discipline of the plan/runtime boundary, now for expressions.

Stage 3: compile once to closures, evaluate per record

Section titled “Stage 3: compile once to closures, evaluate per record”

The TypedProgram is correct but still a tree, and walking a tree, re-matching every Expr variant, on every record, would be slow. So CXL does what the plan/runtime boundary did for the whole plan, one level down: it lowers the typed AST once into a tree of closures, then runs that per record. The module doc names the strategy outright: “Compile-once-to-closures evaluator.”

cxl ·compiled.rs ·CompiledProgram type @19acdcb4
// each lowered node IS a closure
type CompiledExpr<S> =
Box<dyn Fn(&mut Frame<S>) -> Result<Value, EvalError> + Send + Sync>;
pub(crate) struct CompiledProgram<S: RecordStorage + 'static> {
statements: Vec<CompiledStmt<S>>,
}
// lowered exactly once, then reused across every record (and every thread)
pub(crate) fn compile<S>(typed: &TypedProgram) -> CompiledProgram<S> { /* lower each stmt once */ }

Each AST node becomes a boxed closure that captures its already-compiled children by value. A literal bakes its Value once at lowering instead of re-reading the AST per record; a field reference captures its resolved name. Per-record evaluation is then just calling closures: a sequence of direct Fn calls with no central dispatch match and no re-indexing of the AST. Because the compiled program is immutable, one copy is shared across records and threads (the per-record bookkeeping lives in a separate state value the caller threads in). This is the same generic S: RecordStorage from One reader, every format: the compiled closures are monomorphized over the storage, so field reads stay zero-cost.

Now you build the staged evaluator yourself, the eval stage in miniature, scaffolded from a fully-worked version down to one you write from a skeleton. Each rung compiles on std alone and mirrors CompiledExpr: each node becomes a closure capturing its already-compiled children. The one new idea here is compile once, call many; the rest is the closed-enum match you already command, so the scaffolding fades fast.

Parse → an AST, compile-once → closures, evaluate per record. Run it and watch compile execute a single time while the per-record loop just calls the result:

rust // editable

compile runs once and returns a closure tree; the per-record loop just calls it. The recursion is the staging: compiling an Add compiles its children first, captures those compiled closures by value, and hands back a closure that calls them. By the time main reaches the loop, every match and every child lookup is already done.

Same evaluator, but the Add arm is left for you. Both children are already compiled into lc and rc; your job is the closure that runs per record. It must capture lc and rc and, given a record, return their sum. The other two arms are done; fill the one gap so the formula price + 10 prints 110, 260, 17.

rust // editable
💡 Hint 1
The compiling work is done: lc and rc are closures. You only need the per-record step. Box::new(move |rec| ...) captures both by value; inside, call them: lc(rec) and rc(rec) each return an i64.
Show solution
Expr::Add(l, r) => {
let lc = compile(*l);
let rc = compile(*r);
Box::new(move |rec| lc(rec) + rc(rec)) // compiled once; this closure is the per-record step
}

Notice the split: compile(*l) and compile(*r) run once, at lowering time; the move |rec| lc(rec) + rc(rec) body runs once per record. That boundary, expensive structure work up front and cheap calls per record, is the staged-evaluation model in three lines.

Now with much less scaffolding. Extend the language with subtraction. Add a Sub variant to Expr, then add its arm to compile so it lowers exactly like Add but subtracts. The formula below is price - 10, so the records 100, 250, 7 should print 90, 240, -3.

rust // editable
💡 Hint 1
A Sub node holds the same two boxed children as Add: Sub(Box<Expr>, Box<Expr>). Its compile arm is the Add arm with + swapped for -. Because Expr is a closed enum, once you add the variant the match will refuse to compile until you handle it: the exhaustiveness check from the reading-a-value lesson telling you which arm you owe.
Show solution
enum Expr {
Lit(i64),
Field(String),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
}
// ...in compile's match:
Expr::Sub(l, r) => {
let lc = compile(*l);
let rc = compile(*r);
Box::new(move |rec| lc(rec) - rc(rec))
}

You just extended the language by one operator and felt the real evaluator’s shape: one lowering pass per node, then cheap repeated calls. Adding the variant forced a new match arm, and the closed enum makes “you forgot to handle Sub” a compile error, not a runtime surprise. (CXL also has language-level closures, it => body for array operations like filter/map, which lower to a host loop over a separately-compiled body. Same mechanism, exposed to the user.)

// quick check

Why can a type-incorrect CXL formula never be evaluated at run time?

That closes Planning & Expressions. You’ve seen the engine’s typed proof handles (ValidatedPath, the strict/spanned parse, CompiledPlan), its failure taxonomy (PipelineError, abort versus quarantine), and now the staged language that runs over every record. A single thread runs through all of it: push the work and the proof to plan time, and let the type system carry the guarantee into run time. Execution & Memory descends into that run time: how the DAG executor dispatches nodes, moves records across threads, and stays inside a memory budget.

Go deeper on the Rust (optional; for the same concept taught from first principles in The Rust Book):

Glossary terms used: CXL, Value.