Skip to content

Add termination checking for recursive functions#1092

Open
joscoh wants to merge 17 commits intomainfrom
josh/term-check
Open

Add termination checking for recursive functions#1092
joscoh wants to merge 17 commits intomainfrom
josh/term-check

Conversation

@joscoh
Copy link
Copy Markdown
Contributor

@joscoh joscoh commented May 1, 2026

Issue #, if available:

Description of changes:

  • Adds a new TerminationCheck pipeline phase that generates $$term verification procedures for recFuncBlock declarations, asserting that
    an adtRank measure strictly decreases at each recursive call site
  • Generates D..adtRank : D → Int uninterpreted functions and per-constructor axioms for ADTs used as decreasing measures, supporting mutual
    datatypes and polymorphic datatypes instantiated at concrete types. These axioms are added as preconditions to the generated termination-checking procedures to avoid extra axioms in other contexts.
  • Adds decreases syntax for functions in Core, where the decreases is inferred from the @[cases] if not provided.
  • Fixes two issues caught by termination checking: List_get_non_neg/List_set_non_neg in the Python runtime were calling the wrong sibling functions (the existing mechanism was probably fine but the termination argument is needlessly complex), and nth in tests was missing an isNil base case guard.

Note that PrecondElim is not run on these generated procedures, since this will duplicate the precondition assertions from the original program.
For the moment, only functions are checked for termination. Procedure termination checking will be added in a future PR.

Tests

-StrataTest.DL.Lambda.AdtRankAxiomsTests — unit tests for adtRank axiom generation (IntList, MyNat, RoseTree/RoseList mutual,
no-recursive-field, polymorphic)
-StrataTest.Languages.Core.Tests.TerminationCheckTests — tests covering termination checking, including standard recursion, mutual recursion, error cases, preconditions, polymorphic datatypes, etc.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@joscoh joscoh added the Core label May 1, 2026
@github-actions github-actions Bot added the Python label May 1, 2026
@aqjune-aws aqjune-aws self-requested a review May 4, 2026 16:05
Copy link
Copy Markdown
Contributor

@MikaelMayer MikaelMayer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid PR. The architecture is clean — AdtRankAxioms for axiom generation, TerminationCheck for the pipeline phase, with good separation of concerns. The wrapImplications refactoring to share code between PrecondElim and TermCheck is well done. The List_get_non_neg/List_set_non_neg bug fixes are genuine catches. Test coverage is comprehensive (17 tests covering basic, mutual, polymorphic, error cases, preconditions, non-termination detection). Documentation is thorough and well-written.

Comment thread Strata/DL/Util/Func.lean Outdated
| some _ => none
| none => FuncAttr.findInlineIfConstr func.attr

/-- Extract the datatype name from a monomorphic type. -/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: adtNameOf silently returns "" for non-ADT types, which would produce a bogus "..adtRank" function name if it ever reached mkAdtRankLt or mkAdtRankDecls. The error checking in Step 1 of termCheck prevents this in practice, but an Option String return (or a panic!) would make the invariant explicit and prevent silent corruption if the precondition is ever violated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it should panic. It is possible to change to an Option but it doesn't actually help all that much: we still need a default case for the call sites (mkAdtRankLt and extractTermObligations). Since the error checking catches this already I didn't think it was worth the additional complication.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about:

Suggested change
/-- Extract the datatype name from a monomorphic type. -/
/-- Extract the datatype name from a monomorphic type.
Precondition: `ty` must be a `.tcons` (enforced by error checking in `termCheck`). -/
private def adtNameOf (ty : LMonoTy) : String :=

Comment thread Strata/Transform/TerminationCheck.lean Outdated
@joscoh joscoh marked this pull request as ready for review May 4, 2026 20:02
@joscoh joscoh requested a review from a team May 4, 2026 20:02
Comment on lines +86 to +120
go (e : Expression.Expr) (implications : List (Unit × Expression.Expr))
: List Expression.Expr :=
match _he: e with
| .ite _ c t f =>
let cObs := go c implications
let tObs := go t (((), c) :: implications)
let notC : Expression.Expr :=
LExpr.mkApp () (.op () "Bool.Not" (.some (LMonoTy.arrow .bool .bool))) [c]
let fObs := go f (((), notC) :: implications)
cObs ++ tObs ++ fObs
| .app _ _ _ =>
match _h : getLFuncCall e with
| (op, args) =>
let argObs := args.attach.flatMap fun ⟨a, _⟩ => go a implications
let callObs := match op with
| .op _ opName _ =>
if opName.name ∈ recFuncNames then
match funcDecreasesMap.find? (fun (n, _, _) => n == opName.name) with
| some (_, calleeIdx, calleeAdtTy) =>
match args[calleeIdx]? with
| some callArg =>
let ltExpr := mkAdtRankLt callArg callerParam callerAdtTy calleeAdtTy
[wrapImplications implications ltExpr]
| none => []
| none => []
else []
| _ => []
argObs ++ callObs
| .eq _ e1 e2 => go e1 implications ++ go e2 implications
| .abs _ _ _ body => go body implications
| .quant _ _ _ _ trigger body =>
go trigger implications ++ go body implications
| _ => []
termination_by e.sizeOf
decreasing_by
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractTermObligations recurses into ite branches, eq, abs, quant, and function applications (via getLFuncCall). However, it does not recurse into the body of a let-binding.

In the locally nameless representation, a let-binding let x = e1 in e2 is encoded as .app _ (.abs _ _ _ e2) e1. When getLFuncCall encounters this, it returns (.abs _ _ _ e2, [e1]). The argObs recurses into e1, but e2 (the let body) is never visited — the .abs case at line 113 only fires for standalone abs nodes, not ones inside .app.

This means a recursive call inside a let body would be missed:

rec function f(@[cases] xs : IntList) : int {
  let y = g(xs) in f(IntList..tl(xs)) + y
}

In practice this doesn't matter today because Core functions don't use let bindings, but it's worth a comment documenting this limitation so future maintainers don't assume full coverage:

Suggested change
go (e : Expression.Expr) (implications : List (Unit × Expression.Expr))
: List Expression.Expr :=
match _he: e with
| .ite _ c t f =>
let cObs := go c implications
let tObs := go t (((), c) :: implications)
let notC : Expression.Expr :=
LExpr.mkApp () (.op () "Bool.Not" (.some (LMonoTy.arrow .bool .bool))) [c]
let fObs := go f (((), notC) :: implications)
cObs ++ tObs ++ fObs
| .app _ _ _ =>
match _h : getLFuncCall e with
| (op, args) =>
let argObs := args.attach.flatMap fun ⟨a, _⟩ => go a implications
let callObs := match op with
| .op _ opName _ =>
if opName.name ∈ recFuncNames then
match funcDecreasesMap.find? (fun (n, _, _) => n == opName.name) with
| some (_, calleeIdx, calleeAdtTy) =>
match args[calleeIdx]? with
| some callArg =>
let ltExpr := mkAdtRankLt callArg callerParam callerAdtTy calleeAdtTy
[wrapImplications implications ltExpr]
| none => []
| none => []
else []
| _ => []
argObs ++ callObs
| .eq _ e1 e2 => go e1 implications ++ go e2 implications
| .abs _ _ _ body => go body implications
| .quant _ _ _ _ trigger body =>
go trigger implications ++ go body implications
| _ => []
termination_by e.sizeOf
decreasing_by
| .app _ _ _ =>
-- Note: let-bindings (.app _ (.abs _ _ _ body) arg) are decomposed by
-- getLFuncCall as (abs, [arg]). The abs body is NOT recursed into.
-- This is acceptable because Core recursive functions currently don't
-- use let-bindings. If that changes, this case needs updating.
match _h : getLFuncCall e with

Comment on lines +225 to +240
(emittedAdtRank : Std.HashSet String)
: CoreTransformM (Bool × List Decl) := do
match decls with
| [] => return (false, [])
| d :: rest =>
match d with
| .recFuncBlock funcs md => do
-- Step 1: error checking
-- Skip polymorphic functions: adtRank axioms are monomorphic, so we
-- cannot generate them for polymorphic datatypes yet. The user-facing
-- error is in Env.addFactoryFunc; when that restriction is lifted,
-- this filter must be updated to handle polymorphic adtRank generation.
let fileRange := Imperative.getFileRange md |>.getD FileRange.unknown
let throwErr (msg : String) : CoreTransformM Unit :=
throw (DiagnosticModel.withRange fileRange msg)
for func in funcs do
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error checking in Step 1 throws on the first error it finds. For a recFuncBlock with multiple functions, if the first function has a valid decreases but the second doesn't, only the second's error is reported. This is fine for single errors, but if multiple functions in the same block have issues, the user has to fix them one at a time.

