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 Expr::Ident(subject_name) = &subject.node
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
518pub(crate) fn has_negative_guarded_ascent(fd: &FnDef, param_name: &str) -> bool {
519    let Some(tail) = fd.body.tail_expr() else {
520        return false;
521    };
522    let Expr::Match { subject, arms, .. } = &tail.node else {
523        return false;
524    };
525    let Expr::BinOp(BinOp::Lt, left, right) = &subject.node else {
526        return false;
527    };
528    if !is_ident(left, param_name)
529        || !matches!(&right.node, Expr::Literal(crate::ast::Literal::Int(0)))
530    {
531        return false;
532    }
533
534    let mut true_arm = None;
535    let mut false_arm = None;
536    for arm in arms {
537        match arm.pattern {
538            Pattern::Literal(crate::ast::Literal::Bool(true)) => true_arm = Some(arm.body.as_ref()),
539            Pattern::Literal(crate::ast::Literal::Bool(false)) => {
540                false_arm = Some(arm.body.as_ref())
541            }
542            _ => return false,
543        }
544    }
545
546    let Some(true_arm) = true_arm else {
547        return false;
548    };
549    let Some(false_arm) = false_arm else {
550        return false;
551    };
552
553    let mut true_calls = Vec::new();
554    collect_calls_from_expr(true_arm, &mut true_calls);
555    let mut false_calls = Vec::new();
556    collect_calls_from_expr(false_arm, &mut false_calls);
557
558    true_calls
559        .iter()
560        .any(|(name, _)| call_matches(name, &fd.name))
561        && false_calls
562            .iter()
563            .all(|(name, _)| !call_matches(name, &fd.name))
564}
565
566/// Detect ascending-index recursion and extract the bound expression
567/// as an Aver AST (`Spanned<Expr>`). Returns `(param_index, bound)`.
568pub(crate) fn single_int_ascending_param(fd: &FnDef) -> Option<(usize, Spanned<Expr>)> {
569    let recursive_calls: Vec<Vec<&Spanned<Expr>>> = collect_calls_from_body(fd.body.as_ref())
570        .into_iter()
571        .filter(|(name, _)| call_matches(name, &fd.name))
572        .map(|(_, args)| args)
573        .collect();
574    if recursive_calls.is_empty() {
575        return None;
576    }
577
578    for (idx, (param_name, param_ty)) in fd.params.iter().enumerate() {
579        if param_ty != "Int" {
580            continue;
581        }
582        let ascent_ok = recursive_calls.iter().all(|args| {
583            args.get(idx)
584                .cloned()
585                .is_some_and(|arg| is_int_plus_positive(arg, param_name))
586        });
587        if !ascent_ok {
588            continue;
589        }
590        if let Some(bound) = extract_equality_bound_expr(fd, param_name) {
591            return Some((idx, bound));
592        }
593    }
594    None
595}
596
597/// Extract the bound expression from `match param == BOUND` as an
598/// Aver AST node. Each backend renders this into its own idiom (Lean
599/// via `bound_expr_to_lean`, Dafny via its own `emit_expr` path).
600pub(crate) fn extract_equality_bound_expr(fd: &FnDef, param_name: &str) -> Option<Spanned<Expr>> {
601    let tail = fd.body.tail_expr()?;
602    let Expr::Match { subject, arms, .. } = &tail.node else {
603        return None;
604    };
605    let Expr::BinOp(BinOp::Eq, left, right) = &subject.node else {
606        return None;
607    };
608    if !is_ident(left, param_name) {
609        return None;
610    }
611    // Verify: true arm = base (no self-call), false arm = recursive (has self-call)
612    let mut true_has_self = false;
613    let mut false_has_self = false;
614    for arm in arms {
615        match arm.pattern {
616            Pattern::Literal(crate::ast::Literal::Bool(true)) => {
617                let mut calls = Vec::new();
618                collect_calls_from_expr(&arm.body, &mut calls);
619                true_has_self = calls.iter().any(|(n, _)| call_matches(n, &fd.name));
620            }
621            Pattern::Literal(crate::ast::Literal::Bool(false)) => {
622                let mut calls = Vec::new();
623                collect_calls_from_expr(&arm.body, &mut calls);
624                false_has_self = calls.iter().any(|(n, _)| call_matches(n, &fd.name));
625            }
626            _ => return None,
627        }
628    }
629    if true_has_self || !false_has_self {
630        return None;
631    }
632    Some((**right).clone())
633}
634
635pub(crate) fn supports_single_sizeof_structural(fd: &FnDef, inputs: &ProofLowerInputs) -> bool {
636    let recursive_calls: Vec<Vec<&Spanned<Expr>>> = collect_calls_from_body(fd.body.as_ref())
637        .into_iter()
638        .filter(|(name, _)| call_matches(name, &fd.name))
639        .map(|(_, args)| args)
640        .collect();
641    if recursive_calls.is_empty() {
642        return false;
643    }
644
645    let metric_indices = sizeof_measure_param_indices(fd);
646    if metric_indices.is_empty() {
647        return false;
648    }
649
650    let binder_sets: HashMap<usize, HashSet<String>> = metric_indices
651        .iter()
652        .filter_map(|idx| {
653            let (param_name, param_type) = fd.params.get(*idx)?;
654            inputs.recursive_type_names().contains(param_type).then(|| {
655                (
656                    *idx,
657                    collect_recursive_subterm_binders(fd, param_name, param_type, inputs),
658                )
659            })
660        })
661        .collect();
662
663    if binder_sets.values().all(HashSet::is_empty) {
664        return false;
665    }
666
667    recursive_calls.iter().all(|args| {
668        let mut strictly_smaller = false;
669        for idx in &metric_indices {
670            let Some((param_name, _)) = fd.params.get(*idx) else {
671                return false;
672            };
673            let Some(arg) = args.get(*idx).cloned() else {
674                return false;
675            };
676            if is_ident(arg, param_name) {
677                continue;
678            }
679            let Some(binders) = binder_sets.get(idx) else {
680                return false;
681            };
682            if local_name_of(arg).is_some_and(|id| binders.contains(id)) {
683                strictly_smaller = true;
684                continue;
685            }
686            return false;
687        }
688        strictly_smaller
689    })
690}
691
692pub(crate) fn single_list_structural_param_index(fd: &FnDef) -> Option<usize> {
693    fd.params
694        .iter()
695        .enumerate()
696        .find_map(|(param_index, (param_name, param_ty))| {
697            if !(param_ty.starts_with("List<") || param_ty == "List") {
698                return None;
699            }
700
701            let tails = collect_list_tail_binders(fd, param_name);
702            if tails.is_empty() {
703                return None;
704            }
705
706            let recursive_calls: Vec<Option<&Spanned<Expr>>> =
707                collect_calls_from_body(fd.body.as_ref())
708                    .into_iter()
709                    .filter(|(name, _)| call_matches(name, &fd.name))
710                    .map(|(_, args)| args.get(param_index).cloned())
711                    .collect();
712            if recursive_calls.is_empty() {
713                return None;
714            }
715
716            recursive_calls
717                .into_iter()
718                .all(|arg| {
719                    arg.and_then(local_name_of)
720                        .is_some_and(|id| tails.contains(id))
721                })
722                .then_some(param_index)
723        })
724}
725
726pub(crate) fn is_ident(expr: &Spanned<Expr>, name: &str) -> bool {
727    local_name_of(expr).is_some_and(|id| id == name)
728}
729
730pub(crate) fn is_int_plus_positive(expr: &Spanned<Expr>, param_name: &str) -> bool {
731    match &expr.node {
732        Expr::BinOp(BinOp::Add, left, right) => {
733            local_name_of(left).is_some_and(|id| id == param_name)
734                && matches!(&right.node, Expr::Literal(crate::ast::Literal::Int(n)) if *n >= 1)
735        }
736        Expr::FnCall(callee, args) => {
737            let Some(name) = expr_to_dotted_name(callee) else {
738                return false;
739            };
740            (name == "Int.add" || name == "int.add")
741                && args.len() == 2
742                && local_name_of(&args[0]).is_some_and(|id| id == param_name)
743                && matches!(&args[1].node, Expr::Literal(crate::ast::Literal::Int(n)) if *n >= 1)
744        }
745        _ => false,
746    }
747}
748
749pub(crate) fn is_skip_ws_advance(
750    expr: &Spanned<Expr>,
751    string_param: &str,
752    pos_param: &str,
753) -> bool {
754    let Expr::FnCall(callee, args) = &expr.node else {
755        return false;
756    };
757    let Some(name) = expr_to_dotted_name(callee) else {
758        return false;
759    };
760    if !call_matches(&name, "skipWs") || args.len() != 2 {
761        return false;
762    }
763    is_ident(&args[0], string_param) && is_int_plus_positive(&args[1], pos_param)
764}
765
766pub(crate) fn is_skip_ws_same(expr: &Spanned<Expr>, string_param: &str, pos_param: &str) -> bool {
767    let Expr::FnCall(callee, args) = &expr.node else {
768        return false;
769    };
770    let Some(name) = expr_to_dotted_name(callee) else {
771        return false;
772    };
773    if !call_matches(&name, "skipWs") || args.len() != 2 {
774        return false;
775    }
776    is_ident(&args[0], string_param) && is_ident(&args[1], pos_param)
777}
778
779pub(crate) fn is_string_pos_advance(
780    expr: &Spanned<Expr>,
781    string_param: &str,
782    pos_param: &str,
783) -> bool {
784    is_int_plus_positive(expr, pos_param) || is_skip_ws_advance(expr, string_param, pos_param)
785}
786
787#[derive(Clone, Copy, Debug, Eq, PartialEq)]
788pub(crate) enum StringPosEdge {
789    Same,
790    Advance,
791}
792
793pub(crate) fn classify_string_pos_edge(
794    expr: &Spanned<Expr>,
795    string_param: &str,
796    pos_param: &str,
797) -> Option<StringPosEdge> {
798    if is_ident(expr, pos_param) || is_skip_ws_same(expr, string_param, pos_param) {
799        return Some(StringPosEdge::Same);
800    }
801    if is_string_pos_advance(expr, string_param, pos_param) {
802        return Some(StringPosEdge::Advance);
803    }
804    if let Expr::FnCall(callee, args) = &expr.node {
805        let name = expr_to_dotted_name(callee)?;
806        if call_matches(&name, "skipWs")
807            && args.len() == 2
808            && is_ident(&args[0], string_param)
809            && local_name_of(&args[1]).is_some_and(|id| id != pos_param)
810        {
811            return Some(StringPosEdge::Advance);
812        }
813    }
814    if local_name_of(expr).is_some_and(|id| id != pos_param) {
815        return Some(StringPosEdge::Advance);
816    }
817    None
818}
819
820pub(crate) fn ranks_from_same_edges(
821    names: &HashSet<String>,
822    same_edges: &HashMap<String, HashSet<String>>,
823) -> Option<HashMap<String, usize>> {
824    let mut indegree: HashMap<String, usize> = names.iter().map(|n| (n.clone(), 0)).collect();
825    for outs in same_edges.values() {
826        for to in outs {
827            if let Some(entry) = indegree.get_mut(to) {
828                *entry += 1;
829            } else {
830                return None;
831            }
832        }
833    }
834
835    let mut queue: Vec<String> = indegree
836        .iter()
837        .filter_map(|(name, &deg)| (deg == 0).then_some(name.clone()))
838        .collect();
839    queue.sort();
840    let mut topo = Vec::new();
841    while let Some(node) = queue.pop() {
842        topo.push(node.clone());
843        let outs = same_edges.get(&node).cloned().unwrap_or_default();
844        let mut newly_zero = Vec::new();
845        for to in outs {
846            if let Some(entry) = indegree.get_mut(&to) {
847                *entry -= 1;
848                if *entry == 0 {
849                    newly_zero.push(to);
850                }
851            } else {
852                return None;
853            }
854        }
855        newly_zero.sort();
856        queue.extend(newly_zero);
857    }
858
859    if topo.len() != names.len() {
860        return None;
861    }
862
863    let n = topo.len();
864    let mut ranks = HashMap::new();
865    for (idx, name) in topo.into_iter().enumerate() {
866        ranks.insert(name, n - idx);
867    }
868    Some(ranks)
869}
870
871pub(crate) fn supports_single_string_pos_advance(fd: &FnDef) -> bool {
872    let Some((string_param, string_ty)) = fd.params.first() else {
873        return false;
874    };
875    let Some((pos_param, pos_ty)) = fd.params.get(1) else {
876        return false;
877    };
878    if string_ty != "String" || pos_ty != "Int" {
879        return false;
880    }
881
882    type CallPair<'a> = (Option<&'a Spanned<Expr>>, Option<&'a Spanned<Expr>>);
883    let recursive_calls: Vec<CallPair<'_>> = collect_calls_from_body(fd.body.as_ref())
884        .into_iter()
885        .filter(|(name, _)| call_matches(name, &fd.name))
886        .map(|(_, args)| (args.first().cloned(), args.get(1).cloned()))
887        .collect();
888    if recursive_calls.is_empty() {
889        return false;
890    }
891
892    recursive_calls.into_iter().all(|(arg0, arg1)| {
893        arg0.is_some_and(|e| is_ident(e, string_param))
894            && arg1.is_some_and(|e| is_string_pos_advance(e, string_param, pos_param))
895    })
896}
897
898pub(crate) fn supports_mutual_int_countdown(component: &[&FnDef]) -> bool {
899    if component.len() < 2 {
900        return false;
901    }
902    if component
903        .iter()
904        .any(|fd| !matches!(fd.params.first(), Some((_, t)) if t == "Int"))
905    {
906        return false;
907    }
908    let names: HashSet<String> = component.iter().map(|fd| fd.name.clone()).collect();
909    let mut any_intra = false;
910    for fd in component {
911        let param_name = &fd.params[0].0;
912        for (callee, args) in collect_calls_from_body(fd.body.as_ref()) {
913            if !call_is_in_set(&callee, &names) {
914                continue;
915            }
916            any_intra = true;
917            let Some(arg0) = args.first().cloned() else {
918                return false;
919            };
920            if !is_int_minus_positive(arg0, param_name) {
921                return false;
922            }
923        }
924    }
925    any_intra
926}
927
928pub(crate) fn supports_mutual_string_pos_advance(
929    component: &[&FnDef],
930) -> Option<HashMap<String, usize>> {
931    if component.len() < 2 {
932        return None;
933    }
934    if component.iter().any(|fd| {
935        !matches!(fd.params.first(), Some((_, t)) if t == "String")
936            || !matches!(fd.params.get(1), Some((_, t)) if t == "Int")
937    }) {
938        return None;
939    }
940
941    let names: HashSet<String> = component.iter().map(|fd| fd.name.clone()).collect();
942    let mut same_edges: HashMap<String, HashSet<String>> =
943        names.iter().map(|n| (n.clone(), HashSet::new())).collect();
944    let mut any_intra = false;
945
946    for fd in component {
947        let string_param = &fd.params[0].0;
948        let pos_param = &fd.params[1].0;
949        for (callee_raw, args) in collect_calls_from_body(fd.body.as_ref()) {
950            let Some(callee) = canonical_callee_name(&callee_raw, &names) else {
951                continue;
952            };
953            any_intra = true;
954
955            let arg0 = args.first().cloned()?;
956            let arg1 = args.get(1).cloned()?;
957
958            if !is_ident(arg0, string_param) {
959                return None;
960            }
961
962            match classify_string_pos_edge(arg1, string_param, pos_param) {
963                Some(StringPosEdge::Same) => {
964                    if let Some(edges) = same_edges.get_mut(&fd.name) {
965                        edges.insert(callee);
966                    } else {
967                        return None;
968                    }
969                }
970                Some(StringPosEdge::Advance) => {}
971                None => return None,
972            }
973        }
974    }
975
976    if !any_intra {
977        return None;
978    }
979
980    ranks_from_same_edges(&names, &same_edges)
981}
982
983pub(crate) fn is_scalar_like_type(type_name: &str) -> bool {
984    matches!(
985        type_name,
986        "Int" | "Float" | "Bool" | "String" | "Char" | "Byte" | "Unit"
987    )
988}
989
990/// SizeOf-measure param indices for a fn — every non-scalar param
991/// position contributes a term to the structural measure. Matches
992/// the picks made for the Lean `termination_by` clause and the
993/// Dafny native `decreases` tuple so the same params drive measure
994/// inference on both backends.
995pub fn sizeof_measure_param_indices(fd: &FnDef) -> Vec<usize> {
996    fd.params
997        .iter()
998        .enumerate()
999        .filter_map(|(idx, (_, type_name))| (!is_scalar_like_type(type_name)).then_some(idx))
1000        .collect()
1001}
1002
1003/// True iff some intra-SCC call passes a non-trivially-shrinking
1004/// expression into a callee's sizeOf parameter — tail-recursive
1005/// accumulator patterns like `[x] + acc` / `List.prepend(x, acc)`
1006/// / `acc + [x]`, plus arbitrary `FnCall(...)` results we can't
1007/// statically prove shrink. These all fail termination-by-measure
1008/// even though fuel encoding handles them fine; backends fall back
1009/// to fuel for those SCCs.
1010pub fn scc_has_growing_accumulator(fns: &[&FnDef]) -> bool {
1011    let names: HashSet<String> = fns.iter().map(|fd| fd.name.clone()).collect();
1012    let mut sizeof_indices: HashMap<String, Vec<usize>> = HashMap::new();
1013    for fd in fns {
1014        sizeof_indices.insert(fd.name.clone(), sizeof_measure_param_indices(fd));
1015    }
1016    for fd in fns {
1017        // String interpolation in a body that ends up calling an
1018        // intra-SCC fn confuses Lean's wf elaboration — the recursive
1019        // call lives inside the interpolation's anonymous `s!"{...}"`
1020        // expansion, and the wf check pins anonymous binders the
1021        // tactic chain can't pin back to the source-named param.
1022        // Wumpus's `roomListItem` (`_ => s!"{rs}, {roomList rest}"`)
1023        // is the canonical shape. Fall back to fuel for this case.
1024        if body_has_intra_scc_call_in_interpolation(fd.body.as_ref(), &names) {
1025            return true;
1026        }
1027        for (callee_raw, args) in collect_calls_from_body(fd.body.as_ref()) {
1028            let Some(callee_name) = canonical_callee_name(&callee_raw, &names) else {
1029                continue;
1030            };
1031            let Some(callee_indices) = sizeof_indices.get(&callee_name) else {
1032                continue;
1033            };
1034            for callee_idx in callee_indices {
1035                let Some(arg) = args.get(*callee_idx) else {
1036                    continue;
1037                };
1038                if !arg_is_non_growing(arg) {
1039                    return true;
1040                }
1041            }
1042        }
1043    }
1044    false
1045}
1046
1047fn body_has_intra_scc_call_in_interpolation(body: &FnBody, names: &HashSet<String>) -> bool {
1048    let FnBody::Block(stmts) = body;
1049    stmts.iter().any(|s| match s {
1050        Stmt::Binding(_, _, e) | Stmt::Expr(e) => {
1051            expr_has_intra_scc_call_in_interpolation(e, names)
1052        }
1053    })
1054}
1055
1056fn expr_has_intra_scc_call_in_interpolation(expr: &Spanned<Expr>, names: &HashSet<String>) -> bool {
1057    use crate::ast::StrPart;
1058    match &expr.node {
1059        Expr::InterpolatedStr(parts) => parts.iter().any(|p| match p {
1060            StrPart::Parsed(inner) => expr_calls_into_set(inner, names),
1061            _ => false,
1062        }),
1063        Expr::Match { subject, arms, .. } => {
1064            expr_has_intra_scc_call_in_interpolation(subject, names)
1065                || arms
1066                    .iter()
1067                    .any(|a| expr_has_intra_scc_call_in_interpolation(&a.body, names))
1068        }
1069        Expr::FnCall(f, args) => {
1070            expr_has_intra_scc_call_in_interpolation(f, names)
1071                || args
1072                    .iter()
1073                    .any(|a| expr_has_intra_scc_call_in_interpolation(a, names))
1074        }
1075        Expr::TailCall(boxed) => boxed
1076            .args
1077            .iter()
1078            .any(|a| expr_has_intra_scc_call_in_interpolation(a, names)),
1079        Expr::BinOp(_, l, r) => {
1080            expr_has_intra_scc_call_in_interpolation(l, names)
1081                || expr_has_intra_scc_call_in_interpolation(r, names)
1082        }
1083        Expr::Constructor(_, Some(inner)) | Expr::Attr(inner, _) | Expr::Neg(inner) => {
1084            expr_has_intra_scc_call_in_interpolation(inner, names)
1085        }
1086        _ => false,
1087    }
1088}
1089
1090fn expr_calls_into_set(expr: &Spanned<Expr>, names: &HashSet<String>) -> bool {
1091    match &expr.node {
1092        Expr::FnCall(f, args) => {
1093            let head_hit = expr_to_dotted_name(f)
1094                .as_deref()
1095                .map(|n| canonical_callee_name(n, names).is_some())
1096                .unwrap_or(false);
1097            head_hit
1098                || args.iter().any(|a| expr_calls_into_set(a, names))
1099                || expr_calls_into_set(f, names)
1100        }
1101        Expr::TailCall(boxed) => {
1102            canonical_callee_name(&boxed.target, names).is_some()
1103                || boxed.args.iter().any(|a| expr_calls_into_set(a, names))
1104        }
1105        Expr::Attr(inner, _) | Expr::Neg(inner) | Expr::Constructor(_, Some(inner)) => {
1106            expr_calls_into_set(inner, names)
1107        }
1108        Expr::BinOp(_, l, r) => expr_calls_into_set(l, names) || expr_calls_into_set(r, names),
1109        Expr::Match { subject, arms, .. } => {
1110            expr_calls_into_set(subject, names)
1111                || arms.iter().any(|a| expr_calls_into_set(&a.body, names))
1112        }
1113        _ => false,
1114    }
1115}
1116
1117fn arg_is_non_growing(expr: &Spanned<Expr>) -> bool {
1118    matches!(
1119        &expr.node,
1120        Expr::Ident(_) | Expr::Resolved { .. } | Expr::Literal(_) | Expr::Attr(..)
1121    )
1122}
1123
1124pub(crate) fn supports_mutual_sizeof_ranked(
1125    component: &[&FnDef],
1126) -> Option<HashMap<String, usize>> {
1127    if component.len() < 2 {
1128        return None;
1129    }
1130    let names: HashSet<String> = component.iter().map(|fd| fd.name.clone()).collect();
1131    let metric_indices: HashMap<String, Vec<usize>> = component
1132        .iter()
1133        .map(|fd| (fd.name.clone(), sizeof_measure_param_indices(fd)))
1134        .collect();
1135    if component.iter().any(|fd| {
1136        metric_indices
1137            .get(&fd.name)
1138            .is_none_or(|indices| indices.is_empty())
1139    }) {
1140        return None;
1141    }
1142
1143    let mut same_edges: HashMap<String, HashSet<String>> =
1144        names.iter().map(|n| (n.clone(), HashSet::new())).collect();
1145    let mut any_intra = false;
1146    for fd in component {
1147        let caller_metric_indices = metric_indices.get(&fd.name)?;
1148        let caller_metric_params: Vec<&str> = caller_metric_indices
1149            .iter()
1150            .filter_map(|idx| fd.params.get(*idx).map(|(name, _)| name.as_str()))
1151            .collect();
1152        for (callee_raw, args) in collect_calls_from_body(fd.body.as_ref()) {
1153            let Some(callee) = canonical_callee_name(&callee_raw, &names) else {
1154                continue;
1155            };
1156            any_intra = true;
1157            let callee_metric_indices = metric_indices.get(&callee)?;
1158            let is_same_edge = callee_metric_indices.len() == caller_metric_params.len()
1159                && callee_metric_indices
1160                    .iter()
1161                    .enumerate()
1162                    .all(|(pos, callee_idx)| {
1163                        let Some(arg) = args.get(*callee_idx).cloned() else {
1164                            return false;
1165                        };
1166                        is_ident(arg, caller_metric_params[pos])
1167                    });
1168            if is_same_edge {
1169                if let Some(edges) = same_edges.get_mut(&fd.name) {
1170                    edges.insert(callee);
1171                } else {
1172                    return None;
1173                }
1174            }
1175        }
1176    }
1177    if !any_intra {
1178        return None;
1179    }
1180
1181    let ranks = ranks_from_same_edges(&names, &same_edges)?;
1182    let mut out = HashMap::new();
1183    for fd in component {
1184        let rank = ranks.get(&fd.name).cloned()?;
1185        out.insert(fd.name.clone(), rank);
1186    }
1187    Some(out)
1188}
1189
1190/// True when every callsite of `fn_name` visible inside `ctx` is also
1191/// inside `ctx` — i.e. no module that uses `ctx` for proof emission can
1192/// have an external caller we can't analyze. Holds when either:
1193/// - the fn lives in the entry items and the entry module has no
1194///   explicit `exposes` clause (single-file program or implicitly-
1195///   private surface), or has an `exposes` list that omits `fn_name`;
1196/// - the fn lives in a dep module and that dep module's `exposes`
1197///   omits `fn_name`. Dep modules without an `exposes` clause are
1198///   conservative — treated as exposed since downstream consumers may
1199///   pull them in unscoped; we don't yet thread that distinction so
1200///   the conservative side is "not closed-world".
1201///
1202/// The `ctx.items` carry a `TopLevel::Module(m)` for entry; dep
1203/// modules currently surface no `Module` block in `ModuleInfo`, so
1204/// the dep-module branch returns `false` until that plumbing is
1205/// added. Practically this matches the issue-84 scope: per-file
1206/// proof targets (fibonacci.av) where the analyzed fn is in the
1207/// entry module.
1208pub(crate) fn is_closed_world_pure_fn(fn_name: &str, inputs: &ProofLowerInputs) -> bool {
1209    let entry_module = inputs.entry_items.iter().find_map(|item| match item {
1210        TopLevel::Module(m) => Some(m),
1211        _ => None,
1212    });
1213    let entry_fn_names: std::collections::HashSet<&str> = inputs
1214        .entry_items
1215        .iter()
1216        .filter_map(|item| match item {
1217            TopLevel::FnDef(fd) => Some(fd.name.as_str()),
1218            _ => None,
1219        })
1220        .collect();
1221    if entry_fn_names.contains(fn_name) {
1222        match entry_module {
1223            None => return true,
1224            Some(m) => {
1225                if m.exposes_line.is_none() {
1226                    return true;
1227                }
1228                return !m.exposes.iter().any(|e| e == fn_name);
1229            }
1230        }
1231    }
1232    false
1233}
1234
1235/// Find the unique external caller of `target_fn` in `ctx` and return
1236/// the path-constraint predicates that wrap the callsite — flipped to
1237/// positive form and substituted into the callee's variable space.
1238///
1239/// Returns `None` when:
1240/// - there are zero or more than one callers (the issue-84 single-caller
1241///   simplification — same idea as opaque types having one smart
1242///   constructor);
1243/// - the caller has multiple callsites with non-identical enclosing
1244///   guards (no single weakest precondition);
1245/// - the arg passed at the countdown-param position isn't a plain local
1246///   name (caller did `worker(n + 5)` or `worker(f(n))` — substitution
1247///   would need arithmetic the proof emitter doesn't run);
1248/// - any enclosing guard is `match (non-bool expression) { ... }` —
1249///   only `match Bool { true/false -> ... }` and `if/then/else` over
1250///   bool subjects give a flat predicate, anything else is rejected so
1251///   the fall-through to fuel stays sound.
1252///
1253/// On success the returned list's conjunction is the precondition for
1254/// `target_fn`'s aux: every clause is a positive `Spanned<Expr>` Bool
1255/// predicate in the callee's variable space, ready to feed straight
1256/// into `emit_expr` the same way opaque-type predicates do.
1257pub(crate) fn find_single_external_caller_predicate(
1258    target_fn: &str,
1259    target_param_index: usize,
1260    callee_param_name: &str,
1261    inputs: &ProofLowerInputs,
1262) -> Option<Vec<Spanned<Expr>>> {
1263    let all_callers: Vec<&FnDef> = inputs
1264        .entry_items
1265        .iter()
1266        .filter_map(|item| match item {
1267            TopLevel::FnDef(fd) if fd.name != target_fn => Some(fd),
1268            _ => None,
1269        })
1270        .chain(
1271            inputs
1272                .dep_modules
1273                .iter()
1274                .flat_map(|m| m.fn_defs.iter().filter(|fd| fd.name != target_fn)),
1275        )
1276        .filter(|fd| {
1277            collect_calls_from_body(fd.body.as_ref())
1278                .iter()
1279                .any(|(n, _)| call_matches(n, target_fn))
1280        })
1281        .collect();
1282
1283    if all_callers.len() != 1 {
1284        return None;
1285    }
1286    let caller = all_callers[0];
1287
1288    let mut callsites: Vec<(Vec<Spanned<Expr>>, String)> = Vec::new();
1289    for stmt in caller.body.stmts() {
1290        match stmt {
1291            Stmt::Binding(_, _, expr) | Stmt::Expr(expr) => {
1292                walk_caller_collect_callsite_guards(
1293                    expr,
1294                    target_fn,
1295                    target_param_index,
1296                    &[],
1297                    &mut callsites,
1298                );
1299            }
1300        }
1301    }
1302
1303    if callsites.is_empty() {
1304        return None;
1305    }
1306
1307    // For multiple callsites: require IDENTICAL guard lists (else the
1308    // weakest precondition isn't trivial and we punt to fuel).
1309    let (first_guards, first_arg_name) = &callsites[0];
1310    for (other_guards, other_arg_name) in &callsites[1..] {
1311        if other_arg_name != first_arg_name || other_guards != first_guards {
1312            return None;
1313        }
1314    }
1315
1316    let arg_name = first_arg_name.as_str();
1317    let predicate = first_guards
1318        .iter()
1319        .filter(|g| crate::codegen::recursion::expr_references_ident(g, arg_name))
1320        .map(|g| {
1321            crate::codegen::recursion::substitute_ident_in_expr(g, arg_name, callee_param_name)
1322        })
1323        .collect::<Vec<_>>();
1324
1325    Some(predicate)
1326}
1327
1328/// Walker that collects all callsites of `target_fn` inside `expr`,
1329/// pairing each with (a) the chain of enclosing `match Bool` /
1330/// `if-then-else` guards in **positive form** (false-arm guards
1331/// flipped via `flip_comparison_binop`) and (b) the caller-side
1332/// variable name passed at `target_param_index`.
1333fn walk_caller_collect_callsite_guards(
1334    expr: &Spanned<Expr>,
1335    target_fn: &str,
1336    target_param_index: usize,
1337    enclosing_guards: &[Spanned<Expr>],
1338    out: &mut Vec<(Vec<Spanned<Expr>>, String)>,
1339) {
1340    match &expr.node {
1341        Expr::FnCall(callee, args) => {
1342            if let Some(name) = expr_to_dotted_name(callee)
1343                && call_matches(&name, target_fn)
1344                && let Some(arg_at_idx) = args.get(target_param_index)
1345                && let Some(arg_name) = local_name_of(arg_at_idx)
1346            {
1347                out.push((enclosing_guards.to_vec(), arg_name.to_string()));
1348            }
1349            walk_caller_collect_callsite_guards(
1350                callee,
1351                target_fn,
1352                target_param_index,
1353                enclosing_guards,
1354                out,
1355            );
1356            for arg in args {
1357                walk_caller_collect_callsite_guards(
1358                    arg,
1359                    target_fn,
1360                    target_param_index,
1361                    enclosing_guards,
1362                    out,
1363                );
1364            }
1365        }
1366        Expr::TailCall(boxed) => {
1367            for arg in &boxed.args {
1368                walk_caller_collect_callsite_guards(
1369                    arg,
1370                    target_fn,
1371                    target_param_index,
1372                    enclosing_guards,
1373                    out,
1374                );
1375            }
1376        }
1377        Expr::Match { subject, arms } => {
1378            for arm in arms {
1379                let mut new_guards: Vec<Spanned<Expr>> = enclosing_guards.to_vec();
1380                match &arm.pattern {
1381                    Pattern::Literal(crate::ast::Literal::Bool(true)) => {
1382                        new_guards.push((**subject).clone());
1383                    }
1384                    Pattern::Literal(crate::ast::Literal::Bool(false)) => {
1385                        if let Some(flipped) =
1386                            crate::codegen::recursion::flip_comparison_binop(subject)
1387                        {
1388                            new_guards.push(flipped);
1389                        } else {
1390                            // Subject isn't a comparison BinOp — can't
1391                            // flip into a positive Aver expression.
1392                            // Drop the guard so the predicate stays
1393                            // sound (subset of true constraints).
1394                        }
1395                    }
1396                    _ => {
1397                        // Non-bool arm (e.g., `0 -> ...`, `[head, ..tail]
1398                        // -> ...`). Bind constraints aren't part of the
1399                        // bool-predicate vocabulary; treat the arm as
1400                        // transparent.
1401                    }
1402                }
1403                walk_caller_collect_callsite_guards(
1404                    &arm.body,
1405                    target_fn,
1406                    target_param_index,
1407                    &new_guards,
1408                    out,
1409                );
1410            }
1411            walk_caller_collect_callsite_guards(
1412                subject,
1413                target_fn,
1414                target_param_index,
1415                enclosing_guards,
1416                out,
1417            );
1418        }
1419        Expr::BinOp(_, l, r) => {
1420            walk_caller_collect_callsite_guards(
1421                l,
1422                target_fn,
1423                target_param_index,
1424                enclosing_guards,
1425                out,
1426            );
1427            walk_caller_collect_callsite_guards(
1428                r,
1429                target_fn,
1430                target_param_index,
1431                enclosing_guards,
1432                out,
1433            );
1434        }
1435        Expr::Attr(inner, _) | Expr::Neg(inner) | Expr::ErrorProp(inner) => {
1436            walk_caller_collect_callsite_guards(
1437                inner,
1438                target_fn,
1439                target_param_index,
1440                enclosing_guards,
1441                out,
1442            );
1443        }
1444        Expr::Constructor(_, arg) => {
1445            if let Some(inner) = arg {
1446                walk_caller_collect_callsite_guards(
1447                    inner,
1448                    target_fn,
1449                    target_param_index,
1450                    enclosing_guards,
1451                    out,
1452                );
1453            }
1454        }
1455        Expr::InterpolatedStr(parts) => {
1456            for p in parts {
1457                if let crate::ast::StrPart::Parsed(inner) = p {
1458                    walk_caller_collect_callsite_guards(
1459                        inner,
1460                        target_fn,
1461                        target_param_index,
1462                        enclosing_guards,
1463                        out,
1464                    );
1465                }
1466            }
1467        }
1468        Expr::List(items) | Expr::Tuple(items) | Expr::IndependentProduct(items, _) => {
1469            for item in items {
1470                walk_caller_collect_callsite_guards(
1471                    item,
1472                    target_fn,
1473                    target_param_index,
1474                    enclosing_guards,
1475                    out,
1476                );
1477            }
1478        }
1479        Expr::MapLiteral(entries) => {
1480            for (k, v) in entries {
1481                walk_caller_collect_callsite_guards(
1482                    k,
1483                    target_fn,
1484                    target_param_index,
1485                    enclosing_guards,
1486                    out,
1487                );
1488                walk_caller_collect_callsite_guards(
1489                    v,
1490                    target_fn,
1491                    target_param_index,
1492                    enclosing_guards,
1493                    out,
1494                );
1495            }
1496        }
1497        Expr::RecordCreate { fields, .. } => {
1498            for (_, v) in fields {
1499                walk_caller_collect_callsite_guards(
1500                    v,
1501                    target_fn,
1502                    target_param_index,
1503                    enclosing_guards,
1504                    out,
1505                );
1506            }
1507        }
1508        Expr::RecordUpdate { base, updates, .. } => {
1509            walk_caller_collect_callsite_guards(
1510                base,
1511                target_fn,
1512                target_param_index,
1513                enclosing_guards,
1514                out,
1515            );
1516            for (_, v) in updates {
1517                walk_caller_collect_callsite_guards(
1518                    v,
1519                    target_fn,
1520                    target_param_index,
1521                    enclosing_guards,
1522                    out,
1523                );
1524            }
1525        }
1526        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } => {}
1527    }
1528}
1529
1530/// Classify every recursive pure fn in `inputs`. The returned map
1531/// assigns each supported function a [`RecursionPlan`]; anything
1532/// that falls outside the recognised shapes becomes a
1533/// [`ProofModeIssue`].
1534///
1535/// **Consumers should not call this directly.** After Step 18 / 20
1536/// the only caller is `proof_lower::populate_fn_contracts`, which
1537/// translates the output into `ProofIR.fn_contracts` and
1538/// `ProofIR.unclassified_fns`. Backends read those fields instead.
1539/// The fn stays `pub` only because the diff test
1540/// (`tests/proof_ir_diff.rs`) cross-checks ProofIR against the
1541/// legacy classifier output — that test is itself slated for
1542/// rationalisation once the migration is fully bedded down.
1543/// Scope-aware classifier. `scope = None` runs against entry only,
1544/// `Some(prefix)` against one dep module. Aver's module DAG
1545/// invariant rules out cross-module recursion SCCs, so per-scope
1546/// classification is the canonical view and avoids two
1547/// same-bare-name fns from different modules colliding in the
1548/// SCC analyser's `HashMap<name, _>`. The `global_view` flag keeps
1549/// the legacy "chain all fns, classify globally" behaviour for
1550/// `tests/proof_ir_diff.rs` cross-check tests; production callers
1551/// always pass `global_view = false` and walk scopes themselves.
1552pub fn analyze_plans_in_scope(
1553    inputs: &ProofLowerInputs,
1554    scope: Option<&str>,
1555    global_view: bool,
1556) -> (HashMap<String, RecursionPlan>, Vec<ProofModeIssue>) {
1557    let mut plans = HashMap::new();
1558    let mut issues = Vec::new();
1559
1560    let all_pure = if global_view {
1561        inputs.pure_fns()
1562    } else {
1563        inputs.pure_fns_in_scope(scope)
1564    };
1565    let recursive_names = if global_view {
1566        inputs.recursive_pure_fn_names()
1567    } else {
1568        inputs.recursive_pure_fn_names_in_scope(scope)
1569    };
1570    let components = call_graph::ordered_fn_components(&all_pure, inputs.module_prefixes);
1571
1572    for component in components {
1573        if component.is_empty() {
1574            continue;
1575        }
1576        let is_recursive_component =
1577            component.len() > 1 || recursive_names.contains(&component[0].name);
1578        if !is_recursive_component {
1579            continue;
1580        }
1581
1582        if component.len() > 1 {
1583            if supports_mutual_int_countdown(&component) {
1584                for fd in &component {
1585                    plans.insert(fd.name.clone(), RecursionPlan::MutualIntCountdown);
1586                }
1587            } else if let Some(ranks) = supports_mutual_string_pos_advance(&component) {
1588                for fd in &component {
1589                    if let Some(rank) = ranks.get(&fd.name).cloned() {
1590                        plans.insert(
1591                            fd.name.clone(),
1592                            RecursionPlan::MutualStringPosAdvance { rank },
1593                        );
1594                    }
1595                }
1596            } else if let Some(rankings) = supports_mutual_sizeof_ranked(&component) {
1597                for fd in &component {
1598                    if let Some(rank) = rankings.get(&fd.name).cloned() {
1599                        plans.insert(fd.name.clone(), RecursionPlan::MutualSizeOfRanked { rank });
1600                    }
1601                }
1602            } else {
1603                let names = component
1604                    .iter()
1605                    .map(|fd| fd.name.clone())
1606                    .collect::<Vec<_>>()
1607                    .join(", ");
1608                let line = component.iter().map(|fd| fd.line).min().unwrap_or(1);
1609                issues.push(ProofModeIssue {
1610                    line,
1611                    message: format!(
1612                        "unsupported mutual recursion group (currently supported in proof mode: Int countdown on first param): {}",
1613                        names
1614                    ),
1615                });
1616            }
1617            continue;
1618        }
1619
1620        let fd = component[0];
1621        if crate::codegen::lean::recurrence::detect_second_order_int_linear_recurrence(fd).is_some()
1622        {
1623            plans.insert(fd.name.clone(), RecursionPlan::LinearRecurrence2);
1624        } else if let Some((param_index, bound)) = single_int_ascending_param(fd) {
1625            plans.insert(
1626                fd.name.clone(),
1627                RecursionPlan::IntAscending { param_index, bound },
1628            );
1629        } else if let Some(param_index) = single_int_countdown_param_index(fd) {
1630            // Try native guarded emission first — when the body has the
1631            // clean `match p { 0 -> BASE; _ -> rec(p-1, ...) }` shape and
1632            // the fn is closed-world (no external module can call it
1633            // with a negative param), Lean can emit a real recursive
1634            // def with `(h : p ≥ 0)` precondition + termination on
1635            // `p.natAbs`. Falls back to the fuel-encoded IntCountdown
1636            // plan when either condition fails.
1637            if is_closed_world_pure_fn(&fd.name, inputs)
1638                && let Some((base_arm_literal, base_arm_body, wildcard_arm_body)) =
1639                    int_countdown_native_arms(fd, param_index)
1640                && let Some((callee_param_name, _)) = fd.params.get(param_index)
1641            {
1642                // Caller-derived precondition first; empty means
1643                // "no single external caller in this artifact" and the
1644                // Lean emitter defaults to `(h_dom : p ≥ 0)` for
1645                // legacy fibTR-shape compatibility.
1646                let precondition = find_single_external_caller_predicate(
1647                    &fd.name,
1648                    param_index,
1649                    callee_param_name,
1650                    inputs,
1651                )
1652                .unwrap_or_default();
1653                plans.insert(
1654                    fd.name.clone(),
1655                    RecursionPlan::IntCountdownGuarded {
1656                        param_index,
1657                        base_arm_literal,
1658                        base_arm_body,
1659                        wildcard_arm_body,
1660                        precondition,
1661                    },
1662                );
1663            } else {
1664                plans.insert(fd.name.clone(), RecursionPlan::IntCountdown { param_index });
1665            }
1666        } else if supports_single_sizeof_structural(fd, inputs) {
1667            plans.insert(fd.name.clone(), RecursionPlan::SizeOfStructural);
1668        } else if let Some(param_index) = single_list_structural_param_index(fd) {
1669            plans.insert(
1670                fd.name.clone(),
1671                RecursionPlan::ListStructural { param_index },
1672            );
1673        } else if supports_single_string_pos_advance(fd) {
1674            plans.insert(fd.name.clone(), RecursionPlan::StringPosAdvance);
1675        } else {
1676            issues.push(ProofModeIssue {
1677                line: fd.line,
1678                message: format!(
1679                    "recursive function '{}' is outside proof subset (currently supported: Int countdown, 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)",
1680                    fd.name
1681                ),
1682            });
1683        }
1684    }
1685
1686    (plans, issues)
1687}