rewrite design
Design goal
rewrite turns a local rule, a partial function that rewrites a term at
its root, into global, auditable reduction: one step at a chosen position,
with a record of where and by which rule, and bounded repetition of such
steps. All operational semantics of the library (the eval
strategies, the lambda calculus, the De Bruijn reducer of
debruijn) are defined as single-step functions of this shape,
so that every normalizer can be explained step by step.
Mathematical background
Abstract rewriting systems
An abstract rewriting system is a set with a relation .11 Terese, Term Rewriting Systems, Cambridge University Press 2003, chapter 1. Write for its reflexive transitive closure and for its equivalence closure.
- is a normal form if there is no with .
- is terminating (strongly normalizing) if there is no infinite chain , and weakly normalizing if every element reduces to some normal form.
- is confluent if implies for some , and locally confluent if this holds for one-step forks .
Confluence makes normal forms unique: if and with both normal, a common reduct must equal both. Newman’s lemma states that a terminating, locally confluent system is confluent; local confluence of a term rewriting system follows from the joinability of its critical pairs (Knuth–Bendix).22 M. H. A. Newman, “On theories with a combinatorial definition of equivalence”, Annals of Mathematics 43, 1942.
Positions and contexts
A position is a path of frames from the root: BinderBody enters
, ApplyHead enters , ApplyArgument(i)
enters the -th argument. Write for the subterm at and
for with that subterm replaced by :
The rewrite relation of a rule
A rule is a partial function . A redex of is a term in its domain. The rewrite relation of is its closure under contexts:
A strategy is a partial function with
whenever it is defined; it chooses one of the possible steps. The traversal
functions of this package are strategies, and StepResult makes the choice
observable.
Design decisions
The single step is the primitive
Problem. A normalizer that rewrites “everything it can” in one pass is fast, but its behaviour cannot be compared with a specification, and its intermediate states are lost.
Choice. The primitive is one step at one position, returned as
Reduced(before, after, rule, path) or NoStep. Its contract, checked by
construction in the implementation, is
so each reported step is a step of at exactly the reported position.
The traversals build after by rebuilding only the nodes on the path, and
build path by prepending one frame per level on the way back up. Normalizers
and traces are loops over this primitive and add nothing semantic. The cost of
the design is that a full normalization repeats a traversal from the root for
every step; the benefit is that every normalizer has a trace, and that two
implementations can be compared step by step.
Strategies as traversal orders
top_down_once tries the rule at a node before its children, children in the
order head, argument 0, argument 1, … It returns the first redex in
pre-order, which is the leftmost-outermost redex: no redex contains it, and
among such redexes it is the leftmost. bottom_up_once tries children first
and returns the first redex in post-order, the leftmost-innermost redex: it
contains no other redex.
Lemma (normal forms). For both traversals, NoStep on iff is a
normal form of .
Both traversals visit every position of (they enter binder bodies, the
head and all arguments) and try there. They return NoStep only after
every attempt returned None, so no position is a redex; conversely, if some
position is a redex, the traversal reaches it unless it returns earlier with
another step.
Hence normalize with either traversal returns NormalForm(t, n) only when
is -normal. Other strategies, such as weak-head reduction in
eval, visit fewer positions; for them NoStep only means “no
redex at a position the strategy considers”.
Bounded repetition with an exact count
normalize(t, step, k) computes the sequence ,
and stops at the first
with or at :
The extra call at distinguishes “normal after exactly steps” from “limit reached”, so the result never claims a normal form that it has not checked. A step limit is necessary because termination is not decidable for arbitrary rules (and fails for the untyped lambda calculus); it is a parameter, not a global setting.
Rules are named, not registered
Problem. Rewriting systems usually have several rules.
Options. A rule registry inside the package; a list of rules per call; one rule function per call.
Choice. One rule function with one RuleName per call. A system with
several rules is either combined into one rule (as beta_eta_rule in
lambda, which tries beta before eta at each position), or
expressed as a step function that tries several single-rule traversals in
turn. The two choices give different strategies: the first picks the
outermost position at which any rule applies, the second prefers the first
rule anywhere in the term. Keeping that choice with the caller avoids fixing
one priority scheme for every language.
Generic traversal through the view
generic_top_down_once is top_down_once with project for pattern
matching and the trait constructors for rebuilding. It needs no knowledge of
the downstream AST beyond the view laws, so a polynomial or
numeric-expression AST gets positions and traces for free. Only the top-down
strategy is provided generically, because it is the one downstream
simplifiers use and because it makes the normal-form lemma available.
Correctness / invariants
- One step rewrites at most one position, and the reported
pathaddresses it inbefore(contract above). Tested by “structured step records rule and root path” and the path tests of debruijn. NoStepfromtop_down_once,bottom_up_onceorgeneric_top_down_oncemeans the term is normal for the rule (normal-form lemma).normalizeandtraceperform at mostmax_stepssteps and callstepat mostmax_steps + 1times;NormalForm(t, n)impliesstep(t) = NoStep.- In a
ReductionTrace,steps[0].before = initial,steps[i].after = steps[i+1].before, and the final term ofresultis the lastafter(orinitialwhen there are no steps).
What is not checked. The package does not decide termination or confluence of a rule. When the rule is confluent, every terminating strategy reaches the same normal form; when it is not, different strategies can legitimately return different normal forms, and the trace shows why.
Cost: one step costs rule attempts for a term of size , plus rebuilding the path; normalizing in steps costs rule attempts.
Alternatives rejected
- In-place or memoized rewriting. Faster, but loses
beforeand the path, and conflicts with immutable terms. - A fixed rule language (patterns with variables). A pattern language would need matching and an occurs check; a rule as a MoonBit function can use pattern matching of the host language and any side condition.
- Capture-aware traversal. The traversal could rename binders before passing an open subterm to a rule. Rules that need binding-aware behaviour use substitution, which already renames; renaming in the traversal would change binder names behind the rule’s back.
Boundaries
- Rules see subterms under binders as open terms; the traversal neither renames nor reports which binders are in scope. A rule must be correct on open terms.
- No matching modulo associativity, commutativity or alpha-equivalence; a rule matches with MoonBit patterns on the term as it is.
- No termination, confluence or critical-pair analysis.
- Generic versions exist only for the top-down strategy and for normalization without a trace.
- The bound
max_stepscounts steps, not time or memory.