Consider collecting all errors and throwing them together, or at minimum adding a comment noting the fail-fast behavior.

Comment on lines +155 to +195
: Option (Decl × Nat) := do
let decrIdx ← getDecreasesIdx func
let inputTys := func.inputs.values
let decrTy ← inputTys[decrIdx]?
let decrParam ← func.inputs.keys[decrIdx]?
let body ← func.body
let obligations := extractTermObligations body recFuncNames decrParam decrTy
funcDecreasesMap
if obligations.isEmpty then none
let stmts := obligations.mapIdx fun i ob =>
Statement.assert s!"{func.name.name}_terminates_{i}" ob md
-- Specialize polymorphic axioms to the concrete decreasing-parameter type
let tySubst := mkTySubst tf decrTy
let specializedAxioms := adtRankAxioms.map fun (name, e) =>
(name, e.applySubst tySubst)
some (.proc {
header := {
name := ⟨termProcName func.name.name, ()⟩
typeArgs := func.typeArgs
inputs := func.inputs
outputs := []
noFilter := true
}
spec := { preconditions :=
(specializedAxioms.map fun (name, e) =>
(name, { expr := e, attr := .Free })) ++
(func.preconditions.mapIdx fun i p =>
(s!"{func.name.name}_requires_{i}", { expr := p.expr, attr := .Free })),
postconditions := [] }
body := stmts
} md, obligations.length)

/-- Add a termination-check procedure as a leaf node in the cached call graph. -/
private def addTermProcToCallGraph (name : String) : CoreTransformM Unit :=
modify fun σ => match σ.cachedAnalyses.callGraph with
| .some cg => { σ with cachedAnalyses := { σ.cachedAnalyses with
callGraph := .some (cg.addLeafNode name) } }
| .none => σ

/-- Result of generating adtRank declarations for a mutual datatype block. -/
private structure AdtRankDecls where
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mkTermCheckProc embeds ALL adtRank axioms as preconditions of the generated $$term procedure, even axioms for datatypes not used by this particular function. For a mutual block with functions over different datatypes, each $$term proc gets axioms for all datatypes in the block.

