Formal argument for termination of the simplification transpiler.
Status: Design Documentation Last Updated: 2025-12-06
This document provides a formal argument that the simplification transpiler always terminates. Termination is critical for a production system - we must guarantee that simplification completes in bounded time.
The termination argument relies on three mechanisms:
Define a measure function $M: \text{Proc} \to (\mathbb{N} \times \mathbb{N} \times \mathbb{N} )$ that maps terms to lexicographically ordered triples:
M(term) = (size(term), depth(term), complexity(term))
Where:
size(term): Number of AST nodesdepth(term): Maximum nesting depthcomplexity(term): Structural complexity measureTriples are compared lexicographically:
(a₁, b₁, c₁) < (a₂, b₂, c₂) iff
a₁ < a₂, or
(a₁ = a₂ and b₁ < b₂), or
(a₁ = a₂ and b₁ = b₂ and c₁ < c₂)
This ordering is well-founded (no infinite descending chains) because $\mathbb{N}$ is well-founded.
These rules strictly decrease the size component:
| Rule | Pattern | Template | Size Change |
|---|---|---|---|
| add-zero-right | x + 0 | x | -2 |
| add-zero-left | 0 + x | x | -2 |
| mul-one-right | x * 1 | x | -2 |
| mul-zero | x * 0 | 0 | -n+1 |
| double-neg | --x | x | -2 |
| nil-identity | P \| 0 | P | -2 |
| dead-scope | new x.P (x unused) | P | -2 |
| if-true | if true then P else Q | P | -4-|Q| |
Property: After a size-reducing rule, M(new) < M(old) because the first component decreases.
These rules preserve size but may change depth or complexity:
| Rule | Pattern | Template | Invariant |
|---|---|---|---|
| par-commute | P \| Q | Q \| P | Moves toward canonical order |
| par-assoc | (P\|Q)\|R | P\|(Q\|R) | Reduces left-nesting |
| scope-extrude | new x.(P\|Q) | (new x.P)\|Q | Reduces scope depth |
Property: Size-preserving rules must be shown to eventually enable a size-reducing rule or reach a fixed point.
Rules: Algebraic identities (add-zero, mul-one, double-neg, etc.)
Termination argument:
termination_weight < 0size(term)\text{size}(\text{term}) \in \mathbb{N}$, so cannot decrease forever\therefore$ Phase terminates in at most size(initial) iterationsBound: $\mathcal{O}(n)$ iterations where n = initial term size
Rules: Dead code elimination, constant propagation
Termination argument:
termination_weight < 0 (remove unreachable code)\therefore$ Phase terminatesBound: $\mathcal{O}(n)$ iterations
Rules: Rholang congruence (nil-identity, commute, assoc, scope-extrude)
Termination argument:
This phase is more complex because some rules are size-preserving.
Step 1: Nil-identity rules
P | 0 → P\mathcal{O}(n)$ stepsStep 2: Associativity rules
(P|Q)|R → P|(Q|R)\mathcal{O}(n)$ stepsStep 3: Commutativity rules
P|Q → Q|P when order(P) > order(Q)\mathcal{O}(n^{2})$ steps (like bubble sort)Step 4: Scope extrusion
new x.(P|Q) → (new x.P)|Q when $x \notin \text{FV}(Q)$depth(term) times\mathcal{O}(n \times d)$ steps where d = depthCombined bound: $\mathcal{O}(n^{2} + n \times d)$ = $\mathcal{O}(n^{2})$ for Phase 3
Rules: Redundant cast removal, type-aware inlining
Termination argument:
\therefore$ Phase terminatesBound: $\mathcal{O}(n)$ iterations
Proof:
Let T₀ be the initial term with size(T₀) = n.
Phase bounds: Each phase terminates with bound:
\mathcal{O}(n)$\mathcal{O}(n)$\mathcal{O}(n^{2})$\mathcal{O}(n)$Cross-phase reanalysis: When Phase 2 or 4 makes changes, we may re-run earlier phases. However:
\le n$\therefore$ Total reanalysis overhead is $\mathcal{O}(n \times \text{phases})$ = $\mathcal{O}(n)$Cycle detection: The visited set V tracks all seen terms by hash:
T \in V$, we stop (would repeat)|V| \le \text{number} \text{of} \text{distinct} \text{terms} \text{seen}$|V| \le 2^n$ (actually much less)Absolute bound: Configuration provides max_iterations as hard limit
Total complexity: $\mathcal{O}(n^{2} \times p)$ where p = number of phases = $\mathcal{O}(n^{2})$
□
/// Termination measure: (size, depth, structural_complexity)
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct TermMeasure {
size: usize,
depth: usize,
complexity: usize,
}
impl TermMeasure {
pub fn compute(proc: &Proc) -> Self {
Self {
size: proc.size(),
depth: proc.depth(),
complexity: structural_complexity(proc),
}
}
}
/// Size: count of AST nodes
fn size(proc: &Proc) -> usize {
match proc {
Proc::Nil => 1,
Proc::Num(_) | Proc::Bool(_) | Proc::Var(_) => 1,
Proc::Par(p, q) => 1 + size(p) + size(q),
Proc::New(_, body) => 1 + size(body),
Proc::Send(_, data, cont) => 1 + size(data) + size(cont),
Proc::Receive(_, _, body) => 1 + size(body),
// ... other cases
}
}
/// Depth: maximum nesting level
fn depth(proc: &Proc) -> usize {
match proc {
Proc::Nil | Proc::Num(_) | Proc::Bool(_) | Proc::Var(_) => 0,
Proc::Par(p, q) => 1 + depth(p).max(depth(q)),
Proc::New(_, body) => 1 + depth(body),
// ... other cases
}
}
/// Structural complexity: measures deviation from canonical form
fn structural_complexity(proc: &Proc) -> usize {
match proc {
Proc::Par(p, q) => {
let left_par_penalty = if matches!(p.as_ref(), Proc::Par(_, _)) { 1 } else { 0 };
let order_penalty = if canonical_order(p) > canonical_order(q) { 1 } else { 0 };
left_par_penalty + order_penalty + structural_complexity(p) + structural_complexity(q)
}
Proc::New(x, body) => {
// Penalty for scope that could be extruded
let extrusion_penalty = count_extrudable(x, body);
extrusion_penalty + structural_complexity(body)
}
_ => 0
}
}
Each rule must satisfy:
∀ old, new: applies(rule, old, new) ⟹
(weight < 0 ⟹ size(new) < size(old)) ∧
(weight = 0 ⟹ size(new) = size(old) ∧ complexity(new) < complexity(old))
| Rule | Weight | Size Change | Complexity Change | Verified |
|---|---|---|---|---|
| add-zero-right | -1 | -2 | N/A | ✓ |
| add-zero-left | -1 | -2 | N/A | ✓ |
| mul-zero | -2 | -(n-1) | N/A | ✓ |
| double-neg | -1 | -2 | N/A | ✓ |
| nil-identity-right | -1 | -2 | N/A | ✓ |
| nil-identity-left | -1 | -2 | N/A | ✓ |
| par-commute | 0 | 0 | -1 (order penalty) | ✓ |
| par-assoc-right | 0 | 0 | -1 (left-par penalty) | ✓ |
| scope-extrude | 0 | 0 | -1 (extrusion possible) | ✓ |
| dead-scope | -1 | -2 | N/A | ✓ |
(** Term measure type *)
Record Measure := mkMeasure {
size : nat;
depth : nat;
complexity : nat
}.
(** Lexicographic ordering *)
Definition measure_lt (m1 m2 : Measure) : Prop :=
(size m1 < size m2) \/
(size m1 = size m2 /\ depth m1 < depth m2) \/
(size m1 = size m2 /\ depth m1 = depth m2 /\ complexity m1 < complexity m2).
(** Measure is well-founded *)
Theorem measure_wf : well_founded measure_lt.
Proof.
(* Follows from well-foundedness of nat with lexicographic product *)
apply wf_lexprod; apply lt_wf.
Qed.
(** Rule application decreases measure *)
Theorem rule_decreases_measure : forall rule old new,
applies rule old new ->
measure_lt (compute_measure new) (compute_measure old).
Proof.
(* Case analysis on rule type *)
intros rule old new H.
destruct (termination_weight rule) eqn:Hw.
- (* Negative weight: size decreases *)
left. apply size_reducing_rule_decreases_size; auto.
- (* Zero weight: complexity decreases *)
right; right. split; [reflexivity|].
split; [reflexivity|].
apply size_preserving_rule_decreases_complexity; auto.
Qed.
(** Main termination theorem *)
Theorem simplification_terminates : forall initial,
exists final, simplify initial = final.
Proof.
intro initial.
apply well_founded_induction with (R := measure_lt).
- exact measure_wf.
- intros term IH.
destruct (find_applicable_rule term) as [[rule new]|].
+ (* Rule applies: recurse with smaller measure *)
apply IH. apply rule_decreases_measure. auto.
+ (* No rule applies: fixpoint reached *)
exists term. reflexivity.
Qed.
Even with the formal argument, we include practical safeguards:
pub struct TerminationGuard {
/// Maximum iterations (absolute bound)
max_iterations: usize,
/// Current iteration count
iterations: usize,
/// Visited term hashes
visited: HashSet<u64>,
/// Initial term size (for progress tracking)
initial_size: usize,
}
impl TerminationGuard {
pub fn check(&mut self, term: &Proc) -> TerminationCheck {
self.iterations += 1;
// Hard limit
if self.iterations >= self.max_iterations {
return TerminationCheck::Stop(TerminationReason::MaxIterations);
}
// Cycle detection
let hash = term.content_hash();
if self.visited.contains(&hash) {
return TerminationCheck::Stop(TerminationReason::CycleDetected);
}
self.visited.insert(hash);
// Progress check (term should generally shrink)
let current_size = term.size();
if current_size > self.initial_size * 2 {
// Something went wrong - size increased dramatically
return TerminationCheck::Stop(TerminationReason::SizeExplosion);
}
TerminationCheck::Continue
}
}
| Phase | Time Complexity | Space Complexity |
|---|---|---|
| LocalSimplification | $\mathcal{O}(n)$ | $\mathcal{O}(1)$ |
| AnalysisDriven | $\mathcal{O}(n)$ | $\mathcal{O}(n)$ for analysis facts |
| StructuralNormalization | $\mathcal{O}(n^{2})$ | $\mathcal{O}(n)$ for canonical form |
| TypeDirected | $\mathcal{O}(n)$ | $\mathcal{O}(n)$ for types |
| Total | $\mathcal{O}(n^{2})$ | $\mathcal{O}(n)$ |
Where n = size of input term.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |