Skip to main content

aver/codegen/recursion/
detect.rs

1//! Proof-mode recursion classifier.
2//!
3//! Backend-neutral pattern matching over the Aver AST that decides,
4//! for each recursive pure fn (or mutual-recursion SCC), which
5//! [`RecursionPlan`] variant applies — or emits a [`ProofModeIssue`]
6//! when the shape falls outside the supported set.
7//!
8//! Lean and Dafny consume the same plans through
9//! [`crate::codegen::recursion::analyze_plans`]; a couple of helpers
10//! that depend on AST queries tied to Lean's `toplevel` (pure-fn
11//! predicate, recursive-type-def predicate, type-def name) still live
12//! in `crate::codegen::lean` and are re-used here through
13//! `pub(crate)` exports. That could move to a neutral AST helper
14//! module in a later pass.
15use std::collections::{HashMap, HashSet};
16
17use crate::ast::{
18    BinOp, Expr, FnBody, FnDef, MatchArm, Pattern, Spanned, Stmt, TailCallData, TopLevel, TypeDef,
19};
20use crate::call_graph;
21use crate::codegen::proof_lower::ProofLowerInputs;
22
23use super::{ProofModeIssue, RecursionPlan};
24
25pub(crate) fn expr_to_dotted_name(expr: &Spanned<Expr>) -> Option<String> {
26    match &expr.node {
27        Expr::Ident(name) => Some(name.clone()),
28        Expr::Attr(obj, field) => expr_to_dotted_name(obj).map(|p| format!("{}.{}", p, field)),
29        _ => None,
30    }
31}
32
33/// Local-variable name regardless of whether the expression has been
34/// resolved. Resolver rewrites `Expr::Ident(name)` for locals into
35/// `Expr::Resolved { name, .. }` (slot-numbered for the VM); pre-resolution
36/// passes still see `Expr::Ident`. Recursion-shape detectors run AFTER
37/// resolution at codegen time, so they must accept both forms — otherwise
38/// every list/string-recursive function silently falls outside the proof
39/// subset.
40pub(crate) fn local_name_of(expr: &Spanned<Expr>) -> Option<&str> {
41    match &expr.node {
42        Expr::Ident(name) => Some(name.as_str()),
43        Expr::Resolved { name, .. } => Some(name.as_str()),
44        _ => None,
45    }
46}
47
48/// **syntax-discovery-only** (epic #170 Phase 7). Exact-match
49/// recognition of a dotted source name. The previous suffix-match
50/// clause was an identity leak — sibling fix to the one in
51/// `proof_lower::callee_matches_name` /
52/// `law_auto::shared::callee_matches_name`.
53pub(crate) fn call_matches(name: &str, target: &str) -> bool {
54    name == target
55}
56
57pub(crate) fn call_is_in_set(name: &str, targets: &HashSet<String>) -> bool {
58    call_matches_any(name, targets)
59}
60
61pub(crate) fn canonical_callee_name(name: &str, targets: &HashSet<String>) -> Option<String> {
62    if targets.contains(name) {
63        return Some(name.to_string());
64    }
65    name.rsplit('.')
66        .next()
67        .filter(|last| targets.contains(*last))
68        .map(ToString::to_string)
69}
70
71pub(crate) fn call_matches_any(name: &str, targets: &HashSet<String>) -> bool {
72    if targets.contains(name) {
73        return true;
74    }
75    match name.rsplit('.').next() {
76        Some(last) => targets.contains(last),
77        None => false,
78    }
79}
80
81pub(crate) fn is_int_minus_positive(expr: &Spanned<Expr>, param_name: &str) -> bool {
82    match &expr.node {
83        Expr::BinOp(BinOp::Sub, left, right) => {
84            local_name_of(left).is_some_and(|id| id == param_name)
85                && matches!(&right.node, Expr::Literal(crate::ast::Literal::Int(n)) if *n >= 1)
86        }
87        Expr::FnCall(callee, args) => {
88            let Some(name) = expr_to_dotted_name(callee) else {
89                return false;
90            };
91            (name == "Int.sub" || name == "int.sub")
92                && args.len() == 2
93                && local_name_of(&args[0]).is_some_and(|id| id == param_name)
94                && matches!(&args[1].node, Expr::Literal(crate::ast::Literal::Int(n)) if *n >= 1)
95        }
96        _ => false,
97    }
98}
99
100pub(crate) fn collect_calls_from_expr<'a>(
101    expr: &'a Spanned<Expr>,
102    out: &mut Vec<(String, Vec<&'a Spanned<Expr>>)>,
103) {
104    match &expr.node {
105        Expr::FnCall(callee, args) => {
106            if let Some(name) = expr_to_dotted_name(callee) {
107                out.push((name, args.iter().collect()));
108            }
109            collect_calls_from_expr(callee, out);
110            for arg in args {
111                collect_calls_from_expr(arg, out);
112            }
113        }
114        Expr::TailCall(boxed) => {
115            let TailCallData {
116                target: name, args, ..
117            } = boxed.as_ref();
118            out.push((name.clone(), args.iter().collect()));
119            for arg in args {
120                collect_calls_from_expr(arg, out);
121            }
122        }
123        Expr::Attr(obj, _) => collect_calls_from_expr(obj, out),
124        Expr::BinOp(_, left, right) => {
125            collect_calls_from_expr(left, out);
126            collect_calls_from_expr(right, out);
127        }
128        Expr::Neg(inner) => collect_calls_from_expr(inner, out),
129        Expr::Match { subject, arms, .. } => {
130            collect_calls_from_expr(subject, out);
131            for arm in arms {
132                collect_calls_from_expr(&arm.body, out);
133            }
134        }
135        Expr::Constructor(_, inner) => {
136            if let Some(inner) = inner {
137                collect_calls_from_expr(inner, out);
138            }
139        }
140        Expr::ErrorProp(inner) => collect_calls_from_expr(inner, out),
141        Expr::InterpolatedStr(parts) => {
142            for p in parts {
143                if let crate::ast::StrPart::Parsed(e) = p {
144                    collect_calls_from_expr(e, out);
145                }
146            }
147        }
148        Expr::List(items) | Expr::Tuple(items) | Expr::IndependentProduct(items, _) => {
149            for item in items {
150                collect_calls_from_expr(item, out);
151            }
152        }
153        Expr::MapLiteral(entries) => {
154            for (k, v) in entries {
155                collect_calls_from_expr(k, out);
156                collect_calls_from_expr(v, out);
157            }
158        }
159        Expr::RecordCreate { fields, .. } => {
160            for (_, v) in fields {
161                collect_calls_from_expr(v, out);
162            }
163        }
164        Expr::RecordUpdate { base, updates, .. } => {
165            collect_calls_from_expr(base, out);
166            for (_, v) in updates {
167                collect_calls_from_expr(v, out);
168            }
169        }
170        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } => {}
171    }
172}
173
174pub(crate) fn collect_calls_from_body(body: &FnBody) -> Vec<(String, Vec<&Spanned<Expr>>)> {
175    let mut out = Vec::new();
176    for stmt in body.stmts() {
177        match stmt {
178            Stmt::Binding(_, _, expr) | Stmt::Expr(expr) => collect_calls_from_expr(expr, &mut out),
179        }
180    }
181    out
182}
183
184pub(crate) fn collect_list_tail_binders_from_expr(
185    expr: &Spanned<Expr>,
186    list_param_name: &str,
187    tails: &mut HashSet<String>,
188) {
189    match &expr.node {
190        Expr::Match { subject, arms, .. } => {
191            if local_name_of(subject).is_some_and(|id| id == list_param_name) {
192                for MatchArm { pattern, .. } in arms {
193                    if let Pattern::Cons(_, tail) = pattern {
194                        tails.insert(tail.clone());
195                    }
196                }
197            }
198            for arm in arms {
199                collect_list_tail_binders_from_expr(&arm.body, list_param_name, tails);
200            }
201            collect_list_tail_binders_from_expr(subject, list_param_name, tails);
202        }
203        Expr::FnCall(callee, args) => {
204            collect_list_tail_binders_from_expr(callee, list_param_name, tails);
205            for arg in args {
206                collect_list_tail_binders_from_expr(arg, list_param_name, tails);
207            }
208        }
209        Expr::TailCall(boxed) => {
210            let TailCallData {
211                target: _, args, ..
212            } = boxed.as_ref();
213            for arg in args {
214                collect_list_tail_binders_from_expr(arg, list_param_name, tails);
215            }
216        }
217        Expr::Attr(obj, _) => collect_list_tail_binders_from_expr(obj, list_param_name, tails),
218        Expr::BinOp(_, left, right) => {
219            collect_list_tail_binders_from_expr(left, list_param_name, tails);
220            collect_list_tail_binders_from_expr(right, list_param_name, tails);
221        }
222        Expr::Neg(inner) => collect_list_tail_binders_from_expr(inner, list_param_name, tails),
223        Expr::Constructor(_, inner) => {
224            if let Some(inner) = inner {
225                collect_list_tail_binders_from_expr(inner, list_param_name, tails);
226            }
227        }
228        Expr::ErrorProp(inner) => {
229            collect_list_tail_binders_from_expr(inner, list_param_name, tails)
230        }
231        Expr::InterpolatedStr(parts) => {
232            for p in parts {
233                if let crate::ast::StrPart::Parsed(e) = p {
234                    collect_list_tail_binders_from_expr(e, list_param_name, tails);
235                }
236            }
237        }
238        Expr::List(items) | Expr::Tuple(items) | Expr::IndependentProduct(items, _) => {
239            for item in items {
240                collect_list_tail_binders_from_expr(item, list_param_name, tails);
241            }
242        }
243        Expr::MapLiteral(entries) => {
244            for (k, v) in entries {
245                collect_list_tail_binders_from_expr(k, list_param_name, tails);
246                collect_list_tail_binders_from_expr(v, list_param_name, tails);
247            }
248        }
249        Expr::RecordCreate { fields, .. } => {
250            for (_, v) in fields {
251                collect_list_tail_binders_from_expr(v, list_param_name, tails);
252            }
253        }
254        Expr::RecordUpdate { base, updates, .. } => {
255            collect_list_tail_binders_from_expr(base, list_param_name, tails);
256            for (_, v) in updates {
257                collect_list_tail_binders_from_expr(v, list_param_name, tails);
258            }
259        }
260        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } => {}
261    }
262}
263
264pub(crate) fn collect_list_tail_binders(fd: &FnDef, list_param_name: &str) -> HashSet<String> {
265    let mut tails = HashSet::new();
266    for stmt in fd.body.stmts() {
267        match stmt {
268            Stmt::Binding(_, _, expr) | Stmt::Expr(expr) => {
269                collect_list_tail_binders_from_expr(expr, list_param_name, &mut tails)
270            }
271        }
272    }
273    tails
274}
275
276pub(crate) fn recursive_constructor_binders(
277    td: &TypeDef,
278    variant_name: &str,
279    binders: &[String],
280) -> Vec<String> {
281    let variant_short = variant_name.rsplit('.').next().unwrap_or(variant_name);
282    match td {
283        TypeDef::Sum { name, variants, .. } => variants
284            .iter()
285            .find(|variant| variant.name == variant_short)
286            .map(|variant| {
287                variant
288                    .fields
289                    .iter()
290                    .zip(binders.iter())
291                    .filter_map(|(field_ty, binder)| {
292                        (field_ty.trim() == name).then_some(binder.clone())
293                    })
294                    .collect()
295            })
296            .unwrap_or_default(),
297        TypeDef::Product { .. } => Vec::new(),
298    }
299}
300
301pub(crate) fn grow_recursive_subterm_binders_from_expr(
302    expr: &Spanned<Expr>,
303    tracked: &HashSet<String>,
304    td: &TypeDef,
305    out: &mut HashSet<String>,
306) {
307    match &expr.node {
308        Expr::Match { subject, arms, .. } => {
309            if let Some(subject_name) = local_name_of(subject)
310                && tracked.contains(subject_name)
311            {
312                for arm in arms {
313                    if let Pattern::Constructor(variant_name, binders) = &arm.pattern {
314                        out.extend(recursive_constructor_binders(td, variant_name, binders));
315                    }
316                }
317            }
318            grow_recursive_subterm_binders_from_expr(subject, tracked, td, out);
319            for arm in arms {
320                grow_recursive_subterm_binders_from_expr(&arm.body, tracked, td, out);
321            }
322        }
323        Expr::FnCall(callee, args) => {
324            grow_recursive_subterm_binders_from_expr(callee, tracked, td, out);
325            for arg in args {
326                grow_recursive_subterm_binders_from_expr(arg, tracked, td, out);
327            }
328        }
329        Expr::Attr(obj, _) => grow_recursive_subterm_binders_from_expr(obj, tracked, td, out),
330        Expr::BinOp(_, left, right) => {
331            grow_recursive_subterm_binders_from_expr(left, tracked, td, out);
332            grow_recursive_subterm_binders_from_expr(right, tracked, td, out);
333        }
334        Expr::Neg(inner) => grow_recursive_subterm_binders_from_expr(inner, tracked, td, out),
335        Expr::Constructor(_, Some(inner)) | Expr::ErrorProp(inner) => {
336            grow_recursive_subterm_binders_from_expr(inner, tracked, td, out)
337        }
338        Expr::InterpolatedStr(parts) => {
339            for part in parts {
340                if let crate::ast::StrPart::Parsed(inner) = part {
341                    grow_recursive_subterm_binders_from_expr(inner, tracked, td, out);
342                }
343            }
344        }
345        Expr::List(items) | Expr::Tuple(items) | Expr::IndependentProduct(items, _) => {
346            for item in items {
347                grow_recursive_subterm_binders_from_expr(item, tracked, td, out);
348            }
349        }
350        Expr::MapLiteral(entries) => {
351            for (k, v) in entries {
352                grow_recursive_subterm_binders_from_expr(k, tracked, td, out);
353                grow_recursive_subterm_binders_from_expr(v, tracked, td, out);
354            }
355        }
356        Expr::RecordCreate { fields, .. } => {
357            for (_, v) in fields {
358                grow_recursive_subterm_binders_from_expr(v, tracked, td, out);
359            }
360        }
361        Expr::RecordUpdate { base, updates, .. } => {
362            grow_recursive_subterm_binders_from_expr(base, tracked, td, out);
363            for (_, v) in updates {
364                grow_recursive_subterm_binders_from_expr(v, tracked, td, out);
365            }
366        }
367        Expr::TailCall(boxed) => {
368            for arg in &boxed.args {
369                grow_recursive_subterm_binders_from_expr(arg, tracked, td, out);
370            }
371        }
372        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } | Expr::Constructor(_, None) => {}
373    }
374}
375
376pub(crate) fn collect_recursive_subterm_binders(
377    fd: &FnDef,
378    param_name: &str,
379    param_type: &str,
380    inputs: &ProofLowerInputs,
381) -> HashSet<String> {
382    let Some(td) = inputs.find_type_def(param_type) else {
383        return HashSet::new();
384    };
385    let mut tracked: HashSet<String> = HashSet::from([param_name.to_string()]);
386    loop {
387        let mut discovered = HashSet::new();
388        for stmt in fd.body.stmts() {
389            match stmt {
390                Stmt::Binding(_, _, expr) | Stmt::Expr(expr) => {
391                    grow_recursive_subterm_binders_from_expr(expr, &tracked, td, &mut discovered);
392                }
393            }
394        }
395        let before = tracked.len();
396        tracked.extend(discovered);
397        if tracked.len() == before {
398            break;
399        }
400    }
401    tracked.remove(param_name);
402    tracked
403}
404
405/// Body of the `0` arm of `match <countdown_param> { 0 -> BASE; _ -> rec(...) }`,
406/// when the fn body has exactly that two-arm shape and no other recursive
407/// branches. Returned to the codegen so the native-emit wrapper can use
408/// `BASE` for the `p < 0` case (outside the well-formed Aver domain).
409///
410/// Conservative: requires the top-level expression to be a `match` whose
411/// subject is the countdown param itself (after resolution). Inner /
412/// nested matches don't qualify — those are handled by the fuel path
413/// where well-foundedness is encoded structurally.
414pub(crate) fn int_countdown_native_arms(
415    fd: &FnDef,
416    param_index: usize,
417) -> Option<(i64, Spanned<Expr>, Spanned<Expr>)> {
418    let (param_name, _) = fd.params.get(param_index)?;
419    // Body must be exactly one expression — leading `let` bindings
420    // would need to be threaded into both the wrapper and the aux, which
421    // the simple string-level emit can't do. Fuel path stays available
422    // for fns with let-prefix bodies.
423    if fd.body.stmts().len() != 1 {
424        return None;
425    }
426    let tail = fd.body.tail_expr()?;
427    let Expr::Match { subject, arms, .. } = &tail.node else {
428        return None;
429    };
430    if !is_ident(subject, param_name) {
431        return None;
432    }
433    if arms.len() != 2 {
434        return None;
435    }
436    let mut literal_arm: Option<(i64, Spanned<Expr>)> = None;
437    let mut wildcard_arm_body: Option<Spanned<Expr>> = None;
438    for arm in arms {
439        match &arm.pattern {
440            Pattern::Literal(crate::ast::Literal::Int(n)) => {
441                literal_arm = Some((*n, (*arm.body).clone()));
442            }
443            Pattern::Wildcard | Pattern::Ident(_) => {
444                wildcard_arm_body = Some((*arm.body).clone());
445            }
446            _ => return None,
447        }
448    }
449    let (literal_value, literal_arm_body) = literal_arm?;
450    let wildcard_arm_body = wildcard_arm_body?;
451
452    // Lean native emit assumes the default `(h_dom : p ≥ 0)`
453    // precondition; under that, the wildcard arm constraint `p ≠ L`
454    // combined with `p ≥ 0` must imply `p ≥ 1` so the recursive call
455    // `f(p - 1)` lands in-domain. That holds only when `L == 0`. A
456    // shape like `match n { 5 -> base; _ -> f(n - 1) }` would let
457    // `n = 0` reach the wildcard arm, recurse with `n - 1 = -1`, and
458    // break the precondition — `omega` rightly rejects this at lake
459    // build. Generalising to arbitrary literals needs a proper
460    // preservation check over (precondition ∧ arm-constraint ⊢
461    // f-args-in-domain) and a non-default precondition derived from
462    // the caller's guards; until that's in, restrict native emit to
463    // the literal-zero shape and fall back to fuel otherwise.
464    if literal_value != 0 {
465        return None;
466    }
467
468    let mut lit_calls = Vec::new();
469    collect_calls_from_expr(&literal_arm_body, &mut lit_calls);
470    if lit_calls.iter().any(|(n, _)| call_matches(n, &fd.name)) {
471        return None;
472    }
473    let mut wc_calls = Vec::new();
474    collect_calls_from_expr(&wildcard_arm_body, &mut wc_calls);
475    if !wc_calls.iter().any(|(n, _)| call_matches(n, &fd.name)) {
476        return None;
477    }
478    Some((literal_value, literal_arm_body, wildcard_arm_body))
479}
480
481pub(crate) fn single_int_countdown_param_index(fd: &FnDef) -> Option<usize> {
482    let recursive_calls: Vec<Vec<&Spanned<Expr>>> = collect_calls_from_body(fd.body.as_ref())
483        .into_iter()
484        .filter(|(name, _)| call_matches(name, &fd.name))
485        .map(|(_, args)| args)
486        .collect();
487    if recursive_calls.is_empty() {
488        return None;
489    }
490
491    fd.params
492        .iter()
493        .enumerate()
494        .find_map(|(idx, (param_name, param_ty))| {
495            if param_ty != "Int" {
496                return None;
497            }
498            let countdown_ok = recursive_calls.iter().all(|args| {
499                args.get(idx)
500                    .cloned()
501                    .is_some_and(|arg| is_int_minus_positive(arg, param_name))
502            });
503            if countdown_ok {
504                return Some(idx);
505            }
506
507            // Negative-guarded ascent (match n < 0) is handled as countdown
508            // because the fuel is natAbs(n) which works for both directions.
509            let ascent_ok = recursive_calls.iter().all(|args| {
510                args.get(idx)
511                    .copied()
512                    .is_some_and(|arg| is_int_plus_positive(arg, param_name))
513            });
514            (ascent_ok && has_negative_guarded_ascent(fd, param_name)).then_some(idx)
515        })
516}
517
518/// **syntax-discovery-only**: recognize a floor-division shrink of
519/// `param_name` — `Result.withDefault(Int.div(p, k), <int literal>)`
520/// with literal `k >= 2`, either inlined or through a unary wrapper
521/// fn whose single-expression body is exactly that shape over its own
522/// parameter. Returns `(divisor, helper_fn)`.
523pub(crate) fn floor_div_shrink_of(
524    expr: &Spanned<Expr>,
525    param_name: &str,
526    inputs: &ProofLowerInputs,
527) -> Option<(i64, Option<String>)> {
528    if let Some(divisor) = inline_floor_div_shrink(expr, param_name) {
529        return Some((divisor, None));
530    }
531    // Wrapper form: `h(p)` where `h` is a pure unary Int fn whose
532    // body is the inlined shape over its own param. The call must be
533    // a bare (same-scope) name so both backends can cite it directly.
534    let Expr::FnCall(callee, args) = &expr.node else {
535        return None;
536    };
537    let name = expr_to_dotted_name(callee)?;
538    if name.contains('.') || args.len() != 1 || !is_ident(&args[0], param_name) {
539        return None;
540    }
541    let fd = inputs.find_fn_def_by_call_name(&name)?;
542    if !fd.effects.is_empty() || fd.name != name {
543        return None;
544    }
545    let [(helper_param, helper_ty)] = fd.params.as_slice() else {
546        return None;
547    };
548    if helper_ty != "Int" || fd.return_type != "Int" {
549        return None;
550    }
551    let [crate::ast::Stmt::Expr(body)] = fd.body.stmts() else {
552        return None;
553    };
554    let divisor = inline_floor_div_shrink(body, helper_param)?;
555    Some((divisor, Some(fd.name.clone())))
556}
557
558/// `Result.withDefault(Int.div(p, k), <int literal>)` with literal
559/// `k >= 2` — the inlined floor-division shrink shape.
560fn inline_floor_div_shrink(expr: &Spanned<Expr>, param_name: &str) -> Option<i64> {
561    let Expr::FnCall(callee, args) = &expr.node else {
562        return None;
563    };
564    if expr_to_dotted_name(callee).as_deref() != Some("Result.withDefault") || args.len() != 2 {
565        return None;
566    }
567    if !matches!(&args[1].node, Expr::Literal(crate::ast::Literal::Int(_))) {
568        return None;
569    }
570    let Expr::FnCall(div_callee, div_args) = &args[0].node else {
571        return None;
572    };
573    if expr_to_dotted_name(div_callee).as_deref() != Some("Int.div") || div_args.len() != 2 {
574        return None;
575    }
576    if !is_ident(&div_args[0], param_name) {
577        return None;
578    }
579    match &div_args[1].node {
580        Expr::Literal(crate::ast::Literal::Int(k)) if *k >= 2 => Some(*k),
581        _ => None,
582    }
583}
584
585/// Collect, for every self-call of `fd`, the chain of enclosing
586/// `match <bool> { true/false -> … }` guards in positive form
587/// (false-arm guards flipped via `flip_comparison_binop`;
588/// unflippable ones dropped — a SOUND under-approximation, since the
589/// caller only ever needs "do the collected guards imply the
590/// shrink", and fewer facts can only make it decline).
591pub(crate) fn collect_self_call_guard_chains(fd: &FnDef) -> Vec<Vec<Spanned<Expr>>> {
592    let mut out = Vec::new();
593    for stmt in fd.body.stmts() {
594        match stmt {
595            Stmt::Binding(_, _, expr) | Stmt::Expr(expr) => {
596                walk_self_call_guards(expr, &fd.name, &[], &mut out);
597            }
598        }
599    }
600    out
601}
602
603fn walk_self_call_guards(
604    expr: &Spanned<Expr>,
605    fn_name: &str,
606    enclosing: &[Spanned<Expr>],
607    out: &mut Vec<Vec<Spanned<Expr>>>,
608) {
609    match &expr.node {
610        Expr::FnCall(callee, args) => {
611            if let Some(name) = expr_to_dotted_name(callee)
612                && call_matches(&name, fn_name)
613            {
614                out.push(enclosing.to_vec());
615            }
616            walk_self_call_guards(callee, fn_name, enclosing, out);
617            for arg in args {
618                walk_self_call_guards(arg, fn_name, enclosing, out);
619            }
620        }
621        Expr::TailCall(boxed) => {
622            if boxed.target == fn_name {
623                out.push(enclosing.to_vec());
624            }
625            for arg in &boxed.args {
626                walk_self_call_guards(arg, fn_name, enclosing, out);
627            }
628        }
629        Expr::Match { subject, arms } => {
630            for arm in arms {
631                let mut new_guards: Vec<Spanned<Expr>> = enclosing.to_vec();
632                match &arm.pattern {
633                    Pattern::Literal(crate::ast::Literal::Bool(true)) => {
634                        new_guards.push((**subject).clone());
635                    }
636                    Pattern::Literal(crate::ast::Literal::Bool(false)) => {
637                        if let Some(flipped) =
638                            crate::codegen::recursion::flip_comparison_binop(subject)
639                        {
640                            new_guards.push(flipped);
641                        }
642                    }
643                    _ => {}
644                }
645                walk_self_call_guards(&arm.body, fn_name, &new_guards, out);
646            }
647            walk_self_call_guards(subject, fn_name, enclosing, out);
648        }
649        Expr::BinOp(_, l, r) => {
650            walk_self_call_guards(l, fn_name, enclosing, out);
651            walk_self_call_guards(r, fn_name, enclosing, out);
652        }
653        Expr::Attr(inner, _) | Expr::Neg(inner) | Expr::ErrorProp(inner) => {
654            walk_self_call_guards(inner, fn_name, enclosing, out);
655        }
656        Expr::Constructor(_, arg) => {
657            if let Some(inner) = arg {
658                walk_self_call_guards(inner, fn_name, enclosing, out);
659            }
660        }
661        Expr::InterpolatedStr(parts) => {
662            for p in parts {
663                if let crate::ast::StrPart::Parsed(inner) = p {
664                    walk_self_call_guards(inner, fn_name, enclosing, out);
665                }
666            }
667        }
668        Expr::List(items) | Expr::Tuple(items) | Expr::IndependentProduct(items, _) => {
669            for item in items {
670                walk_self_call_guards(item, fn_name, enclosing, out);
671            }
672        }
673        Expr::MapLiteral(entries) => {
674            for (k, v) in entries {
675                walk_self_call_guards(k, fn_name, enclosing, out);
676                walk_self_call_guards(v, fn_name, enclosing, out);
677            }
678        }
679        Expr::RecordCreate { fields, .. } => {
680            for (_, v) in fields {
681                walk_self_call_guards(v, fn_name, enclosing, out);
682            }
683        }
684        Expr::RecordUpdate { base, updates, .. } => {
685            walk_self_call_guards(base, fn_name, enclosing, out);
686            for (_, v) in updates {
687                walk_self_call_guards(v, fn_name, enclosing, out);
688            }
689        }
690        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } => {}
691    }
692}
693
694/// Does the (positive-form) guard chain imply `param >= 1`? Two
695/// syntactic certificate shapes, both purely linear:
696///
697/// - DIRECT: a clause bounding the param below by a positive literal
698///   (`p >= L` with `L >= 1`, `p > L` with `L >= 0`, or the flipped
699///   literal-first spellings).
700/// - SCALED: a clause `p >= c * q` (or `p >= q * c` / `p >= q`) with
701///   literal `c >= 1`, where the chain also DIRECTLY bounds `q >= 1`
702///   — the binary-exponent shape (`¬(b < 1)` and `¬(a < 2 * b)`
703///   together give `a >= 2 * b >= 2`).
704///
705/// Anything else declines — the caller never guesses a measure.
706pub(crate) fn guards_imply_param_ge_one(guards: &[Spanned<Expr>], param_name: &str) -> bool {
707    if guards
708        .iter()
709        .any(|g| guard_bounds_ident_ge_one(g, param_name))
710    {
711        return true;
712    }
713    guards.iter().any(|g| {
714        let Expr::BinOp(BinOp::Gte, left, right) = &g.node else {
715            return false;
716        };
717        if !is_ident(left, param_name) {
718            return false;
719        }
720        let scaled_var: Option<&str> = match &right.node {
721            Expr::Ident(q) => Some(q.as_str()),
722            Expr::Resolved { name: q, .. } => Some(q.as_str()),
723            Expr::BinOp(BinOp::Mul, l, r) => match (&l.node, &r.node) {
724                (Expr::Literal(crate::ast::Literal::Int(c)), _) if *c >= 1 => local_name_of(r),
725                (_, Expr::Literal(crate::ast::Literal::Int(c))) if *c >= 1 => local_name_of(l),
726                _ => None,
727            },
728            _ => None,
729        };
730        scaled_var.is_some_and(|q| guards.iter().any(|h| guard_bounds_ident_ge_one(h, q)))
731    })
732}
733
734/// One clause of the DIRECT certificate: `name >= L` (L >= 1),
735/// `name > L` (L >= 0), `L <= name` (L >= 1), `L < name` (L >= 0).
736fn guard_bounds_ident_ge_one(guard: &Spanned<Expr>, name: &str) -> bool {
737    let Expr::BinOp(op, left, right) = &guard.node else {
738        return false;
739    };
740    let lit = |e: &Spanned<Expr>| -> Option<i64> {
741        match &e.node {
742            Expr::Literal(crate::ast::Literal::Int(v)) => Some(*v),
743            _ => None,
744        }
745    };
746    match op {
747        BinOp::Gte => is_ident(left, name) && lit(right).is_some_and(|l| l >= 1),
748        BinOp::Gt => is_ident(left, name) && lit(right).is_some_and(|l| l >= 0),
749        BinOp::Lte => is_ident(right, name) && lit(left).is_some_and(|l| l >= 1),
750        BinOp::Lt => is_ident(right, name) && lit(left).is_some_and(|l| l >= 0),
751        _ => false,
752    }
753}
754
755/// Classifier for [`RecursionPlan::IntFloorDivCountdown`]: a single
756/// `Int` param that EVERY self-call shrinks by the same
757/// literal-divisor floor division (see [`floor_div_shrink_of`]),
758/// with every self-call site's guard chain implying the param is
759/// `>= 1` (see [`guards_imply_param_ge_one`]) so the shrink is a
760/// genuine strict decrease. Returns
761/// `(param_index, divisor, helper_fn)`.
762pub(crate) fn single_int_floor_div_countdown(
763    fd: &FnDef,
764    inputs: &ProofLowerInputs,
765) -> Option<(usize, i64, Option<String>)> {
766    let recursive_calls: Vec<Vec<&Spanned<Expr>>> = collect_calls_from_body(fd.body.as_ref())
767        .into_iter()
768        .filter(|(name, _)| call_matches(name, &fd.name))
769        .map(|(_, args)| args)
770        .collect();
771    if recursive_calls.is_empty() {
772        return None;
773    }
774
775    let (param_index, divisor, helper_fn) =
776        fd.params
777            .iter()
778            .enumerate()
779            .find_map(|(idx, (param_name, param_ty))| {
780                if param_ty != "Int" {
781                    return None;
782                }
783                let mut shrink: Option<(i64, Option<String>)> = None;
784                for args in &recursive_calls {
785                    let arg = args.get(idx).copied()?;
786                    let this = floor_div_shrink_of(arg, param_name, inputs)?;
787                    match &shrink {
788                        None => shrink = Some(this),
789                        Some(prev) if *prev == this => {}
790                        Some(_) => return None,
791                    }
792                }
793                shrink.map(|(divisor, helper)| (idx, divisor, helper))
794            })?;
795
796    // Guard validation: every self-call site must sit under a guard
797    // chain that implies the shrinking param is >= 1. The chains come
798    // back one per self-call in body-walk order; a missing or
799    // too-weak chain declines the whole plan — never guess.
800    let (param_name, _) = fd.params.get(param_index)?;
801    let chains = collect_self_call_guard_chains(fd);
802    if chains.len() != recursive_calls.len() || chains.is_empty() {
803        return None;
804    }
805    if !chains
806        .iter()
807        .all(|chain| guards_imply_param_ge_one(chain, param_name))
808    {
809        return None;
810    }
811    Some((param_index, divisor, helper_fn))
812}
813
814pub(crate) fn has_negative_guarded_ascent(fd: &FnDef, param_name: &str) -> bool {
815    let Some(tail) = fd.body.tail_expr() else {
816        return false;
817    };
818    let Expr::Match { subject, arms, .. } = &tail.node else {
819        return false;
820    };
821    let Expr::BinOp(BinOp::Lt, left, right) = &subject.node else {
822        return false;
823    };
824    if !is_ident(left, param_name)
825        || !matches!(&right.node, Expr::Literal(crate::ast::Literal::Int(0)))
826    {
827        return false;
828    }
829
830    let mut true_arm = None;
831    let mut false_arm = None;
832    for arm in arms {
833        match arm.pattern {
834            Pattern::Literal(crate::ast::Literal::Bool(true)) => true_arm = Some(arm.body.as_ref()),
835            Pattern::Literal(crate::ast::Literal::Bool(false)) => {
836                false_arm = Some(arm.body.as_ref())
837            }
838            _ => return false,
839        }
840    }
841
842    let Some(true_arm) = true_arm else {
843        return false;
844    };
845    let Some(false_arm) = false_arm else {
846        return false;
847    };
848
849    let mut true_calls = Vec::new();
850    collect_calls_from_expr(true_arm, &mut true_calls);
851    let mut false_calls = Vec::new();
852    collect_calls_from_expr(false_arm, &mut false_calls);
853
854    true_calls
855        .iter()
856        .any(|(name, _)| call_matches(name, &fd.name))
857        && false_calls
858            .iter()
859            .all(|(name, _)| !call_matches(name, &fd.name))
860}
861
862/// Detect ascending-index recursion and extract the bound expression
863/// as an Aver AST (`Spanned<Expr>`). Returns `(param_index, bound)`.
864pub(crate) fn single_int_ascending_param(fd: &FnDef) -> Option<(usize, Spanned<Expr>)> {
865    let recursive_calls: Vec<Vec<&Spanned<Expr>>> = collect_calls_from_body(fd.body.as_ref())
866        .into_iter()
867        .filter(|(name, _)| call_matches(name, &fd.name))
868        .map(|(_, args)| args)
869        .collect();
870    if recursive_calls.is_empty() {
871        return None;
872    }
873
874    for (idx, (param_name, param_ty)) in fd.params.iter().enumerate() {
875        if param_ty != "Int" {
876            continue;
877        }
878        let ascent_ok = recursive_calls.iter().all(|args| {
879            args.get(idx)
880                .cloned()
881                .is_some_and(|arg| is_int_plus_positive(arg, param_name))
882        });
883        if !ascent_ok {
884            continue;
885        }
886        if let Some(bound) = extract_equality_bound_expr(fd, param_name) {
887            return Some((idx, bound));
888        }
889    }
890    None
891}
892
893/// Extract the bound expression from `match param == BOUND` as an
894/// Aver AST node. Each backend renders this into its own idiom (Lean
895/// via `bound_expr_to_lean`, Dafny via its own `emit_expr` path).
896pub(crate) fn extract_equality_bound_expr(fd: &FnDef, param_name: &str) -> Option<Spanned<Expr>> {
897    let tail = fd.body.tail_expr()?;
898    let Expr::Match { subject, arms, .. } = &tail.node else {
899        return None;
900    };
901    let Expr::BinOp(BinOp::Eq, left, right) = &subject.node else {
902        return None;
903    };
904    if !is_ident(left, param_name) {
905        return None;
906    }
907    // Verify: true arm = base (no self-call), false arm = recursive (has self-call)
908    let mut true_has_self = false;
909    let mut false_has_self = false;
910    for arm in arms {
911        match arm.pattern {
912            Pattern::Literal(crate::ast::Literal::Bool(true)) => {
913                let mut calls = Vec::new();
914                collect_calls_from_expr(&arm.body, &mut calls);
915                true_has_self = calls.iter().any(|(n, _)| call_matches(n, &fd.name));
916            }
917            Pattern::Literal(crate::ast::Literal::Bool(false)) => {
918                let mut calls = Vec::new();
919                collect_calls_from_expr(&arm.body, &mut calls);
920                false_has_self = calls.iter().any(|(n, _)| call_matches(n, &fd.name));
921            }
922            _ => return None,
923        }
924    }
925    if true_has_self || !false_has_self {
926        return None;
927    }
928    Some((**right).clone())
929}
930
931pub(crate) fn supports_single_sizeof_structural(fd: &FnDef, inputs: &ProofLowerInputs) -> bool {
932    let recursive_calls: Vec<Vec<&Spanned<Expr>>> = collect_calls_from_body(fd.body.as_ref())
933        .into_iter()
934        .filter(|(name, _)| call_matches(name, &fd.name))
935        .map(|(_, args)| args)
936        .collect();
937    if recursive_calls.is_empty() {
938        return false;
939    }
940
941    let metric_indices = sizeof_measure_param_indices(fd);
942    if metric_indices.is_empty() {
943        return false;
944    }
945
946    let binder_sets: HashMap<usize, HashSet<String>> = metric_indices
947        .iter()
948        .filter_map(|idx| {
949            let (param_name, param_type) = fd.params.get(*idx)?;
950            inputs.recursive_type_names().contains(param_type).then(|| {
951                (
952                    *idx,
953                    collect_recursive_subterm_binders(fd, param_name, param_type, inputs),
954                )
955            })
956        })
957        .collect();
958
959    if binder_sets.values().all(HashSet::is_empty) {
960        return false;
961    }
962
963    recursive_calls.iter().all(|args| {
964        let mut strictly_smaller = false;
965        for idx in &metric_indices {
966            let Some((param_name, _)) = fd.params.get(*idx) else {
967                return false;
968            };
969            let Some(arg) = args.get(*idx).cloned() else {
970                return false;
971            };
972            if is_ident(arg, param_name) {
973                continue;
974            }
975            let Some(binders) = binder_sets.get(idx) else {
976                return false;
977            };
978            if local_name_of(arg).is_some_and(|id| binders.contains(id)) {
979                strictly_smaller = true;
980                continue;
981            }
982            return false;
983        }
984        strictly_smaller
985    })
986}
987
988pub(crate) fn single_list_structural_param_index(fd: &FnDef) -> Option<usize> {
989    fd.params
990        .iter()
991        .enumerate()
992        .find_map(|(param_index, (param_name, param_ty))| {
993            if !(param_ty.starts_with("List<") || param_ty == "List") {
994                return None;
995            }
996
997            let tails = collect_list_tail_binders(fd, param_name);
998            if tails.is_empty() {
999                return None;
1000            }
1001
1002            let recursive_calls: Vec<Option<&Spanned<Expr>>> =
1003                collect_calls_from_body(fd.body.as_ref())
1004                    .into_iter()
1005                    .filter(|(name, _)| call_matches(name, &fd.name))
1006                    .map(|(_, args)| args.get(param_index).cloned())
1007                    .collect();
1008            if recursive_calls.is_empty() {
1009                return None;
1010            }
1011
1012            recursive_calls
1013                .into_iter()
1014                .all(|arg| {
1015                    arg.and_then(local_name_of)
1016                        .is_some_and(|id| tails.contains(id))
1017                })
1018                .then_some(param_index)
1019        })
1020}
1021
1022/// `true` iff every recursive self-call of `fd` passes, at `param_index`, a
1023/// bare local that is NOT the param itself — i.e. the param is consumed in the
1024/// recursion (the `z` of `match p { S(z) -> recurse(z, ...) }`). Used to spot a
1025/// Peano param a list-recursive fn decrements SYNCHRONOUSLY with the list
1026/// (`take`/`drop`), which must then be GENERALIZED in the induction or the cons
1027/// IH lands at the wrong predecessor. Conservative: a non-local arg (`p - 1`)
1028/// yields `false`, so this fires only on the clean succ-binder shape.
1029pub(crate) fn param_decremented_in_recursion(fd: &FnDef, param_index: usize) -> bool {
1030    let calls: Vec<(String, Vec<&Spanned<Expr>>)> = collect_calls_from_body(fd.body.as_ref())
1031        .into_iter()
1032        .filter(|(name, _)| call_matches(name, &fd.name))
1033        .collect();
1034    if calls.is_empty() {
1035        return false;
1036    }
1037    let Some((pname, _)) = fd.params.get(param_index) else {
1038        return false;
1039    };
1040    calls.iter().all(|(_, args)| {
1041        args.get(param_index)
1042            .and_then(|a| local_name_of(a))
1043            .is_some_and(|id| id != pname)
1044    })
1045}
1046
1047/// `true` iff every recursive self-call passes at `param_index` a RECONSTRUCTED
1048/// expression (not a bare identifier) — a THREADED accumulator (`qrev`'s `acc`
1049/// gets `List.concat([h], acc)`). The "not a bare local" requirement is what
1050/// separates a genuine accumulator from a SYNCHRONOUS second list: `zip(xs,
1051/// ys)` recurses with `ys`'s tail binder `yt` (a bare local), which is a
1052/// structural decrement to be inducted normally, NOT a threaded accumulator to
1053/// generalize-without-cases. Such a param must be GENERALIZED (so the IH reads
1054/// `∀ acc, P xs acc` and applies at the threaded value) but NOT case-split.
1055pub(crate) fn param_threaded_in_recursion(fd: &FnDef, param_index: usize) -> bool {
1056    let calls: Vec<(String, Vec<&Spanned<Expr>>)> = collect_calls_from_body(fd.body.as_ref())
1057        .into_iter()
1058        .filter(|(name, _)| call_matches(name, &fd.name))
1059        .collect();
1060    if calls.is_empty() {
1061        return false;
1062    }
1063    calls.iter().all(|(_, args)| {
1064        args.get(param_index)
1065            .is_some_and(|a| local_name_of(a).is_none())
1066    })
1067}
1068
1069/// `true` iff `fd` is a binary fn that recurses by peeling a constructor off
1070/// BOTH arguments synchronously — the canonical `max`/`min` shape:
1071/// `match p0 { base -> …; succ(z) -> match p1 { base -> …; succ(w) -> …rec(z, w)… } }`.
1072/// Exactly one self-call, reached only from inside both succ arms, passing the
1073/// two inner succ binders (NOT either original param) at positions 0 and 1.
1074///
1075/// This is the recursion shape that a commutativity (`f a b = f b a`) or
1076/// associativity (`f (f a b) c = f a (f b c)`) proof must induct on with
1077/// `induction a generalizing <rest> with | zero => cases <rest> … | succ k ih =>
1078/// cases <rest> …`: inducting on one arg alone leaves the other arg's peel
1079/// stuck. Conservative — both params must share one type, there must be exactly
1080/// one self-call, and its two args must be the freshly-bound succ predecessors,
1081/// so it fires only on the genuine both-args-peeling family and never on a
1082/// single-arg-recursive fn (whose self-call repeats one original param).
1083pub(crate) fn recurses_decrementing_both_args(fd: &FnDef) -> bool {
1084    if fd.params.len() != 2 {
1085        return false;
1086    }
1087    let (p0, t0) = &fd.params[0];
1088    let (p1, t1) = &fd.params[1];
1089    if t0 != t1 {
1090        return false;
1091    }
1092    // Exactly one self-call, both args bare locals distinct from the originals.
1093    let calls: Vec<Vec<&Spanned<Expr>>> = collect_calls_from_body(fd.body.as_ref())
1094        .into_iter()
1095        .filter(|(name, _)| call_matches(name, &fd.name))
1096        .map(|(_, args)| args)
1097        .collect();
1098    if calls.len() != 1 {
1099        return false;
1100    }
1101    let args = &calls[0];
1102    let (Some(a0), Some(a1)) = (
1103        args.first().and_then(|a| local_name_of(a)),
1104        args.get(1).and_then(|a| local_name_of(a)),
1105    ) else {
1106        return false;
1107    };
1108    if a0 == p0 || a0 == p1 || a1 == p0 || a1 == p1 || a0 == a1 {
1109        return false;
1110    }
1111    // Outer match on p0 with a succ arm binding `a0`, whose body is an inner
1112    // match on p1 with a succ arm binding `a1`. Mirrors `peano_outer_split` but
1113    // stays local to detect.rs (which has no `CodegenContext`/Peano lookup).
1114    let Some(tail) = fd.body.tail_expr() else {
1115        return false;
1116    };
1117    let Expr::Match { subject, arms, .. } = &tail.node else {
1118        return false;
1119    };
1120    if local_name_of(subject) != Some(p0.as_str()) {
1121        return false;
1122    }
1123    arms.iter().any(|arm| {
1124        let Pattern::Constructor(_, binders) = &arm.pattern else {
1125            return false;
1126        };
1127        if binders.len() != 1 || binders[0] != a0 {
1128            return false;
1129        }
1130        let Expr::Match {
1131            subject: inner_subj,
1132            arms: inner_arms,
1133            ..
1134        } = &arm.body.node
1135        else {
1136            return false;
1137        };
1138        if local_name_of(inner_subj) != Some(p1.as_str()) {
1139            return false;
1140        }
1141        inner_arms.iter().any(|inner| {
1142            matches!(&inner.pattern,
1143                Pattern::Constructor(_, b) if b.len() == 1 && b[0] == a1)
1144        })
1145    })
1146}
1147
1148pub(crate) fn is_ident(expr: &Spanned<Expr>, name: &str) -> bool {
1149    local_name_of(expr).is_some_and(|id| id == name)
1150}
1151
1152pub(crate) fn is_int_plus_positive(expr: &Spanned<Expr>, param_name: &str) -> bool {
1153    match &expr.node {
1154        Expr::BinOp(BinOp::Add, left, right) => {
1155            local_name_of(left).is_some_and(|id| id == param_name)
1156                && matches!(&right.node, Expr::Literal(crate::ast::Literal::Int(n)) if *n >= 1)
1157        }
1158        Expr::FnCall(callee, args) => {
1159            let Some(name) = expr_to_dotted_name(callee) else {
1160                return false;
1161            };
1162            (name == "Int.add" || name == "int.add")
1163                && args.len() == 2
1164                && local_name_of(&args[0]).is_some_and(|id| id == param_name)
1165                && matches!(&args[1].node, Expr::Literal(crate::ast::Literal::Int(n)) if *n >= 1)
1166        }
1167        _ => false,
1168    }
1169}
1170
1171pub(crate) fn is_skip_ws_advance(
1172    expr: &Spanned<Expr>,
1173    string_param: &str,
1174    pos_param: &str,
1175) -> bool {
1176    let Expr::FnCall(callee, args) = &expr.node else {
1177        return false;
1178    };
1179    let Some(name) = expr_to_dotted_name(callee) else {
1180        return false;
1181    };
1182    if !call_matches(&name, "skipWs") || args.len() != 2 {
1183        return false;
1184    }
1185    is_ident(&args[0], string_param) && is_int_plus_positive(&args[1], pos_param)
1186}
1187
1188pub(crate) fn is_skip_ws_same(expr: &Spanned<Expr>, string_param: &str, pos_param: &str) -> bool {
1189    let Expr::FnCall(callee, args) = &expr.node else {
1190        return false;
1191    };
1192    let Some(name) = expr_to_dotted_name(callee) else {
1193        return false;
1194    };
1195    if !call_matches(&name, "skipWs") || args.len() != 2 {
1196        return false;
1197    }
1198    is_ident(&args[0], string_param) && is_ident(&args[1], pos_param)
1199}
1200
1201pub(crate) fn is_string_pos_advance(
1202    expr: &Spanned<Expr>,
1203    string_param: &str,
1204    pos_param: &str,
1205) -> bool {
1206    is_int_plus_positive(expr, pos_param) || is_skip_ws_advance(expr, string_param, pos_param)
1207}
1208
1209#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1210pub(crate) enum StringPosEdge {
1211    Same,
1212    Advance,
1213}
1214
1215pub(crate) fn classify_string_pos_edge(
1216    expr: &Spanned<Expr>,
1217    string_param: &str,
1218    pos_param: &str,
1219) -> Option<StringPosEdge> {
1220    if is_ident(expr, pos_param) || is_skip_ws_same(expr, string_param, pos_param) {
1221        return Some(StringPosEdge::Same);
1222    }
1223    if is_string_pos_advance(expr, string_param, pos_param) {
1224        return Some(StringPosEdge::Advance);
1225    }
1226    if let Expr::FnCall(callee, args) = &expr.node {
1227        let name = expr_to_dotted_name(callee)?;
1228        if call_matches(&name, "skipWs")
1229            && args.len() == 2
1230            && is_ident(&args[0], string_param)
1231            && local_name_of(&args[1]).is_some_and(|id| id != pos_param)
1232        {
1233            return Some(StringPosEdge::Advance);
1234        }
1235    }
1236    if local_name_of(expr).is_some_and(|id| id != pos_param) {
1237        return Some(StringPosEdge::Advance);
1238    }
1239    None
1240}
1241
1242pub(crate) fn ranks_from_same_edges(
1243    names: &HashSet<String>,
1244    same_edges: &HashMap<String, HashSet<String>>,
1245) -> Option<HashMap<String, usize>> {
1246    let mut indegree: HashMap<String, usize> = names.iter().map(|n| (n.clone(), 0)).collect();
1247    for outs in same_edges.values() {
1248        for to in outs {
1249            if let Some(entry) = indegree.get_mut(to) {
1250                *entry += 1;
1251            } else {
1252                return None;
1253            }
1254        }
1255    }
1256
1257    let mut queue: Vec<String> = indegree
1258        .iter()
1259        .filter_map(|(name, &deg)| (deg == 0).then_some(name.clone()))
1260        .collect();
1261    queue.sort();
1262    let mut topo = Vec::new();
1263    while let Some(node) = queue.pop() {
1264        topo.push(node.clone());
1265        let outs = same_edges.get(&node).cloned().unwrap_or_default();
1266        let mut newly_zero = Vec::new();
1267        for to in outs {
1268            if let Some(entry) = indegree.get_mut(&to) {
1269                *entry -= 1;
1270                if *entry == 0 {
1271                    newly_zero.push(to);
1272                }
1273            } else {
1274                return None;
1275            }
1276        }
1277        newly_zero.sort();
1278        queue.extend(newly_zero);
1279    }
1280
1281    if topo.len() != names.len() {
1282        return None;
1283    }
1284
1285    let n = topo.len();
1286    let mut ranks = HashMap::new();
1287    for (idx, name) in topo.into_iter().enumerate() {
1288        ranks.insert(name, n - idx);
1289    }
1290    Some(ranks)
1291}
1292
1293pub(crate) fn supports_single_string_pos_advance(fd: &FnDef) -> bool {
1294    let Some((string_param, string_ty)) = fd.params.first() else {
1295        return false;
1296    };
1297    let Some((pos_param, pos_ty)) = fd.params.get(1) else {
1298        return false;
1299    };
1300    if string_ty != "String" || pos_ty != "Int" {
1301        return false;
1302    }
1303
1304    type CallPair<'a> = (Option<&'a Spanned<Expr>>, Option<&'a Spanned<Expr>>);
1305    let recursive_calls: Vec<CallPair<'_>> = collect_calls_from_body(fd.body.as_ref())
1306        .into_iter()
1307        .filter(|(name, _)| call_matches(name, &fd.name))
1308        .map(|(_, args)| (args.first().cloned(), args.get(1).cloned()))
1309        .collect();
1310    if recursive_calls.is_empty() {
1311        return false;
1312    }
1313
1314    recursive_calls.into_iter().all(|(arg0, arg1)| {
1315        arg0.is_some_and(|e| is_ident(e, string_param))
1316            && arg1.is_some_and(|e| is_string_pos_advance(e, string_param, pos_param))
1317    })
1318}
1319
1320pub(crate) fn supports_mutual_int_countdown(component: &[&FnDef]) -> bool {
1321    if component.len() < 2 {
1322        return false;
1323    }
1324    if component
1325        .iter()
1326        .any(|fd| !matches!(fd.params.first(), Some((_, t)) if t == "Int"))
1327    {
1328        return false;
1329    }
1330    let names: HashSet<String> = component.iter().map(|fd| fd.name.clone()).collect();
1331    let mut any_intra = false;
1332    for fd in component {
1333        let param_name = &fd.params[0].0;
1334        for (callee, args) in collect_calls_from_body(fd.body.as_ref()) {
1335            if !call_is_in_set(&callee, &names) {
1336                continue;
1337            }
1338            any_intra = true;
1339            let Some(arg0) = args.first().cloned() else {
1340                return false;
1341            };
1342            if !is_int_minus_positive(arg0, param_name) {
1343                return false;
1344            }
1345        }
1346    }
1347    any_intra
1348}
1349
1350pub(crate) fn supports_mutual_string_pos_advance(
1351    component: &[&FnDef],
1352) -> Option<HashMap<String, usize>> {
1353    if component.len() < 2 {
1354        return None;
1355    }
1356    if component.iter().any(|fd| {
1357        !matches!(fd.params.first(), Some((_, t)) if t == "String")
1358            || !matches!(fd.params.get(1), Some((_, t)) if t == "Int")
1359    }) {
1360        return None;
1361    }
1362
1363    let names: HashSet<String> = component.iter().map(|fd| fd.name.clone()).collect();
1364    let mut same_edges: HashMap<String, HashSet<String>> =
1365        names.iter().map(|n| (n.clone(), HashSet::new())).collect();
1366    let mut any_intra = false;
1367
1368    for fd in component {
1369        let string_param = &fd.params[0].0;
1370        let pos_param = &fd.params[1].0;
1371        for (callee_raw, args) in collect_calls_from_body(fd.body.as_ref()) {
1372            let Some(callee) = canonical_callee_name(&callee_raw, &names) else {
1373                continue;
1374            };
1375            any_intra = true;
1376
1377            let arg0 = args.first().cloned()?;
1378            let arg1 = args.get(1).cloned()?;
1379
1380            if !is_ident(arg0, string_param) {
1381                return None;
1382            }
1383
1384            match classify_string_pos_edge(arg1, string_param, pos_param) {
1385                Some(StringPosEdge::Same) => {
1386                    if let Some(edges) = same_edges.get_mut(&fd.name) {
1387                        edges.insert(callee);
1388                    } else {
1389                        return None;
1390                    }
1391                }
1392                Some(StringPosEdge::Advance) => {}
1393                None => return None,
1394            }
1395        }
1396    }
1397
1398    if !any_intra {
1399        return None;
1400    }
1401
1402    ranks_from_same_edges(&names, &same_edges)
1403}
1404
1405pub(crate) fn is_scalar_like_type(type_name: &str) -> bool {
1406    matches!(
1407        type_name,
1408        "Int" | "Float" | "Bool" | "String" | "Char" | "Byte" | "Unit"
1409    )
1410}
1411
1412/// SizeOf-measure param indices for a fn — every non-scalar param
1413/// position contributes a term to the structural measure. Matches
1414/// the picks made for the Lean `termination_by` clause and the
1415/// Dafny native `decreases` tuple so the same params drive measure
1416/// inference on both backends.
1417pub fn sizeof_measure_param_indices(fd: &FnDef) -> Vec<usize> {
1418    fd.params
1419        .iter()
1420        .enumerate()
1421        .filter_map(|(idx, (_, type_name))| (!is_scalar_like_type(type_name)).then_some(idx))
1422        .collect()
1423}
1424
1425/// True iff some intra-SCC call passes a non-trivially-shrinking
1426/// expression into a callee's sizeOf parameter — tail-recursive
1427/// accumulator patterns like `[x] + acc` / `List.prepend(x, acc)`
1428/// / `acc + [x]`, plus arbitrary `FnCall(...)` results we can't
1429/// statically prove shrink. These all fail termination-by-measure
1430/// even though fuel encoding handles them fine; backends fall back
1431/// to fuel for those SCCs.
1432pub fn scc_has_growing_accumulator(fns: &[&FnDef]) -> bool {
1433    let names: HashSet<String> = fns.iter().map(|fd| fd.name.clone()).collect();
1434    let mut sizeof_indices: HashMap<String, Vec<usize>> = HashMap::new();
1435    for fd in fns {
1436        sizeof_indices.insert(fd.name.clone(), sizeof_measure_param_indices(fd));
1437    }
1438    for fd in fns {
1439        // String interpolation in a body that ends up calling an
1440        // intra-SCC fn confuses Lean's wf elaboration — the recursive
1441        // call lives inside the interpolation's anonymous `s!"{...}"`
1442        // expansion, and the wf check pins anonymous binders the
1443        // tactic chain can't pin back to the source-named param.
1444        // Wumpus's `roomListItem` (`_ => s!"{rs}, {roomList rest}"`)
1445        // is the canonical shape. Fall back to fuel for this case.
1446        if body_has_intra_scc_call_in_interpolation(fd.body.as_ref(), &names) {
1447            return true;
1448        }
1449        for (callee_raw, args) in collect_calls_from_body(fd.body.as_ref()) {
1450            let Some(callee_name) = canonical_callee_name(&callee_raw, &names) else {
1451                continue;
1452            };
1453            let Some(callee_indices) = sizeof_indices.get(&callee_name) else {
1454                continue;
1455            };
1456            for callee_idx in callee_indices {
1457                let Some(arg) = args.get(*callee_idx) else {
1458                    continue;
1459                };
1460                if !arg_is_non_growing(arg) {
1461                    return true;
1462                }
1463            }
1464        }
1465    }
1466    false
1467}
1468
1469fn body_has_intra_scc_call_in_interpolation(body: &FnBody, names: &HashSet<String>) -> bool {
1470    let FnBody::Block(stmts) = body;
1471    stmts.iter().any(|s| match s {
1472        Stmt::Binding(_, _, e) | Stmt::Expr(e) => {
1473            expr_has_intra_scc_call_in_interpolation(e, names)
1474        }
1475    })
1476}
1477
1478fn expr_has_intra_scc_call_in_interpolation(expr: &Spanned<Expr>, names: &HashSet<String>) -> bool {
1479    use crate::ast::StrPart;
1480    match &expr.node {
1481        Expr::InterpolatedStr(parts) => parts.iter().any(|p| match p {
1482            StrPart::Parsed(inner) => expr_calls_into_set(inner, names),
1483            _ => false,
1484        }),
1485        Expr::Match { subject, arms, .. } => {
1486            expr_has_intra_scc_call_in_interpolation(subject, names)
1487                || arms
1488                    .iter()
1489                    .any(|a| expr_has_intra_scc_call_in_interpolation(&a.body, names))
1490        }
1491        Expr::FnCall(f, args) => {
1492            expr_has_intra_scc_call_in_interpolation(f, names)
1493                || args
1494                    .iter()
1495                    .any(|a| expr_has_intra_scc_call_in_interpolation(a, names))
1496        }
1497        Expr::TailCall(boxed) => boxed
1498            .args
1499            .iter()
1500            .any(|a| expr_has_intra_scc_call_in_interpolation(a, names)),
1501        Expr::BinOp(_, l, r) => {
1502            expr_has_intra_scc_call_in_interpolation(l, names)
1503                || expr_has_intra_scc_call_in_interpolation(r, names)
1504        }
1505        Expr::Constructor(_, Some(inner)) | Expr::Attr(inner, _) | Expr::Neg(inner) => {
1506            expr_has_intra_scc_call_in_interpolation(inner, names)
1507        }
1508        _ => false,
1509    }
1510}
1511
1512fn expr_calls_into_set(expr: &Spanned<Expr>, names: &HashSet<String>) -> bool {
1513    match &expr.node {
1514        Expr::FnCall(f, args) => {
1515            let head_hit = expr_to_dotted_name(f)
1516                .as_deref()
1517                .map(|n| canonical_callee_name(n, names).is_some())
1518                .unwrap_or(false);
1519            head_hit
1520                || args.iter().any(|a| expr_calls_into_set(a, names))
1521                || expr_calls_into_set(f, names)
1522        }
1523        Expr::TailCall(boxed) => {
1524            canonical_callee_name(&boxed.target, names).is_some()
1525                || boxed.args.iter().any(|a| expr_calls_into_set(a, names))
1526        }
1527        Expr::Attr(inner, _) | Expr::Neg(inner) | Expr::Constructor(_, Some(inner)) => {
1528            expr_calls_into_set(inner, names)
1529        }
1530        Expr::BinOp(_, l, r) => expr_calls_into_set(l, names) || expr_calls_into_set(r, names),
1531        Expr::Match { subject, arms, .. } => {
1532            expr_calls_into_set(subject, names)
1533                || arms.iter().any(|a| expr_calls_into_set(&a.body, names))
1534        }
1535        _ => false,
1536    }
1537}
1538
1539fn arg_is_non_growing(expr: &Spanned<Expr>) -> bool {
1540    matches!(
1541        &expr.node,
1542        Expr::Ident(_) | Expr::Resolved { .. } | Expr::Literal(_) | Expr::Attr(..)
1543    )
1544}
1545
1546pub(crate) fn supports_mutual_sizeof_ranked(
1547    component: &[&FnDef],
1548) -> Option<HashMap<String, usize>> {
1549    if component.len() < 2 {
1550        return None;
1551    }
1552    let names: HashSet<String> = component.iter().map(|fd| fd.name.clone()).collect();
1553    let metric_indices: HashMap<String, Vec<usize>> = component
1554        .iter()
1555        .map(|fd| (fd.name.clone(), sizeof_measure_param_indices(fd)))
1556        .collect();
1557    if component.iter().any(|fd| {
1558        metric_indices
1559            .get(&fd.name)
1560            .is_none_or(|indices| indices.is_empty())
1561    }) {
1562        return None;
1563    }
1564
1565    let mut same_edges: HashMap<String, HashSet<String>> =
1566        names.iter().map(|n| (n.clone(), HashSet::new())).collect();
1567    let mut any_intra = false;
1568    for fd in component {
1569        let caller_metric_indices = metric_indices.get(&fd.name)?;
1570        let caller_metric_params: Vec<&str> = caller_metric_indices
1571            .iter()
1572            .filter_map(|idx| fd.params.get(*idx).map(|(name, _)| name.as_str()))
1573            .collect();
1574        for (callee_raw, args) in collect_calls_from_body(fd.body.as_ref()) {
1575            let Some(callee) = canonical_callee_name(&callee_raw, &names) else {
1576                continue;
1577            };
1578            any_intra = true;
1579            let callee_metric_indices = metric_indices.get(&callee)?;
1580            let is_same_edge = callee_metric_indices.len() == caller_metric_params.len()
1581                && callee_metric_indices
1582                    .iter()
1583                    .enumerate()
1584                    .all(|(pos, callee_idx)| {
1585                        let Some(arg) = args.get(*callee_idx).cloned() else {
1586                            return false;
1587                        };
1588                        is_ident(arg, caller_metric_params[pos])
1589                    });
1590            if is_same_edge {
1591                if let Some(edges) = same_edges.get_mut(&fd.name) {
1592                    edges.insert(callee);
1593                } else {
1594                    return None;
1595                }
1596            }
1597        }
1598    }
1599    if !any_intra {
1600        return None;
1601    }
1602
1603    let ranks = ranks_from_same_edges(&names, &same_edges)?;
1604    let mut out = HashMap::new();
1605    for fd in component {
1606        let rank = ranks.get(&fd.name).cloned()?;
1607        out.insert(fd.name.clone(), rank);
1608    }
1609    Some(out)
1610}
1611
1612/// True when every callsite of `fn_name` visible inside `ctx` is also
1613/// inside `ctx` — i.e. no module that uses `ctx` for proof emission can
1614/// have an external caller we can't analyze. Holds when either:
1615/// - the fn lives in the entry items and the entry module has no
1616///   explicit `exposes` clause (single-file program or implicitly-
1617///   private surface), or has an `exposes` list that omits `fn_name`;
1618/// - the fn lives in a dep module and that dep module's `exposes`
1619///   omits `fn_name`. Dep modules without an `exposes` clause are
1620///   conservative — treated as exposed since downstream consumers may
1621///   pull them in unscoped; we don't yet thread that distinction so
1622///   the conservative side is "not closed-world".
1623///
1624/// The `ctx.items` carry a `TopLevel::Module(m)` for entry; dep
1625/// modules currently surface no `Module` block in `ModuleInfo`, so
1626/// the dep-module branch returns `false` until that plumbing is
1627/// added. Practically this matches the issue-84 scope: per-file
1628/// proof targets (fibonacci.av) where the analyzed fn is in the
1629/// entry module.
1630pub(crate) fn is_closed_world_pure_fn(fn_name: &str, inputs: &ProofLowerInputs) -> bool {
1631    let entry_module = inputs.entry_items.iter().find_map(|item| match item {
1632        TopLevel::Module(m) => Some(m),
1633        _ => None,
1634    });
1635    let entry_fn_names: std::collections::HashSet<&str> = inputs
1636        .entry_items
1637        .iter()
1638        .filter_map(|item| match item {
1639            TopLevel::FnDef(fd) => Some(fd.name.as_str()),
1640            _ => None,
1641        })
1642        .collect();
1643    if entry_fn_names.contains(fn_name) {
1644        match entry_module {
1645            None => return true,
1646            Some(m) => {
1647                if m.exposes_line.is_none() {
1648                    return true;
1649                }
1650                return !m.exposes.iter().any(|e| e == fn_name);
1651            }
1652        }
1653    }
1654    false
1655}
1656
1657/// Find the unique external caller of `target_fn` in `ctx` and return
1658/// the path-constraint predicates that wrap the callsite — flipped to
1659/// positive form and substituted into the callee's variable space.
1660///
1661/// Returns `None` when:
1662/// - there are zero or more than one callers (the issue-84 single-caller
1663///   simplification — same idea as opaque types having one smart
1664///   constructor);
1665/// - the caller has multiple callsites with non-identical enclosing
1666///   guards (no single weakest precondition);
1667/// - the arg passed at the countdown-param position isn't a plain local
1668///   name (caller did `worker(n + 5)` or `worker(f(n))` — substitution
1669///   would need arithmetic the proof emitter doesn't run);
1670/// - any enclosing guard is `match (non-bool expression) { ... }` —
1671///   only `match Bool { true/false -> ... }` and `if/then/else` over
1672///   bool subjects give a flat predicate, anything else is rejected so
1673///   the fall-through to fuel stays sound.
1674///
1675/// On success the returned list's conjunction is the precondition for
1676/// `target_fn`'s aux: every clause is a positive `Spanned<Expr>` Bool
1677/// predicate in the callee's variable space, ready to feed straight
1678/// into `emit_expr` the same way opaque-type predicates do.
1679pub(crate) fn find_single_external_caller_predicate(
1680    target_fn: &str,
1681    target_param_index: usize,
1682    callee_param_name: &str,
1683    inputs: &ProofLowerInputs,
1684) -> Option<Vec<Spanned<Expr>>> {
1685    let all_callers: Vec<&FnDef> = inputs
1686        .entry_items
1687        .iter()
1688        .filter_map(|item| match item {
1689            TopLevel::FnDef(fd) if fd.name != target_fn => Some(fd),
1690            _ => None,
1691        })
1692        .chain(
1693            inputs
1694                .dep_modules
1695                .iter()
1696                .flat_map(|m| m.fn_defs.iter().filter(|fd| fd.name != target_fn)),
1697        )
1698        .filter(|fd| {
1699            collect_calls_from_body(fd.body.as_ref())
1700                .iter()
1701                .any(|(n, _)| call_matches(n, target_fn))
1702        })
1703        .collect();
1704
1705    if all_callers.len() != 1 {
1706        return None;
1707    }
1708    let caller = all_callers[0];
1709
1710    let mut callsites: Vec<(Vec<Spanned<Expr>>, String)> = Vec::new();
1711    for stmt in caller.body.stmts() {
1712        match stmt {
1713            Stmt::Binding(_, _, expr) | Stmt::Expr(expr) => {
1714                walk_caller_collect_callsite_guards(
1715                    expr,
1716                    target_fn,
1717                    target_param_index,
1718                    &[],
1719                    &mut callsites,
1720                );
1721            }
1722        }
1723    }
1724
1725    if callsites.is_empty() {
1726        return None;
1727    }
1728
1729    // For multiple callsites: require IDENTICAL guard lists (else the
1730    // weakest precondition isn't trivial and we punt to fuel).
1731    let (first_guards, first_arg_name) = &callsites[0];
1732    for (other_guards, other_arg_name) in &callsites[1..] {
1733        if other_arg_name != first_arg_name || other_guards != first_guards {
1734            return None;
1735        }
1736    }
1737
1738    let arg_name = first_arg_name.as_str();
1739    let predicate = first_guards
1740        .iter()
1741        .filter(|g| crate::codegen::recursion::expr_references_ident(g, arg_name))
1742        .map(|g| {
1743            crate::codegen::recursion::substitute_ident_in_expr(g, arg_name, callee_param_name)
1744        })
1745        .collect::<Vec<_>>();
1746
1747    Some(predicate)
1748}
1749
1750/// Walker that collects all callsites of `target_fn` inside `expr`,
1751/// pairing each with (a) the chain of enclosing `match Bool` /
1752/// `if-then-else` guards in **positive form** (false-arm guards
1753/// flipped via `flip_comparison_binop`) and (b) the caller-side
1754/// variable name passed at `target_param_index`.
1755fn walk_caller_collect_callsite_guards(
1756    expr: &Spanned<Expr>,
1757    target_fn: &str,
1758    target_param_index: usize,
1759    enclosing_guards: &[Spanned<Expr>],
1760    out: &mut Vec<(Vec<Spanned<Expr>>, String)>,
1761) {
1762    match &expr.node {
1763        Expr::FnCall(callee, args) => {
1764            if let Some(name) = expr_to_dotted_name(callee)
1765                && call_matches(&name, target_fn)
1766                && let Some(arg_at_idx) = args.get(target_param_index)
1767                && let Some(arg_name) = local_name_of(arg_at_idx)
1768            {
1769                out.push((enclosing_guards.to_vec(), arg_name.to_string()));
1770            }
1771            walk_caller_collect_callsite_guards(
1772                callee,
1773                target_fn,
1774                target_param_index,
1775                enclosing_guards,
1776                out,
1777            );
1778            for arg in args {
1779                walk_caller_collect_callsite_guards(
1780                    arg,
1781                    target_fn,
1782                    target_param_index,
1783                    enclosing_guards,
1784                    out,
1785                );
1786            }
1787        }
1788        Expr::TailCall(boxed) => {
1789            for arg in &boxed.args {
1790                walk_caller_collect_callsite_guards(
1791                    arg,
1792                    target_fn,
1793                    target_param_index,
1794                    enclosing_guards,
1795                    out,
1796                );
1797            }
1798        }
1799        Expr::Match { subject, arms } => {
1800            for arm in arms {
1801                let mut new_guards: Vec<Spanned<Expr>> = enclosing_guards.to_vec();
1802                match &arm.pattern {
1803                    Pattern::Literal(crate::ast::Literal::Bool(true)) => {
1804                        new_guards.push((**subject).clone());
1805                    }
1806                    Pattern::Literal(crate::ast::Literal::Bool(false)) => {
1807                        if let Some(flipped) =
1808                            crate::codegen::recursion::flip_comparison_binop(subject)
1809                        {
1810                            new_guards.push(flipped);
1811                        } else {
1812                            // Subject isn't a comparison BinOp — can't
1813                            // flip into a positive Aver expression.
1814                            // Drop the guard so the predicate stays
1815                            // sound (subset of true constraints).
1816                        }
1817                    }
1818                    _ => {
1819                        // Non-bool arm (e.g., `0 -> ...`, `[head, ..tail]
1820                        // -> ...`). Bind constraints aren't part of the
1821                        // bool-predicate vocabulary; treat the arm as
1822                        // transparent.
1823                    }
1824                }
1825                walk_caller_collect_callsite_guards(
1826                    &arm.body,
1827                    target_fn,
1828                    target_param_index,
1829                    &new_guards,
1830                    out,
1831                );
1832            }
1833            walk_caller_collect_callsite_guards(
1834                subject,
1835                target_fn,
1836                target_param_index,
1837                enclosing_guards,
1838                out,
1839            );
1840        }
1841        Expr::BinOp(_, l, r) => {
1842            walk_caller_collect_callsite_guards(
1843                l,
1844                target_fn,
1845                target_param_index,
1846                enclosing_guards,
1847                out,
1848            );
1849            walk_caller_collect_callsite_guards(
1850                r,
1851                target_fn,
1852                target_param_index,
1853                enclosing_guards,
1854                out,
1855            );
1856        }
1857        Expr::Attr(inner, _) | Expr::Neg(inner) | Expr::ErrorProp(inner) => {
1858            walk_caller_collect_callsite_guards(
1859                inner,
1860                target_fn,
1861                target_param_index,
1862                enclosing_guards,
1863                out,
1864            );
1865        }
1866        Expr::Constructor(_, arg) => {
1867            if let Some(inner) = arg {
1868                walk_caller_collect_callsite_guards(
1869                    inner,
1870                    target_fn,
1871                    target_param_index,
1872                    enclosing_guards,
1873                    out,
1874                );
1875            }
1876        }
1877        Expr::InterpolatedStr(parts) => {
1878            for p in parts {
1879                if let crate::ast::StrPart::Parsed(inner) = p {
1880                    walk_caller_collect_callsite_guards(
1881                        inner,
1882                        target_fn,
1883                        target_param_index,
1884                        enclosing_guards,
1885                        out,
1886                    );
1887                }
1888            }
1889        }
1890        Expr::List(items) | Expr::Tuple(items) | Expr::IndependentProduct(items, _) => {
1891            for item in items {
1892                walk_caller_collect_callsite_guards(
1893                    item,
1894                    target_fn,
1895                    target_param_index,
1896                    enclosing_guards,
1897                    out,
1898                );
1899            }
1900        }
1901        Expr::MapLiteral(entries) => {
1902            for (k, v) in entries {
1903                walk_caller_collect_callsite_guards(
1904                    k,
1905                    target_fn,
1906                    target_param_index,
1907                    enclosing_guards,
1908                    out,
1909                );
1910                walk_caller_collect_callsite_guards(
1911                    v,
1912                    target_fn,
1913                    target_param_index,
1914                    enclosing_guards,
1915                    out,
1916                );
1917            }
1918        }
1919        Expr::RecordCreate { fields, .. } => {
1920            for (_, v) in fields {
1921                walk_caller_collect_callsite_guards(
1922                    v,
1923                    target_fn,
1924                    target_param_index,
1925                    enclosing_guards,
1926                    out,
1927                );
1928            }
1929        }
1930        Expr::RecordUpdate { base, updates, .. } => {
1931            walk_caller_collect_callsite_guards(
1932                base,
1933                target_fn,
1934                target_param_index,
1935                enclosing_guards,
1936                out,
1937            );
1938            for (_, v) in updates {
1939                walk_caller_collect_callsite_guards(
1940                    v,
1941                    target_fn,
1942                    target_param_index,
1943                    enclosing_guards,
1944                    out,
1945                );
1946            }
1947        }
1948        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } => {}
1949    }
1950}
1951
1952/// Classify every recursive pure fn in `inputs`. The returned map
1953/// assigns each supported function a [`RecursionPlan`]; anything
1954/// that falls outside the recognised shapes becomes a
1955/// [`ProofModeIssue`].
1956///
1957/// **Consumers should not call this directly.** After Step 18 / 20
1958/// the only caller is `proof_lower::populate_fn_contracts`, which
1959/// translates the output into `ProofIR.fn_contracts` and
1960/// `ProofIR.unclassified_fns`. Backends read those fields instead.
1961/// The fn stays `pub` only because the diff test
1962/// (`tests/proof_ir_diff.rs`) cross-checks ProofIR against the
1963/// legacy classifier output — that test is itself slated for
1964/// rationalisation once the migration is fully bedded down.
1965/// Scope-aware classifier. `scope = None` runs against entry only,
1966/// `Some(prefix)` against one dep module. Aver's module DAG
1967/// invariant rules out cross-module recursion SCCs, so per-scope
1968/// classification is the canonical view and avoids two
1969/// same-bare-name fns from different modules colliding in the
1970/// SCC analyser's `HashMap<name, _>`. The `global_view` flag keeps
1971/// the legacy "chain all fns, classify globally" behaviour for
1972/// `tests/proof_ir_diff.rs` cross-check tests; production callers
1973/// always pass `global_view = false` and walk scopes themselves.
1974pub fn analyze_plans_in_scope(
1975    inputs: &ProofLowerInputs,
1976    scope: Option<&str>,
1977    global_view: bool,
1978) -> (HashMap<String, RecursionPlan>, Vec<ProofModeIssue>) {
1979    let mut plans = HashMap::new();
1980    let mut issues = Vec::new();
1981
1982    let all_pure = if global_view {
1983        inputs.pure_fns()
1984    } else {
1985        inputs.pure_fns_in_scope(scope)
1986    };
1987    let recursive_names = if global_view {
1988        inputs.recursive_pure_fn_names()
1989    } else {
1990        inputs.recursive_pure_fn_names_in_scope(scope)
1991    };
1992    let components = call_graph::ordered_fn_components(&all_pure, inputs.module_prefixes);
1993
1994    for component in components {
1995        if component.is_empty() {
1996            continue;
1997        }
1998        let is_recursive_component =
1999            component.len() > 1 || recursive_names.contains(&component[0].name);
2000        if !is_recursive_component {
2001            continue;
2002        }
2003
2004        if component.len() > 1 {
2005            if supports_mutual_int_countdown(&component) {
2006                for fd in &component {
2007                    plans.insert(fd.name.clone(), RecursionPlan::MutualIntCountdown);
2008                }
2009            } else if let Some(ranks) = supports_mutual_string_pos_advance(&component) {
2010                for fd in &component {
2011                    if let Some(rank) = ranks.get(&fd.name).cloned() {
2012                        plans.insert(
2013                            fd.name.clone(),
2014                            RecursionPlan::MutualStringPosAdvance { rank },
2015                        );
2016                    }
2017                }
2018            } else if let Some(rankings) = supports_mutual_sizeof_ranked(&component) {
2019                for fd in &component {
2020                    if let Some(rank) = rankings.get(&fd.name).cloned() {
2021                        plans.insert(fd.name.clone(), RecursionPlan::MutualSizeOfRanked { rank });
2022                    }
2023                }
2024            } else {
2025                let names = component
2026                    .iter()
2027                    .map(|fd| fd.name.clone())
2028                    .collect::<Vec<_>>()
2029                    .join(", ");
2030                let line = component.iter().map(|fd| fd.line).min().unwrap_or(1);
2031                issues.push(ProofModeIssue {
2032                    line,
2033                    message: format!(
2034                        "unsupported mutual recursion group (currently supported in proof mode: Int countdown on first param): {}",
2035                        names
2036                    ),
2037                });
2038            }
2039            continue;
2040        }
2041
2042        let fd = component[0];
2043        if crate::codegen::lean::recurrence::detect_second_order_int_linear_recurrence(fd).is_some()
2044        {
2045            plans.insert(fd.name.clone(), RecursionPlan::LinearRecurrence2);
2046        } else if let Some((param_index, bound)) = single_int_ascending_param(fd) {
2047            plans.insert(
2048                fd.name.clone(),
2049                RecursionPlan::IntAscending { param_index, bound },
2050            );
2051        } else if let Some(param_index) = single_int_countdown_param_index(fd) {
2052            // Try native guarded emission first — when the body has the
2053            // clean `match p { 0 -> BASE; _ -> rec(p-1, ...) }` shape and
2054            // the fn is closed-world (no external module can call it
2055            // with a negative param), Lean can emit a real recursive
2056            // def with `(h : p ≥ 0)` precondition + termination on
2057            // `p.natAbs`. Falls back to the fuel-encoded IntCountdown
2058            // plan when either condition fails.
2059            if is_closed_world_pure_fn(&fd.name, inputs)
2060                && let Some((base_arm_literal, base_arm_body, wildcard_arm_body)) =
2061                    int_countdown_native_arms(fd, param_index)
2062                && let Some((callee_param_name, _)) = fd.params.get(param_index)
2063            {
2064                // Caller-derived precondition first; empty means
2065                // "no single external caller in this artifact" and the
2066                // Lean emitter defaults to `(h_dom : p ≥ 0)` for
2067                // legacy fibTR-shape compatibility.
2068                let precondition = find_single_external_caller_predicate(
2069                    &fd.name,
2070                    param_index,
2071                    callee_param_name,
2072                    inputs,
2073                )
2074                .unwrap_or_default();
2075                plans.insert(
2076                    fd.name.clone(),
2077                    RecursionPlan::IntCountdownGuarded {
2078                        param_index,
2079                        base_arm_literal,
2080                        base_arm_body,
2081                        wildcard_arm_body,
2082                        precondition,
2083                    },
2084                );
2085            } else {
2086                plans.insert(fd.name.clone(), RecursionPlan::IntCountdown { param_index });
2087            }
2088        } else if let Some((param_index, divisor, helper_fn)) =
2089            single_int_floor_div_countdown(fd, inputs)
2090        {
2091            // Floor-division countdown — every self-call shrinks an
2092            // Int param by `Result.withDefault(Int.div(p, k), d)`
2093            // (literal k >= 2, possibly through a unary wrapper fn)
2094            // and the guard chain at every self-call site implies
2095            // `p >= 1`. Both side-conditions are validated above;
2096            // backends emit a native well-founded def.
2097            plans.insert(
2098                fd.name.clone(),
2099                RecursionPlan::IntFloorDivCountdown {
2100                    param_index,
2101                    divisor,
2102                    helper_fn,
2103                },
2104            );
2105        } else if supports_single_sizeof_structural(fd, inputs) {
2106            plans.insert(fd.name.clone(), RecursionPlan::SizeOfStructural);
2107        } else if let Some(param_index) = single_list_structural_param_index(fd) {
2108            plans.insert(
2109                fd.name.clone(),
2110                RecursionPlan::ListStructural { param_index },
2111            );
2112        } else if supports_single_string_pos_advance(fd) {
2113            plans.insert(fd.name.clone(), RecursionPlan::StringPosAdvance);
2114        } else {
2115            issues.push(ProofModeIssue {
2116                line: fd.line,
2117                message: format!(
2118                    "recursive function '{}' is outside proof subset (currently supported: Int countdown, guard-validated Int floor-division countdown by a literal divisor, second-order affine Int recurrences with pair-state worker, structural recursion on List/recursive ADTs, String+position, mutual Int countdown, mutual String+position, and ranked sizeOf recursion)",
2119                    fd.name
2120                ),
2121            });
2122        }
2123    }
2124
2125    (plans, issues)
2126}