This is sound (extra axioms don't hurt) but could make the SMT queries larger than necessary. For small programs this is fine, but for large mutual blocks with many datatypes, filtering axioms to only those relevant to the function's decreasing parameter type would reduce VC size.

Comment on lines +38 to +80
For each constructor `C` and each field of datatype type within the
mutual block `block`, generates:
`∀ fields. D'..adtRank(field_i) < D..adtRank(C(fields...))`
with trigger `D..adtRank(C(fields...))`.

The `block` parameter determines which field types are considered
"recursive" (i.e., belong to the mutual datatype block). -/
def mkAdtRankPerConstrAxioms {T : LExprParams} [Inhabited T.Metadata] [Inhabited T.IDMeta] [DecidableEq T.IDMeta]
(dt : LDatatype T.IDMeta) (block : MutualDatatype T.IDMeta)
(m : T.Metadata) : List (LExpr T.mono) :=
let dtTy := dataDefault dt
dt.constrs.flatMap fun c =>
let numFields := c.args.length
let constrTy := LMonoTy.mkArrow' dtTy (c.args.map (·.2))
let constrApp := c.args.foldlIdx (fun acc i _ =>
.app m acc (.bvar m (numFields - 1 - i))
) (.op m c.name (.some constrTy))
let adtRankTy := .arrow dtTy .int
let adtRankConstr : LExpr T.mono :=
.app m (.op m (adtRankFuncName dt.name) (.some adtRankTy)) constrApp
let fieldTys := (c.args.map (·.snd)).reverse
(c.args.foldlIdx (init := []) fun acc i (_, fieldTy) =>
match block.find? (fun d => fieldTy == dataDefault d) with
| none => acc
| some fieldDt =>
let fieldBvar := .bvar m (numFields - 1 - i)
let fieldRankTy := .arrow (dataDefault fieldDt) .int
let adtRankField := .app m (.op m (adtRankFuncName fieldDt.name)
(.some fieldRankTy)) fieldBvar
let ltTy := .arrow .int (.arrow .int .bool)
let ltExpr := LExpr.mkApp m (.op m "Int.Lt" (.some ltTy))
[adtRankField, adtRankConstr]
let axiom_ := match fieldTys with
| [] => ltExpr
| ty :: rest =>
let inner := .quant m .all "" (.some ty) adtRankConstr ltExpr
rest.foldl (fun body ty =>
.quant m .all "" (.some ty) (LExpr.noTrigger m) body
) inner
axiom_ :: acc
).reverse

/-- Generate the non-negativity axiom `∀ x. adtRank(x) >= 0` for a datatype.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The axiom generation is well-structured. A few observations:

  1. Proof opportunity: The key soundness property is that adtRank is well-founded — i.e., there is no infinite descending chain. This follows from the fact that every constructor's recursive fields have strictly smaller rank. A formal theorem stating this would strengthen confidence:
-- Informal: For any ADT D with constructors C_i, if field f_j of C_i has type D' in the mutual block,
-- then the generated axiom `D'..adtRank(f_j) < D..adtRank(C_i(fields...))` is consistent
-- (i.e., there exists a model satisfying all axioms simultaneously).

This could be proved by exhibiting a concrete model: define adtRank as the structural depth of the ADT value.

  1. Non-negativity axiom: mkAdtRankNonNegAxiom generates ∀ x. adtRank(x) >= 0. Combined with the per-constructor axioms, this ensures adtRank is a well-founded measure on natural numbers. Good.

  2. Trigger placement: The per-constructor axioms use adtRank(C(fields...)) as the trigger, which means they only fire when the SMT solver encounters an adtRank application on a constructor term. This is the right choice — it avoids matching loops while ensuring the axioms fire when needed.

Comment on lines 379 to 384
| .proc proc md => do
if TermCheck.isTermProc proc.header.name.name then
let (changed, rest') ← transformDecls rest
return (changed, d :: rest')
else
let F ← getFactory
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — $$term procedures are skipped by PrecondElim to avoid duplicating precondition assertions. The check uses TermCheck.isTermProc which tests for the $$term suffix.

However, this creates a coupling between PrecondElim and TerminationCheckPrecondElim now imports TerminationCheck. If more pipeline phases need to skip $$term procedures in the future, consider adding a general skipPrecondElim flag to the procedure header (similar to noFilter) instead of hard-coding the name check.

Comment on lines +1 to +17
/-
Copyright Strata Contributors

SPDX-License-Identifier: Apache-2.0 OR MIT
-/

import Strata.Languages.Core.Verifier

/-!
# Termination Checking Tests

Tests termination checking for recursive functions over algebraic datatypes.
-/

namespace Strata.TerminationCheckTest

---------------------------------------------------------------------
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a test for a function where the recursive call is nested inside a non-recursive function call, e.g.:

rec function f(@[cases] xs : IntList) : int {
  if IntList..isNil(xs) then 0 else g(f(IntList..tl(xs)))
}

where g is a non-recursive function. This tests that extractTermObligations correctly recurses into arguments of non-recursive calls.

| some _ => none
| none => FuncAttr.findInlineIfConstr func.attr

/-- Extract the datatype name from a monomorphic type. -/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about:

Suggested change
/-- Extract the datatype name from a monomorphic type. -/
/-- Extract the datatype name from a monomorphic type.
Precondition: `ty` must be a `.tcons` (enforced by error checking in `termCheck`). -/
private def adtNameOf (ty : LMonoTy) : String :=

let rankX := .app m (.op m (adtRankFuncName dt.name) (.some rankTy))
(.bvar m 0)
let geTy := .arrow .int (.arrow .int .bool)
let geExpr := .mkApp m (.op m "Int.Ge" (.some geTy)) [rankX, .intConst m 0]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use (@intGeFunc T).opExpr which will hide hard-coded "Int.Ge" and geTy?

let geExpr := .mkApp m (@intGeFunc T).opExpr [rankX, .intConst m 0]

let adtRankField := .app m (.op m (adtRankFuncName fieldDt.name)
(.some fieldRankTy)) fieldBvar
let ltTy := .arrow .int (.arrow .int .bool)
let ltExpr := LExpr.mkApp m (.op m "Int.Lt" (.some ltTy))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly to line 89, let's use

let ltExpr := .mkApp m (@intLtFunc T).opExpr [adtRankField, adtRankConstr]

single and mutually recursive). Recursive functions are declared with the `rec`
keyword, and exactly one parameter must be annotated with `@[cases]` to indicate
the algebraic datatype argument being recursed on.
the algebraic datatype argument for per-constructor axiom generation.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the generated axioms describing the recursive function unfolded with respect to each constructor? Let's clarify this.

else 1 + zipLen(IntList..tl(xs), IntList..tl(ys))
};
```

Copy link
Copy Markdown
Contributor

@aqjune-aws aqjune-aws May 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can a recursive function have neither @[cases] nor decreasing (which will make termination checking impossible)? My understanding is that this is not true because exactly one argument must be annotated with @[cases] as mentioned in the previous paragraph. Let's state that as a result Strata doesn't accept a recursive function without termination hint.

@[scope(typeArgs)] b : Bindings,
@[scope(typeArgs)] r : Type,
@[scope(b)] preconds : SpacePrefixSepBy SpecElt,
@[scope(b)] decreases : Option Measure,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it accept arbitrary expression? If it is targeting a free variable in this patch for now, what about limiting it to an identifier and gradually extending to arbitrary expressions in the future?

if ListAny..isListAny_nil(l) then from_None()
else if i == 0 then ListAny..head!(l)
else List_get(ListAny..tail!(l), i - 1)
else List_get_non_neg(ListAny..tail!(l), i - 1)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have a behind story about this finding & update? :)

| .eq _ e1 e2 => go e1 implications ++ go e2 implications
| .abs _ _ _ body => go body implications
| .quant _ _ _ _ trigger body =>
go trigger implications ++ go body implications
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems .abs and .quant cases can be slightly complicated because visiting their bodies will have to consider bound variables. What about throwing when meeting this kind of case?

Copy link
Copy Markdown
Contributor

@atomb atomb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still want to do a more thorough review myself, but here are a few 🤖 notes so far.

private def getDecreasesIdx (func : Function) : Option Nat :=
match func.measure with
| some (.fvar _ id _) => func.inputs.keys.findIdx? (· == id)
| some _ => none
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user writes decreases someComplexExpr (not a bare variable), this silently falls through to none, which then triggers the error "requires a 'decreases' clause or a '@[cases]' parameter". The error message is misleading — the user did provide a decreases clause, it just wasn't a simple variable reference. A more specific error message like "decreases expression must be a parameter name" would be clearer.

: Option (Decl × Nat) := do
let decrIdx ← getDecreasesIdx func
let inputTys := func.inputs.values
let decrTy ← inputTys[decrIdx]?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If getDecreasesIdx returns an index that's valid for func.inputs.keys but somehow not for func.inputs.values (shouldn't happen with a well-formed ListMap, but defensive), the function returns none and no termination check is generated. The do notation with makes this silent.

| ty :: rest =>
let inner := .quant m .all "" (.some ty) adtRankConstr ltExpr
rest.foldl (fun body ty =>
.quant m .all "" (.some ty) (LExpr.noTrigger m) body
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bound variable name is "" (empty string). While this works with de Bruijn indices, it makes debugging SMT output harder. The generated SMT uses __q0, __q1 etc. which is fine, but if the pretty-printer ever uses these names, they'd be unhelpful.

@[scope(b)] decreases : Option Measure,
@[scope(b)] c : r) : RecFnDecl =>
"function " name typeArgs b " : " r indent(2, preconds) "\n{\n " indent(2, c) "\n}";
"function " name typeArgs b " : " r indent(2, preconds) "\n" indent(2, decreases) "{\n " indent(2, c) "\n}";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When decreases is none, this produces an extra newline before {. This is cosmetic and may be intentional for readability, but differs from how preconds handles the empty case (no extra whitespace when empty).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants