syntax design
Design goal
syntax gives Luna Flow one definition of “syntax with binders” that is
small enough for any symbolic AST to adopt, yet precise enough to prove the
usual laws of free variables, alpha-equivalence and renaming. It serves two
audiences: the lambda calculus packages, which use the concrete Term[T],
and downstream ASTs, which keep their own types and reach the same
algorithms through BindingSyntax.
Mathematical background
Terms
Fix the set of names of the core design and a set of domain values. Terms are generated by
matching the constructors Value, Variable, Apply and Bind. The binder
is generic: the lambda calculus reads it as , a
polynomial library may read it as a local scope. Values are atoms: they
contain no names.
Free variables and names
is defined by the same equations except
; it is the set
all_names returns, and .
Alpha-equivalence
For write for with every free occurrence of replaced by . Because occurs nowhere in , no occurrence can be captured. Alpha-equivalence is the least congruence on terms such that
All the operations of this library are invariant under , and the lambda calculi are defined on alpha-equivalence classes.11 H. P. Barendregt, The Lambda Calculus: Its Syntax and Semantics, North-Holland 1984, §2.1. The library does not rely on the “variable convention” of that book; every operation renames explicitly when needed.
Design decisions
One generic term type with an opaque payload
Problem. Every symbolic package needs variables and binders, but each has its own constants: numbers, operators, typed constants.
Options. A fixed lambda calculus with an extensible constant type; a separate term type per package; one term type parameterised by the constants.
Choice. Term[T] is parameterised by its constants, and T is opaque:
the algorithms never look inside a Value. This is sound only if constants
contain no variables, which is the stated contract (“T is a closed atom”).
An AST whose constants do contain variables must implement BindingSyntax
instead (next section). Application is n-ary because most symbolic ASTs apply
an operator to several arguments; the lambda calculi read it as a curried
spine.
A view trait instead of a fixed AST
Problem. Downstream repositories already have ASTs. Converting them to
Term[T] and back for every substitution costs time and loses structure.
Choice. BindingSyntax describes a node through a one-layer view. In
categorical terms, define the signature functor
BindingView[N] is , project is a coalgebra
, and variable, apply, bind form an algebra on the three
non-opaque summands. Every generic algorithm is a structural recursion that
calls project, works on the children, and rebuilds with the constructors.
The laws an implementation owes are listed in the
adapter design; for Term[T] they hold by construction, since
project is a bijection between non-Value terms and non-Opaque views.
Consequence. For Term[T], each generic function computes the same
result as its specialised version:
and generic_alpha_rename_bound agrees with Term::alpha_rename_bound. The
proof is a direct induction: in each case the two functions have the same
equation, because project returns exactly the constructor’s fields.
Deciding alpha-equivalence with binder levels
Problem. Deciding directly from the definition requires searching for renamings.
Choice. alpha_equal walks both terms in lockstep with two binder
environments that map each bound name to the level (binder
depth, counting from the root) at which it was bound; a later binding of the
same name shadows an earlier one. Writing for the level of the last
binding of in , and for the current depth:
Theorem. alpha_equal(t, u) holds iff .
Proof sketch. Let be the De Bruijn translation of the debruijn design, which replaces a bound occurrence at depth whose binder sits at level by the index , and keeps free names. Both traversals visit the same positions at the same depth , so for bound occurrences
and free occurrences are compared by name in both. Hence alpha_equal(t, u)
iff . The classical theorem
that two named terms are alpha-equivalent iff their De Bruijn translations are
identical22 N. G. de Bruijn, “Lambda calculus notation with nameless dummies”, Indagationes Mathematicae 34, 1972. completes the proof.
The algorithm runs in time linear in the size of the terms, plus the cost of the environment lookups (linear in the binder depth).
Capture-avoiding renaming of free variables
Problem. Applying a renaming under a binder can capture: renaming in naively gives .
Choice. rename_free implements
with .
Lemma (free variables of a renaming). .
The only interesting case is the binder without freshening. Let with . By induction , so
The second step uses for : either and , which excludes , or . In the freshened case the same computation applies to and , since is outside and so neither a source nor a target. The lemma says exactly that no free variable was captured.
The test is conservative: it also renames the binder when the offending entry’s source does not occur in . The result is still alpha-equivalent to the minimal one.
Checked bound renaming
alpha_rename_bound(t, x, y) computes , the body of the
alpha step above, and returns None unless (or
). Under that side condition the alpha axiom gives directly
The check is stronger than necessary (an occurrence of under an inner binder for would be harmless), but it is cheap and it is all the substitution algorithms need, because they always call it with a name chosen fresh for the body.
Correctness / invariants
- ;
map_valuespreserves both. alpha_equalis reflexive, symmetric and transitive, and coincides with (theorem above). Reflexivity is also checked by a QuickCheck property insrc/syntax/syntax_wbtest.mbt.rename_freesatisfies and is invariant under : alpha-equivalent inputs give alpha-equivalent outputs.alpha_rename_bound(t, x, y) = Some(t')implies .- On
Term[T]everygeneric_*function equals its specialised counterpart.
Cost: free_variables and all_names are linear in the term size (in
hash-set operations). rename_free and alpha_rename_bound are linear when
no binder is renamed; each freshened binder adds a traversal of its body.
Alternatives rejected
- Variables inside values. Letting
Tcontain names would require a traversal trait onTas well. Such ASTs implementBindingSyntaxdirectly, which covers the same need with one trait. - Multi-variable binders. A binder for several names at once is expressed
as nested
Bindnodes. This keeps the view small and the proofs per binder. - Unchecked bound renaming. A version without the
Nonecase would make a capture silent. The checked form makes the precondition visible in the type. - Alpha-equivalence by conversion. Converting both terms to De Bruijn form and comparing would allocate two new terms; the lockstep algorithm is the same comparison without the allocation.
Boundaries
Valuepayloads are never inspected. A payload that contains variables is outside the contract.==on terms is structural, not alpha-equivalence.- No sorts, scopes or types: every name is a term variable of the same kind.
- The package does not check that a
BindingSyntaximplementation obeys its laws; adapter describes how to test them. - Substitution lives in substitution, traversal and rewriting in rewrite.
Footnotes
-
H. P. Barendregt, The Lambda Calculus: Its Syntax and Semantics, North-Holland 1984, §2.1. The library does not rely on the “variable convention” of that book; every operation renames explicitly when needed. ↩
-
N. G. de Bruijn, “Lambda calculus notation with nameless dummies”, Indagationes Mathematicae 34, 1972. ↩