Lexicographic recur Tuple Design
Status: proposed
Date: 2026-02-19 Tracking issue: #1720
Goal
Allow direct Ackermann-style recursion with tuple recur targets, while preserving totality:
def ack(n: Nat, m: Nat) -> Nat:
recur (n, m):
case (Zero, _): Succ(m)
case (Succ(n_prev), Zero): ack(n_prev, Succ(Zero))
case (Succ(n_prev), Succ(m_prev)): ack(n_prev, ack(n, m_prev))
The acceptance rule is lexicographic decrease in the order written in recur (...).
Non-goals
- General size-change termination analysis.
- Arithmetic/semantic reasoning (for example, proving
x - 1 < xoverInt). - User-defined measures.
Current Behavior (Relevant)
DefRecursionCheck currently:
- Allows only one
recurargument position (getRecurIndex). - Requires recursive calls to be structurally smaller only in that one position.
- Does not enforce lexicographic decrease across multiple arguments.
This is why Ackermann currently needs higher-order encoding (ack1 returning Nat -> Nat).
Proposed Language Rule
recur target may be:
- A single name (existing behavior).
- A tuple of names, each name bound directly to a function parameter.
For recur (r0, r1, ..., rk) a recursive call with corresponding call arguments (a0, a1, ..., ak) is legal iff:
- There exists an index
isuch thataiis structurally smaller thanriin the current branch. - For every
j < i,ajis exactlyrj(syntactic equality to the original parameter name). - Arguments
j > iare unrestricted for this specific call.
This is standard lexicographic descent on a well-founded structural order.
Why case (Succ(n_prev), Zero): ack(n_prev, Succ(Zero)) is safe:
- The first component decreases (
n_prev < nstructurally). - Lexicographic order permits any second component once an earlier component decreased.
Checker Design
Data model changes in DefRecursionCheck
Replace the single recursion position with a recursion target vector.
Current:
InDefRecurred(..., group: Int, index: Int, ...)InRecurBranch(..., allowedNames: Set[Bindable])
Proposed:
-
RecurTarget= ordered non-empty vector of parameter positions:NonEmptyList[(groupIndex, argIndex, paramName)] -
InDefRecurred(..., target: RecurTarget, ...) InRecurBranch(..., allowedPerComponent: NonEmptyList[Set[Bindable]])
allowedPerComponent(i) means names proven structurally smaller than target component i in this branch.
Target parsing (getRecurTarget)
Replace getRecurIndex with getRecurTarget:
- If
recurarg isVar(v), resolve exactly as today, target length = 1. - If
recurarg isTupleCons(items), each item must beVar(v)and map to a distinct def parameter position. - Otherwise reject.
Conservative constraints:
- No duplicates in tuple target.
- Every tuple element must resolve to a top-level parameter binding (not a local, not an expression).
Branch analysis
For each branch pattern:
- Single target: keep existing behavior (
pat.substructures). - Tuple target of arity
k: - If branch pattern is a tuple pattern with arity
k, compute component-wise:allowedPerComponent(i) = componentPattern(i).substructures.toSet. -
Otherwise, set all components to empty sets (no provable decrease in this branch).
This is conservative and sound.
Recursive call check
For a recursive call to function f in InRecurBranch:
- Extract call argument at each target position (existing
argsOnDefName+ indexing by group/index). - For each component
i, classify call arg as: Equalif arg is exactlyVar(paramName_i).Decreasesif arg isVar(name)andname โ allowedPerComponent(i).Otherotherwise.- Accept iff some
isatisfies: class(i) == Decreases, and- all earlier classes are
Equal. - Reject otherwise.
Important soundness detail: After validating the outer recursive call, still recurse into all call arguments with checkDecl so nested recursive calls are checked too.
This is required for expressions like: ack(n_prev, ack(n, m_prev)) where the inner recursive call must independently satisfy lexicographic descent.
Error model
Existing errors stay, but tuple-lexicographic mode needs new diagnostics.
New errors
-
RecurTargetInvalidMessage:recur target must be a name or tuple of names bound to function parameters.Use when target contains non-vars, locals, or non-parameter names. -
RecurTargetDuplicateMessage:recur target contains duplicate parameter <name>; each target component must be distinct.Use when tuple target repeats a parameter. -
RecursionNotLexicographicMessage:recursive call to <fn> is not lexicographically smaller on recur target (<t0, ...>).Expected: some component decreases and all earlier components are unchanged.Use when call arguments fail the lexicographic predicate.
Existing errors reused
NotEnoughRecurArgsstill applies if call does not supply enough args to inspect target positions.InvalidRecursion,UnexpectedRecur,IllegalShadow,RecursiveDefNoRecurunchanged.RecursionArgNotVar/RecursionNotSubstructuralbecome effectively superseded for tuple mode; they can remain for single-target compatibility or be folded intoRecursionNotLexicographic.
Examples
Accepted:
ack(n_prev, Succ(Zero))in branch(Succ(n_prev), Zero)(decrease in component 0).ack(n, m_prev)in branch(Succ(n_prev), Succ(m_prev))(component 0 equal, component 1 decreases).ack(n_prev, ack(n, m_prev))because:- outer call decreases component 0;
- inner call decreases component 1 with component 0 equal.
Rejected:
ack(n, Succ(m))in branch(Succ(n_prev), Succ(m_prev))(no component proven to decrease).- Calls that decrease component 1 while component 0 is changed to a non-equal non-decreasing value.
Test plan
Update core/src/test/scala/dev/bosatsu/DefRecursionCheckTest.scala with:
- Positive: direct two-arg Ackermann form with
recur (n, m). - Positive: branch where earlier component decreases and later increases.
- Positive: nested recursive call in later argument (
ack(n_prev, ack(n, m_prev))). - Negative: classic non-terminating “alternate decreases” counterexample.
- Negative: invalid
recurtuple target elements (non-var/non-parameter/local). - Negative: duplicate tuple target names.
- Negative: recursive call that is not lexicographically smaller.
- Regression: existing single-argument recursion cases remain unchanged.
Impact outside recursion checker
Required:
- Tests in
DefRecursionCheckTest. - User docs (
docs/src/main/paradox/recursion.md, and possiblylanguage_guide.md) to document tuplerecur+ lexicographic rule.
Not required:
- Parser/AST shape changes:
recurarg already accepts general expressions including tuples. - Type inference, totality checker, Matchless lowering, or codegen changes.
PackageErrorplumbing changes (newRecursionErrorcases flow through existing wrapper).
Complexity and performance
- Per recursive call check becomes
O(k)forktarget components (typically small). - Traversing all call arguments in recursive-call cases matches existing behavior for non-recursive calls and is linear in argument AST size.
Open implementation choices
- Keep
RecursionArgNotVar+RecursionNotSubstructuralfor single-target path, or replace with unifiedRecursionNotLexicographic. - Whether to allow
recur (x)as equivalent torecur x(recommended: yes, for consistency). - Diagnostic detail level: include per-component classification (
Equal/Decreases/Other) in error text for debugging.