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
988/// The user-ADT analog of [`single_list_structural_param_index`]: a single
989/// recursive-ADT param such that EVERY recursive self-call passes, at that
990/// position, a STRICT recursive-subterm binder of it (the `m` of
991/// `match n { S(m) -> rec(m, …) }`). Other params are IGNORED — a THREADED
992/// accumulator (`triTR`'s `acc`, fed `plus(n, acc)`) does NOT disqualify,
993/// exactly as the list path ignores `qrev`'s threaded `acc`. Lean's equation
994/// compiler infers structural recursion on the returned param regardless of
995/// what the other args do, so the single shrinking param is a sound termination
996/// witness even when a sibling param grows.
997///
998/// This catches the accumulator-fold inner loop (`triTR` / `factTR`) that the
999/// multi-param [`supports_single_sizeof_structural`] rejects: that measure
1000/// requires EVERY non-scalar param to shrink-or-stay, which the reconstructed
1001/// accumulator violates. Both are ORed in the classifier, so this only ever ADDS
1002/// the threaded-accumulator ADT shape — a previously-unclassified fn that fell
1003/// to `partial def` (and bounded sampling) now emits as a structural `def` and
1004/// becomes induction-provable.
1005pub(crate) fn single_adt_structural_param_index(
1006    fd: &FnDef,
1007    inputs: &ProofLowerInputs,
1008) -> Option<usize> {
1009    let recursive_calls: Vec<Vec<&Spanned<Expr>>> = collect_calls_from_body(fd.body.as_ref())
1010        .into_iter()
1011        .filter(|(name, _)| call_matches(name, &fd.name))
1012        .map(|(_, args)| args)
1013        .collect();
1014    if recursive_calls.is_empty() {
1015        return None;
1016    }
1017    let recursive_types = inputs.recursive_type_names();
1018    fd.params
1019        .iter()
1020        .enumerate()
1021        .find_map(|(param_index, (param_name, param_type))| {
1022            if !recursive_types.contains(param_type) {
1023                return None;
1024            }
1025            let binders = collect_recursive_subterm_binders(fd, param_name, param_type, inputs);
1026            if binders.is_empty() {
1027                return None;
1028            }
1029            recursive_calls
1030                .iter()
1031                .all(|args| {
1032                    args.get(param_index)
1033                        .and_then(|a| local_name_of(a))
1034                        .is_some_and(|id| binders.contains(id))
1035                })
1036                .then_some(param_index)
1037        })
1038}
1039
1040pub(crate) fn single_list_structural_param_index(fd: &FnDef) -> Option<usize> {
1041    fd.params
1042        .iter()
1043        .enumerate()
1044        .find_map(|(param_index, (param_name, param_ty))| {
1045            if !(param_ty.starts_with("List<") || param_ty == "List") {
1046                return None;
1047            }
1048
1049            let tails = collect_list_tail_binders(fd, param_name);
1050            if tails.is_empty() {
1051                return None;
1052            }
1053
1054            let recursive_calls: Vec<Option<&Spanned<Expr>>> =
1055                collect_calls_from_body(fd.body.as_ref())
1056                    .into_iter()
1057                    .filter(|(name, _)| call_matches(name, &fd.name))
1058                    .map(|(_, args)| args.get(param_index).cloned())
1059                    .collect();
1060            if recursive_calls.is_empty() {
1061                return None;
1062            }
1063
1064            recursive_calls
1065                .into_iter()
1066                .all(|arg| {
1067                    arg.and_then(local_name_of)
1068                        .is_some_and(|id| tails.contains(id))
1069                })
1070                .then_some(param_index)
1071        })
1072}
1073
1074/// `true` iff every recursive self-call of `fd` passes, at `param_index`, a
1075/// bare local that is NOT the param itself — i.e. the param is consumed in the
1076/// recursion (the `z` of `match p { S(z) -> recurse(z, ...) }`). Used to spot a
1077/// Peano param a list-recursive fn decrements SYNCHRONOUSLY with the list
1078/// (`take`/`drop`), which must then be GENERALIZED in the induction or the cons
1079/// IH lands at the wrong predecessor. Conservative: a non-local arg (`p - 1`)
1080/// yields `false`, so this fires only on the clean succ-binder shape.
1081pub(crate) fn param_decremented_in_recursion(fd: &FnDef, param_index: usize) -> bool {
1082    let calls: Vec<(String, Vec<&Spanned<Expr>>)> = collect_calls_from_body(fd.body.as_ref())
1083        .into_iter()
1084        .filter(|(name, _)| call_matches(name, &fd.name))
1085        .collect();
1086    if calls.is_empty() {
1087        return false;
1088    }
1089    let Some((pname, _)) = fd.params.get(param_index) else {
1090        return false;
1091    };
1092    calls.iter().all(|(_, args)| {
1093        args.get(param_index)
1094            .and_then(|a| local_name_of(a))
1095            .is_some_and(|id| id != pname)
1096    })
1097}
1098
1099/// `true` iff every recursive self-call passes at `param_index` a RECONSTRUCTED
1100/// expression (not a bare identifier) — a THREADED accumulator (`qrev`'s `acc`
1101/// gets `List.concat([h], acc)`). The "not a bare local" requirement is what
1102/// separates a genuine accumulator from a SYNCHRONOUS second list: `zip(xs,
1103/// ys)` recurses with `ys`'s tail binder `yt` (a bare local), which is a
1104/// structural decrement to be inducted normally, NOT a threaded accumulator to
1105/// generalize-without-cases. Such a param must be GENERALIZED (so the IH reads
1106/// `∀ acc, P xs acc` and applies at the threaded value) but NOT case-split.
1107pub(crate) fn param_threaded_in_recursion(fd: &FnDef, param_index: usize) -> bool {
1108    let calls: Vec<(String, Vec<&Spanned<Expr>>)> = collect_calls_from_body(fd.body.as_ref())
1109        .into_iter()
1110        .filter(|(name, _)| call_matches(name, &fd.name))
1111        .collect();
1112    if calls.is_empty() {
1113        return false;
1114    }
1115    calls.iter().all(|(_, args)| {
1116        args.get(param_index)
1117            .is_some_and(|a| local_name_of(a).is_none())
1118    })
1119}
1120
1121/// `true` iff `fd` is a binary fn that recurses by peeling a constructor off
1122/// BOTH arguments synchronously — the canonical `max`/`min` shape:
1123/// `match p0 { base -> …; succ(z) -> match p1 { base -> …; succ(w) -> …rec(z, w)… } }`.
1124/// Exactly one self-call, reached only from inside both succ arms, passing the
1125/// two inner succ binders (NOT either original param) at positions 0 and 1.
1126///
1127/// This is the recursion shape that a commutativity (`f a b = f b a`) or
1128/// associativity (`f (f a b) c = f a (f b c)`) proof must induct on with
1129/// `induction a generalizing <rest> with | zero => cases <rest> … | succ k ih =>
1130/// cases <rest> …`: inducting on one arg alone leaves the other arg's peel
1131/// stuck. Conservative — both params must share one type, there must be exactly
1132/// one self-call, and its two args must be the freshly-bound succ predecessors,
1133/// so it fires only on the genuine both-args-peeling family and never on a
1134/// single-arg-recursive fn (whose self-call repeats one original param).
1135pub(crate) fn recurses_decrementing_both_args(fd: &FnDef) -> bool {
1136    if fd.params.len() != 2 {
1137        return false;
1138    }
1139    let (p0, t0) = &fd.params[0];
1140    let (p1, t1) = &fd.params[1];
1141    if t0 != t1 {
1142        return false;
1143    }
1144    // Exactly one self-call, both args bare locals distinct from the originals.
1145    let calls: Vec<Vec<&Spanned<Expr>>> = collect_calls_from_body(fd.body.as_ref())
1146        .into_iter()
1147        .filter(|(name, _)| call_matches(name, &fd.name))
1148        .map(|(_, args)| args)
1149        .collect();
1150    if calls.len() != 1 {
1151        return false;
1152    }
1153    let args = &calls[0];
1154    let (Some(a0), Some(a1)) = (
1155        args.first().and_then(|a| local_name_of(a)),
1156        args.get(1).and_then(|a| local_name_of(a)),
1157    ) else {
1158        return false;
1159    };
1160    if a0 == p0 || a0 == p1 || a1 == p0 || a1 == p1 || a0 == a1 {
1161        return false;
1162    }
1163    // Outer match on p0 with a succ arm binding `a0`, whose body is an inner
1164    // match on p1 with a succ arm binding `a1`. Mirrors `peano_outer_split` but
1165    // stays local to detect.rs (which has no `CodegenContext`/Peano lookup).
1166    let Some(tail) = fd.body.tail_expr() else {
1167        return false;
1168    };
1169    let Expr::Match { subject, arms, .. } = &tail.node else {
1170        return false;
1171    };
1172    if local_name_of(subject) != Some(p0.as_str()) {
1173        return false;
1174    }
1175    arms.iter().any(|arm| {
1176        let Pattern::Constructor(_, binders) = &arm.pattern else {
1177            return false;
1178        };
1179        if binders.len() != 1 || binders[0] != a0 {
1180            return false;
1181        }
1182        let Expr::Match {
1183            subject: inner_subj,
1184            arms: inner_arms,
1185            ..
1186        } = &arm.body.node
1187        else {
1188            return false;
1189        };
1190        if local_name_of(inner_subj) != Some(p1.as_str()) {
1191            return false;
1192        }
1193        inner_arms.iter().any(|inner| {
1194            matches!(&inner.pattern,
1195                Pattern::Constructor(_, b) if b.len() == 1 && b[0] == a1)
1196        })
1197    })
1198}
1199
1200pub(crate) fn is_ident(expr: &Spanned<Expr>, name: &str) -> bool {
1201    local_name_of(expr).is_some_and(|id| id == name)
1202}
1203
1204pub(crate) fn is_int_plus_positive(expr: &Spanned<Expr>, param_name: &str) -> bool {
1205    match &expr.node {
1206        Expr::BinOp(BinOp::Add, left, right) => {
1207            local_name_of(left).is_some_and(|id| id == param_name)
1208                && matches!(&right.node, Expr::Literal(crate::ast::Literal::Int(n)) if *n >= 1)
1209        }
1210        Expr::FnCall(callee, args) => {
1211            let Some(name) = expr_to_dotted_name(callee) else {
1212                return false;
1213            };
1214            (name == "Int.add" || name == "int.add")
1215                && args.len() == 2
1216                && local_name_of(&args[0]).is_some_and(|id| id == param_name)
1217                && matches!(&args[1].node, Expr::Literal(crate::ast::Literal::Int(n)) if *n >= 1)
1218        }
1219        _ => false,
1220    }
1221}
1222
1223pub(crate) fn is_skip_ws_advance(
1224    expr: &Spanned<Expr>,
1225    string_param: &str,
1226    pos_param: &str,
1227) -> bool {
1228    let Expr::FnCall(callee, args) = &expr.node else {
1229        return false;
1230    };
1231    let Some(name) = expr_to_dotted_name(callee) else {
1232        return false;
1233    };
1234    if !call_matches(&name, "skipWs") || args.len() != 2 {
1235        return false;
1236    }
1237    is_ident(&args[0], string_param) && is_int_plus_positive(&args[1], pos_param)
1238}
1239
1240pub(crate) fn is_skip_ws_same(expr: &Spanned<Expr>, string_param: &str, pos_param: &str) -> bool {
1241    let Expr::FnCall(callee, args) = &expr.node else {
1242        return false;
1243    };
1244    let Some(name) = expr_to_dotted_name(callee) else {
1245        return false;
1246    };
1247    if !call_matches(&name, "skipWs") || args.len() != 2 {
1248        return false;
1249    }
1250    is_ident(&args[0], string_param) && is_ident(&args[1], pos_param)
1251}
1252
1253pub(crate) fn is_string_pos_advance(
1254    expr: &Spanned<Expr>,
1255    string_param: &str,
1256    pos_param: &str,
1257) -> bool {
1258    is_int_plus_positive(expr, pos_param) || is_skip_ws_advance(expr, string_param, pos_param)
1259}
1260
1261#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1262pub(crate) enum StringPosEdge {
1263    Same,
1264    Advance,
1265}
1266
1267pub(crate) fn classify_string_pos_edge(
1268    expr: &Spanned<Expr>,
1269    string_param: &str,
1270    pos_param: &str,
1271) -> Option<StringPosEdge> {
1272    if is_ident(expr, pos_param) || is_skip_ws_same(expr, string_param, pos_param) {
1273        return Some(StringPosEdge::Same);
1274    }
1275    if is_string_pos_advance(expr, string_param, pos_param) {
1276        return Some(StringPosEdge::Advance);
1277    }
1278    if let Expr::FnCall(callee, args) = &expr.node {
1279        let name = expr_to_dotted_name(callee)?;
1280        if call_matches(&name, "skipWs")
1281            && args.len() == 2
1282            && is_ident(&args[0], string_param)
1283            && local_name_of(&args[1]).is_some_and(|id| id != pos_param)
1284        {
1285            return Some(StringPosEdge::Advance);
1286        }
1287    }
1288    if local_name_of(expr).is_some_and(|id| id != pos_param) {
1289        return Some(StringPosEdge::Advance);
1290    }
1291    None
1292}
1293
1294pub(crate) fn ranks_from_same_edges(
1295    names: &HashSet<String>,
1296    same_edges: &HashMap<String, HashSet<String>>,
1297) -> Option<HashMap<String, usize>> {
1298    let mut indegree: HashMap<String, usize> = names.iter().map(|n| (n.clone(), 0)).collect();
1299    for outs in same_edges.values() {
1300        for to in outs {
1301            let entry = indegree.get_mut(to)?;
1302            *entry += 1;
1303        }
1304    }
1305
1306    let mut queue: Vec<String> = indegree
1307        .iter()
1308        .filter_map(|(name, &deg)| (deg == 0).then_some(name.clone()))
1309        .collect();
1310    queue.sort();
1311    let mut topo = Vec::new();
1312    while let Some(node) = queue.pop() {
1313        topo.push(node.clone());
1314        let outs = same_edges.get(&node).cloned().unwrap_or_default();
1315        let mut newly_zero = Vec::new();
1316        for to in outs {
1317            let entry = indegree.get_mut(&to)?;
1318            *entry -= 1;
1319            if *entry == 0 {
1320                newly_zero.push(to);
1321            }
1322        }
1323        newly_zero.sort();
1324        queue.extend(newly_zero);
1325    }
1326
1327    if topo.len() != names.len() {
1328        return None;
1329    }
1330
1331    let n = topo.len();
1332    let mut ranks = HashMap::new();
1333    for (idx, name) in topo.into_iter().enumerate() {
1334        ranks.insert(name, n - idx);
1335    }
1336    Some(ranks)
1337}
1338
1339pub(crate) fn supports_single_string_pos_advance(fd: &FnDef) -> bool {
1340    let Some((string_param, string_ty)) = fd.params.first() else {
1341        return false;
1342    };
1343    let Some((pos_param, pos_ty)) = fd.params.get(1) else {
1344        return false;
1345    };
1346    if string_ty != "String" || pos_ty != "Int" {
1347        return false;
1348    }
1349
1350    type CallPair<'a> = (Option<&'a Spanned<Expr>>, Option<&'a Spanned<Expr>>);
1351    let recursive_calls: Vec<CallPair<'_>> = collect_calls_from_body(fd.body.as_ref())
1352        .into_iter()
1353        .filter(|(name, _)| call_matches(name, &fd.name))
1354        .map(|(_, args)| (args.first().cloned(), args.get(1).cloned()))
1355        .collect();
1356    if recursive_calls.is_empty() {
1357        return false;
1358    }
1359
1360    recursive_calls.into_iter().all(|(arg0, arg1)| {
1361        arg0.is_some_and(|e| is_ident(e, string_param))
1362            && arg1.is_some_and(|e| is_string_pos_advance(e, string_param, pos_param))
1363    })
1364}
1365
1366pub(crate) fn supports_mutual_int_countdown(component: &[&FnDef]) -> bool {
1367    if component.len() < 2 {
1368        return false;
1369    }
1370    if component
1371        .iter()
1372        .any(|fd| !matches!(fd.params.first(), Some((_, t)) if t == "Int"))
1373    {
1374        return false;
1375    }
1376    let names: HashSet<String> = component.iter().map(|fd| fd.name.clone()).collect();
1377    let mut any_intra = false;
1378    for fd in component {
1379        let param_name = &fd.params[0].0;
1380        for (callee, args) in collect_calls_from_body(fd.body.as_ref()) {
1381            if !call_is_in_set(&callee, &names) {
1382                continue;
1383            }
1384            any_intra = true;
1385            let Some(arg0) = args.first().cloned() else {
1386                return false;
1387            };
1388            if !is_int_minus_positive(arg0, param_name) {
1389                return false;
1390            }
1391        }
1392    }
1393    any_intra
1394}
1395
1396pub(crate) fn supports_mutual_string_pos_advance(
1397    component: &[&FnDef],
1398) -> Option<HashMap<String, usize>> {
1399    if component.len() < 2 {
1400        return None;
1401    }
1402    if component.iter().any(|fd| {
1403        !matches!(fd.params.first(), Some((_, t)) if t == "String")
1404            || !matches!(fd.params.get(1), Some((_, t)) if t == "Int")
1405    }) {
1406        return None;
1407    }
1408
1409    let names: HashSet<String> = component.iter().map(|fd| fd.name.clone()).collect();
1410    let mut same_edges: HashMap<String, HashSet<String>> =
1411        names.iter().map(|n| (n.clone(), HashSet::new())).collect();
1412    let mut any_intra = false;
1413
1414    for fd in component {
1415        let string_param = &fd.params[0].0;
1416        let pos_param = &fd.params[1].0;
1417        for (callee_raw, args) in collect_calls_from_body(fd.body.as_ref()) {
1418            let Some(callee) = canonical_callee_name(&callee_raw, &names) else {
1419                continue;
1420            };
1421            any_intra = true;
1422
1423            let arg0 = args.first().cloned()?;
1424            let arg1 = args.get(1).cloned()?;
1425
1426            if !is_ident(arg0, string_param) {
1427                return None;
1428            }
1429
1430            match classify_string_pos_edge(arg1, string_param, pos_param) {
1431                Some(StringPosEdge::Same) => {
1432                    let edges = same_edges.get_mut(&fd.name)?;
1433                    edges.insert(callee);
1434                }
1435                Some(StringPosEdge::Advance) => {}
1436                None => return None,
1437            }
1438        }
1439    }
1440
1441    if !any_intra {
1442        return None;
1443    }
1444
1445    ranks_from_same_edges(&names, &same_edges)
1446}
1447
1448pub(crate) fn is_scalar_like_type(type_name: &str) -> bool {
1449    matches!(
1450        type_name,
1451        "Int" | "Float" | "Bool" | "String" | "Char" | "Byte" | "Unit"
1452    )
1453}
1454
1455/// SizeOf-measure param indices for a fn — every non-scalar param
1456/// position contributes a term to the structural measure. Matches
1457/// the picks made for the Lean `termination_by` clause and the
1458/// Dafny native `decreases` tuple so the same params drive measure
1459/// inference on both backends.
1460pub fn sizeof_measure_param_indices(fd: &FnDef) -> Vec<usize> {
1461    fd.params
1462        .iter()
1463        .enumerate()
1464        .filter_map(|(idx, (_, type_name))| (!is_scalar_like_type(type_name)).then_some(idx))
1465        .collect()
1466}
1467
1468/// True iff some intra-SCC call passes a non-trivially-shrinking
1469/// expression into a callee's sizeOf parameter — tail-recursive
1470/// accumulator patterns like `[x] + acc` / `List.prepend(x, acc)`
1471/// / `acc + [x]`, plus arbitrary `FnCall(...)` results we can't
1472/// statically prove shrink. These all fail termination-by-measure
1473/// even though fuel encoding handles them fine; backends fall back
1474/// to fuel for those SCCs.
1475pub fn scc_has_growing_accumulator(fns: &[&FnDef]) -> bool {
1476    let names: HashSet<String> = fns.iter().map(|fd| fd.name.clone()).collect();
1477    let mut sizeof_indices: HashMap<String, Vec<usize>> = HashMap::new();
1478    for fd in fns {
1479        sizeof_indices.insert(fd.name.clone(), sizeof_measure_param_indices(fd));
1480    }
1481    for fd in fns {
1482        // String interpolation in a body that ends up calling an
1483        // intra-SCC fn confuses Lean's wf elaboration — the recursive
1484        // call lives inside the interpolation's anonymous `s!"{...}"`
1485        // expansion, and the wf check pins anonymous binders the
1486        // tactic chain can't pin back to the source-named param.
1487        // Wumpus's `roomListItem` (`_ => s!"{rs}, {roomList rest}"`)
1488        // is the canonical shape. Fall back to fuel for this case.
1489        if body_has_intra_scc_call_in_interpolation(fd.body.as_ref(), &names) {
1490            return true;
1491        }
1492        for (callee_raw, args) in collect_calls_from_body(fd.body.as_ref()) {
1493            let Some(callee_name) = canonical_callee_name(&callee_raw, &names) else {
1494                continue;
1495            };
1496            let Some(callee_indices) = sizeof_indices.get(&callee_name) else {
1497                continue;
1498            };
1499            for callee_idx in callee_indices {
1500                let Some(arg) = args.get(*callee_idx) else {
1501                    continue;
1502                };
1503                if !arg_is_non_growing(arg) {
1504                    return true;
1505                }
1506            }
1507        }
1508    }
1509    false
1510}
1511
1512fn body_has_intra_scc_call_in_interpolation(body: &FnBody, names: &HashSet<String>) -> bool {
1513    let FnBody::Block(stmts) = body;
1514    stmts.iter().any(|s| match s {
1515        Stmt::Binding(_, _, e) | Stmt::Expr(e) => {
1516            expr_has_intra_scc_call_in_interpolation(e, names)
1517        }
1518    })
1519}
1520
1521fn expr_has_intra_scc_call_in_interpolation(expr: &Spanned<Expr>, names: &HashSet<String>) -> bool {
1522    use crate::ast::StrPart;
1523    match &expr.node {
1524        Expr::InterpolatedStr(parts) => parts.iter().any(|p| match p {
1525            StrPart::Parsed(inner) => expr_calls_into_set(inner, names),
1526            _ => false,
1527        }),
1528        Expr::Match { subject, arms, .. } => {
1529            expr_has_intra_scc_call_in_interpolation(subject, names)
1530                || arms
1531                    .iter()
1532                    .any(|a| expr_has_intra_scc_call_in_interpolation(&a.body, names))
1533        }
1534        Expr::FnCall(f, args) => {
1535            expr_has_intra_scc_call_in_interpolation(f, names)
1536                || args
1537                    .iter()
1538                    .any(|a| expr_has_intra_scc_call_in_interpolation(a, names))
1539        }
1540        Expr::TailCall(boxed) => boxed
1541            .args
1542            .iter()
1543            .any(|a| expr_has_intra_scc_call_in_interpolation(a, names)),
1544        Expr::BinOp(_, l, r) => {
1545            expr_has_intra_scc_call_in_interpolation(l, names)
1546                || expr_has_intra_scc_call_in_interpolation(r, names)
1547        }
1548        Expr::Constructor(_, Some(inner)) | Expr::Attr(inner, _) | Expr::Neg(inner) => {
1549            expr_has_intra_scc_call_in_interpolation(inner, names)
1550        }
1551        _ => false,
1552    }
1553}
1554
1555fn expr_calls_into_set(expr: &Spanned<Expr>, names: &HashSet<String>) -> bool {
1556    match &expr.node {
1557        Expr::FnCall(f, args) => {
1558            let head_hit = expr_to_dotted_name(f)
1559                .as_deref()
1560                .map(|n| canonical_callee_name(n, names).is_some())
1561                .unwrap_or(false);
1562            head_hit
1563                || args.iter().any(|a| expr_calls_into_set(a, names))
1564                || expr_calls_into_set(f, names)
1565        }
1566        Expr::TailCall(boxed) => {
1567            canonical_callee_name(&boxed.target, names).is_some()
1568                || boxed.args.iter().any(|a| expr_calls_into_set(a, names))
1569        }
1570        Expr::Attr(inner, _) | Expr::Neg(inner) | Expr::Constructor(_, Some(inner)) => {
1571            expr_calls_into_set(inner, names)
1572        }
1573        Expr::BinOp(_, l, r) => expr_calls_into_set(l, names) || expr_calls_into_set(r, names),
1574        Expr::Match { subject, arms, .. } => {
1575            expr_calls_into_set(subject, names)
1576                || arms.iter().any(|a| expr_calls_into_set(&a.body, names))
1577        }
1578        _ => false,
1579    }
1580}
1581
1582fn arg_is_non_growing(expr: &Spanned<Expr>) -> bool {
1583    match &expr.node {
1584        Expr::Ident(_) | Expr::Resolved { .. } | Expr::Literal(_) | Expr::Attr(..) => true,
1585        // `Map.entries(x)` lowers to `x` (AverMap is list-backed; `entries` is
1586        // the identity), so it is size-PRESERVING — non-growing exactly when
1587        // its argument is. This lets a json-shaped delegation
1588        // `objectSafe(m) = entriesSafe(Map.entries m)` read as the same-`sizeOf`
1589        // lex step it actually is (the rank, not the size, decreases). Sound:
1590        // a wrong size-claim only makes the SCC's native `termination_by` fail
1591        // to elaborate (fall back to fuel), never a false theorem.
1592        Expr::FnCall(callee, args) => {
1593            expr_to_dotted_name(callee).as_deref() == Some("Map.entries")
1594                && args.len() == 1
1595                && arg_is_non_growing(&args[0])
1596        }
1597        _ => false,
1598    }
1599}
1600
1601pub(crate) fn supports_mutual_sizeof_ranked(
1602    component: &[&FnDef],
1603) -> Option<HashMap<String, usize>> {
1604    if component.len() < 2 {
1605        return None;
1606    }
1607    let names: HashSet<String> = component.iter().map(|fd| fd.name.clone()).collect();
1608    let metric_indices: HashMap<String, Vec<usize>> = component
1609        .iter()
1610        .map(|fd| (fd.name.clone(), sizeof_measure_param_indices(fd)))
1611        .collect();
1612    if component.iter().any(|fd| {
1613        metric_indices
1614            .get(&fd.name)
1615            .is_none_or(|indices| indices.is_empty())
1616    }) {
1617        return None;
1618    }
1619
1620    let mut same_edges: HashMap<String, HashSet<String>> =
1621        names.iter().map(|n| (n.clone(), HashSet::new())).collect();
1622    let mut any_intra = false;
1623    for fd in component {
1624        let caller_metric_indices = metric_indices.get(&fd.name)?;
1625        let caller_metric_params: Vec<&str> = caller_metric_indices
1626            .iter()
1627            .filter_map(|idx| fd.params.get(*idx).map(|(name, _)| name.as_str()))
1628            .collect();
1629        for (callee_raw, args) in collect_calls_from_body(fd.body.as_ref()) {
1630            let Some(callee) = canonical_callee_name(&callee_raw, &names) else {
1631                continue;
1632            };
1633            any_intra = true;
1634            let callee_metric_indices = metric_indices.get(&callee)?;
1635            let is_same_edge = callee_metric_indices.len() == caller_metric_params.len()
1636                && callee_metric_indices
1637                    .iter()
1638                    .enumerate()
1639                    .all(|(pos, callee_idx)| {
1640                        let Some(arg) = args.get(*callee_idx).cloned() else {
1641                            return false;
1642                        };
1643                        is_ident(arg, caller_metric_params[pos])
1644                    });
1645            if is_same_edge {
1646                let edges = same_edges.get_mut(&fd.name)?;
1647                edges.insert(callee);
1648            }
1649        }
1650    }
1651    if !any_intra {
1652        return None;
1653    }
1654
1655    let ranks = ranks_from_same_edges(&names, &same_edges)?;
1656    let mut out = HashMap::new();
1657    for fd in component {
1658        let rank = ranks.get(&fd.name).cloned()?;
1659        out.insert(fd.name.clone(), rank);
1660    }
1661    Some(out)
1662}
1663
1664/// True when every callsite of `fn_name` visible inside `ctx` is also
1665/// inside `ctx` — i.e. no module that uses `ctx` for proof emission can
1666/// have an external caller we can't analyze. Holds when either:
1667/// - the fn lives in the entry items and the entry module has no
1668///   explicit `exposes` clause (single-file program or implicitly-
1669///   private surface), or has an `exposes` list that omits `fn_name`;
1670/// - the fn lives in a dep module and that dep module's `exposes`
1671///   omits `fn_name`. Dep modules without an `exposes` clause are
1672///   conservative — treated as exposed since downstream consumers may
1673///   pull them in unscoped; we don't yet thread that distinction so
1674///   the conservative side is "not closed-world".
1675///
1676/// The `ctx.items` carry a `TopLevel::Module(m)` for entry; dep
1677/// modules currently surface no `Module` block in `ModuleInfo`, so
1678/// the dep-module branch returns `false` until that plumbing is
1679/// added. Practically this matches the issue-84 scope: per-file
1680/// proof targets (fibonacci.av) where the analyzed fn is in the
1681/// entry module.
1682pub(crate) fn is_closed_world_pure_fn(fn_name: &str, inputs: &ProofLowerInputs) -> bool {
1683    let entry_module = inputs.entry_items.iter().find_map(|item| match item {
1684        TopLevel::Module(m) => Some(m),
1685        _ => None,
1686    });
1687    let entry_fn_names: std::collections::HashSet<&str> = inputs
1688        .entry_items
1689        .iter()
1690        .filter_map(|item| match item {
1691            TopLevel::FnDef(fd) => Some(fd.name.as_str()),
1692            _ => None,
1693        })
1694        .collect();
1695    if entry_fn_names.contains(fn_name) {
1696        match entry_module {
1697            None => return true,
1698            Some(m) => {
1699                if m.exposes_line.is_none() {
1700                    return true;
1701                }
1702                return !m.exposes.iter().any(|e| e == fn_name);
1703            }
1704        }
1705    }
1706    false
1707}
1708
1709/// Find the unique external caller of `target_fn` in `ctx` and return
1710/// the path-constraint predicates that wrap the callsite — flipped to
1711/// positive form and substituted into the callee's variable space.
1712///
1713/// Returns `None` when:
1714/// - there are zero or more than one callers (the issue-84 single-caller
1715///   simplification — same idea as opaque types having one smart
1716///   constructor);
1717/// - the caller has multiple callsites with non-identical enclosing
1718///   guards (no single weakest precondition);
1719/// - the arg passed at the countdown-param position isn't a plain local
1720///   name (caller did `worker(n + 5)` or `worker(f(n))` — substitution
1721///   would need arithmetic the proof emitter doesn't run);
1722/// - any enclosing guard is `match (non-bool expression) { ... }` —
1723///   only `match Bool { true/false -> ... }` and `if/then/else` over
1724///   bool subjects give a flat predicate, anything else is rejected so
1725///   the fall-through to fuel stays sound.
1726///
1727/// On success the returned list's conjunction is the precondition for
1728/// `target_fn`'s aux: every clause is a positive `Spanned<Expr>` Bool
1729/// predicate in the callee's variable space, ready to feed straight
1730/// into `emit_expr` the same way opaque-type predicates do.
1731pub(crate) fn find_single_external_caller_predicate(
1732    target_fn: &str,
1733    target_param_index: usize,
1734    callee_param_name: &str,
1735    inputs: &ProofLowerInputs,
1736) -> Option<Vec<Spanned<Expr>>> {
1737    let all_callers: Vec<&FnDef> = inputs
1738        .entry_items
1739        .iter()
1740        .filter_map(|item| match item {
1741            TopLevel::FnDef(fd) if fd.name != target_fn => Some(fd),
1742            _ => None,
1743        })
1744        .chain(
1745            inputs
1746                .dep_modules
1747                .iter()
1748                .flat_map(|m| m.fn_defs.iter().filter(|fd| fd.name != target_fn)),
1749        )
1750        .filter(|fd| {
1751            collect_calls_from_body(fd.body.as_ref())
1752                .iter()
1753                .any(|(n, _)| call_matches(n, target_fn))
1754        })
1755        .collect();
1756
1757    if all_callers.len() != 1 {
1758        return None;
1759    }
1760    let caller = all_callers[0];
1761
1762    let mut callsites: Vec<(Vec<Spanned<Expr>>, String)> = Vec::new();
1763    for stmt in caller.body.stmts() {
1764        match stmt {
1765            Stmt::Binding(_, _, expr) | Stmt::Expr(expr) => {
1766                walk_caller_collect_callsite_guards(
1767                    expr,
1768                    target_fn,
1769                    target_param_index,
1770                    &[],
1771                    &mut callsites,
1772                );
1773            }
1774        }
1775    }
1776
1777    if callsites.is_empty() {
1778        return None;
1779    }
1780
1781    // For multiple callsites: require IDENTICAL guard lists (else the
1782    // weakest precondition isn't trivial and we punt to fuel).
1783    let (first_guards, first_arg_name) = &callsites[0];
1784    for (other_guards, other_arg_name) in &callsites[1..] {
1785        if other_arg_name != first_arg_name || other_guards != first_guards {
1786            return None;
1787        }
1788    }
1789
1790    let arg_name = first_arg_name.as_str();
1791    let predicate = first_guards
1792        .iter()
1793        .filter(|g| crate::codegen::recursion::expr_references_ident(g, arg_name))
1794        .map(|g| {
1795            crate::codegen::recursion::substitute_ident_in_expr(g, arg_name, callee_param_name)
1796        })
1797        .collect::<Vec<_>>();
1798
1799    Some(predicate)
1800}
1801
1802/// Walker that collects all callsites of `target_fn` inside `expr`,
1803/// pairing each with (a) the chain of enclosing `match Bool` /
1804/// `if-then-else` guards in **positive form** (false-arm guards
1805/// flipped via `flip_comparison_binop`) and (b) the caller-side
1806/// variable name passed at `target_param_index`.
1807fn walk_caller_collect_callsite_guards(
1808    expr: &Spanned<Expr>,
1809    target_fn: &str,
1810    target_param_index: usize,
1811    enclosing_guards: &[Spanned<Expr>],
1812    out: &mut Vec<(Vec<Spanned<Expr>>, String)>,
1813) {
1814    match &expr.node {
1815        Expr::FnCall(callee, args) => {
1816            if let Some(name) = expr_to_dotted_name(callee)
1817                && call_matches(&name, target_fn)
1818                && let Some(arg_at_idx) = args.get(target_param_index)
1819                && let Some(arg_name) = local_name_of(arg_at_idx)
1820            {
1821                out.push((enclosing_guards.to_vec(), arg_name.to_string()));
1822            }
1823            walk_caller_collect_callsite_guards(
1824                callee,
1825                target_fn,
1826                target_param_index,
1827                enclosing_guards,
1828                out,
1829            );
1830            for arg in args {
1831                walk_caller_collect_callsite_guards(
1832                    arg,
1833                    target_fn,
1834                    target_param_index,
1835                    enclosing_guards,
1836                    out,
1837                );
1838            }
1839        }
1840        Expr::TailCall(boxed) => {
1841            for arg in &boxed.args {
1842                walk_caller_collect_callsite_guards(
1843                    arg,
1844                    target_fn,
1845                    target_param_index,
1846                    enclosing_guards,
1847                    out,
1848                );
1849            }
1850        }
1851        Expr::Match { subject, arms } => {
1852            for arm in arms {
1853                let mut new_guards: Vec<Spanned<Expr>> = enclosing_guards.to_vec();
1854                match &arm.pattern {
1855                    Pattern::Literal(crate::ast::Literal::Bool(true)) => {
1856                        new_guards.push((**subject).clone());
1857                    }
1858                    Pattern::Literal(crate::ast::Literal::Bool(false)) => {
1859                        if let Some(flipped) =
1860                            crate::codegen::recursion::flip_comparison_binop(subject)
1861                        {
1862                            new_guards.push(flipped);
1863                        } else {
1864                            // Subject isn't a comparison BinOp — can't
1865                            // flip into a positive Aver expression.
1866                            // Drop the guard so the predicate stays
1867                            // sound (subset of true constraints).
1868                        }
1869                    }
1870                    _ => {
1871                        // Non-bool arm (e.g., `0 -> ...`, `[head, ..tail]
1872                        // -> ...`). Bind constraints aren't part of the
1873                        // bool-predicate vocabulary; treat the arm as
1874                        // transparent.
1875                    }
1876                }
1877                walk_caller_collect_callsite_guards(
1878                    &arm.body,
1879                    target_fn,
1880                    target_param_index,
1881                    &new_guards,
1882                    out,
1883                );
1884            }
1885            walk_caller_collect_callsite_guards(
1886                subject,
1887                target_fn,
1888                target_param_index,
1889                enclosing_guards,
1890                out,
1891            );
1892        }
1893        Expr::BinOp(_, l, r) => {
1894            walk_caller_collect_callsite_guards(
1895                l,
1896                target_fn,
1897                target_param_index,
1898                enclosing_guards,
1899                out,
1900            );
1901            walk_caller_collect_callsite_guards(
1902                r,
1903                target_fn,
1904                target_param_index,
1905                enclosing_guards,
1906                out,
1907            );
1908        }
1909        Expr::Attr(inner, _) | Expr::Neg(inner) | Expr::ErrorProp(inner) => {
1910            walk_caller_collect_callsite_guards(
1911                inner,
1912                target_fn,
1913                target_param_index,
1914                enclosing_guards,
1915                out,
1916            );
1917        }
1918        Expr::Constructor(_, arg) => {
1919            if let Some(inner) = arg {
1920                walk_caller_collect_callsite_guards(
1921                    inner,
1922                    target_fn,
1923                    target_param_index,
1924                    enclosing_guards,
1925                    out,
1926                );
1927            }
1928        }
1929        Expr::InterpolatedStr(parts) => {
1930            for p in parts {
1931                if let crate::ast::StrPart::Parsed(inner) = p {
1932                    walk_caller_collect_callsite_guards(
1933                        inner,
1934                        target_fn,
1935                        target_param_index,
1936                        enclosing_guards,
1937                        out,
1938                    );
1939                }
1940            }
1941        }
1942        Expr::List(items) | Expr::Tuple(items) | Expr::IndependentProduct(items, _) => {
1943            for item in items {
1944                walk_caller_collect_callsite_guards(
1945                    item,
1946                    target_fn,
1947                    target_param_index,
1948                    enclosing_guards,
1949                    out,
1950                );
1951            }
1952        }
1953        Expr::MapLiteral(entries) => {
1954            for (k, v) in entries {
1955                walk_caller_collect_callsite_guards(
1956                    k,
1957                    target_fn,
1958                    target_param_index,
1959                    enclosing_guards,
1960                    out,
1961                );
1962                walk_caller_collect_callsite_guards(
1963                    v,
1964                    target_fn,
1965                    target_param_index,
1966                    enclosing_guards,
1967                    out,
1968                );
1969            }
1970        }
1971        Expr::RecordCreate { fields, .. } => {
1972            for (_, v) in fields {
1973                walk_caller_collect_callsite_guards(
1974                    v,
1975                    target_fn,
1976                    target_param_index,
1977                    enclosing_guards,
1978                    out,
1979                );
1980            }
1981        }
1982        Expr::RecordUpdate { base, updates, .. } => {
1983            walk_caller_collect_callsite_guards(
1984                base,
1985                target_fn,
1986                target_param_index,
1987                enclosing_guards,
1988                out,
1989            );
1990            for (_, v) in updates {
1991                walk_caller_collect_callsite_guards(
1992                    v,
1993                    target_fn,
1994                    target_param_index,
1995                    enclosing_guards,
1996                    out,
1997                );
1998            }
1999        }
2000        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } => {}
2001    }
2002}
2003
2004/// Classify every recursive pure fn in `inputs`. The returned map
2005/// assigns each supported function a [`RecursionPlan`]; anything
2006/// that falls outside the recognised shapes becomes a
2007/// [`ProofModeIssue`].
2008///
2009/// **Consumers should not call this directly.** After Step 18 / 20
2010/// the only caller is `proof_lower::populate_fn_contracts`, which
2011/// translates the output into `ProofIR.fn_contracts` and
2012/// `ProofIR.unclassified_fns`. Backends read those fields instead.
2013/// The fn stays `pub` only because the diff test
2014/// (`tests/proof_ir_diff.rs`) cross-checks ProofIR against the
2015/// legacy classifier output — that test is itself slated for
2016/// rationalisation once the migration is fully bedded down.
2017/// Scope-aware classifier. `scope = None` runs against entry only,
2018/// `Some(prefix)` against one dep module. Aver's module DAG
2019/// invariant rules out cross-module recursion SCCs, so per-scope
2020/// classification is the canonical view and avoids two
2021/// same-bare-name fns from different modules colliding in the
2022/// SCC analyser's `HashMap<name, _>`. The `global_view` flag keeps
2023/// the legacy "chain all fns, classify globally" behaviour for
2024/// `tests/proof_ir_diff.rs` cross-check tests; production callers
2025/// always pass `global_view = false` and walk scopes themselves.
2026pub fn analyze_plans_in_scope(
2027    inputs: &ProofLowerInputs,
2028    scope: Option<&str>,
2029    global_view: bool,
2030) -> (HashMap<String, RecursionPlan>, Vec<ProofModeIssue>) {
2031    let mut plans = HashMap::new();
2032    let mut issues = Vec::new();
2033
2034    let all_pure = if global_view {
2035        inputs.pure_fns()
2036    } else {
2037        inputs.pure_fns_in_scope(scope)
2038    };
2039    let recursive_names = if global_view {
2040        inputs.recursive_pure_fn_names()
2041    } else {
2042        inputs.recursive_pure_fn_names_in_scope(scope)
2043    };
2044    let components = call_graph::ordered_fn_components(&all_pure, inputs.module_prefixes);
2045
2046    for component in components {
2047        if component.is_empty() {
2048            continue;
2049        }
2050        let is_recursive_component =
2051            component.len() > 1 || recursive_names.contains(&component[0].name);
2052        if !is_recursive_component {
2053            continue;
2054        }
2055
2056        if component.len() > 1 {
2057            if supports_mutual_int_countdown(&component) {
2058                for fd in &component {
2059                    plans.insert(fd.name.clone(), RecursionPlan::MutualIntCountdown);
2060                }
2061            } else if let Some(ranks) = supports_mutual_string_pos_advance(&component) {
2062                for fd in &component {
2063                    if let Some(rank) = ranks.get(&fd.name).cloned() {
2064                        plans.insert(
2065                            fd.name.clone(),
2066                            RecursionPlan::MutualStringPosAdvance { rank },
2067                        );
2068                    }
2069                }
2070            } else if let Some(rankings) = supports_mutual_sizeof_ranked(&component) {
2071                for fd in &component {
2072                    if let Some(rank) = rankings.get(&fd.name).cloned() {
2073                        plans.insert(fd.name.clone(), RecursionPlan::MutualSizeOfRanked { rank });
2074                    }
2075                }
2076            } else {
2077                let names = component
2078                    .iter()
2079                    .map(|fd| fd.name.clone())
2080                    .collect::<Vec<_>>()
2081                    .join(", ");
2082                let line = component.iter().map(|fd| fd.line).min().unwrap_or(1);
2083                issues.push(ProofModeIssue {
2084                    line,
2085                    message: format!(
2086                        "unsupported mutual recursion group (currently supported in proof mode: Int countdown on first param): {}",
2087                        names
2088                    ),
2089                });
2090            }
2091            continue;
2092        }
2093
2094        let fd = component[0];
2095        if crate::codegen::lean::recurrence::detect_second_order_int_linear_recurrence(fd).is_some()
2096        {
2097            plans.insert(fd.name.clone(), RecursionPlan::LinearRecurrence2);
2098        } else if let Some((param_index, bound)) = single_int_ascending_param(fd) {
2099            plans.insert(
2100                fd.name.clone(),
2101                RecursionPlan::IntAscending { param_index, bound },
2102            );
2103        } else if let Some(param_index) = single_int_countdown_param_index(fd) {
2104            // Try native guarded emission first — when the body has the
2105            // clean `match p { 0 -> BASE; _ -> rec(p-1, ...) }` shape and
2106            // the fn is closed-world (no external module can call it
2107            // with a negative param), Lean can emit a real recursive
2108            // def with `(h : p ≥ 0)` precondition + termination on
2109            // `p.natAbs`. Falls back to the fuel-encoded IntCountdown
2110            // plan when either condition fails.
2111            if is_closed_world_pure_fn(&fd.name, inputs)
2112                && let Some((base_arm_literal, base_arm_body, wildcard_arm_body)) =
2113                    int_countdown_native_arms(fd, param_index)
2114                && let Some((callee_param_name, _)) = fd.params.get(param_index)
2115            {
2116                // Caller-derived precondition first; empty means
2117                // "no single external caller in this artifact" and the
2118                // Lean emitter defaults to `(h_dom : p ≥ 0)` for
2119                // legacy fibTR-shape compatibility.
2120                let precondition = find_single_external_caller_predicate(
2121                    &fd.name,
2122                    param_index,
2123                    callee_param_name,
2124                    inputs,
2125                )
2126                .unwrap_or_default();
2127                plans.insert(
2128                    fd.name.clone(),
2129                    RecursionPlan::IntCountdownGuarded {
2130                        param_index,
2131                        base_arm_literal,
2132                        base_arm_body,
2133                        wildcard_arm_body,
2134                        precondition,
2135                    },
2136                );
2137            } else {
2138                plans.insert(fd.name.clone(), RecursionPlan::IntCountdown { param_index });
2139            }
2140        } else if let Some((param_index, divisor, helper_fn)) =
2141            single_int_floor_div_countdown(fd, inputs)
2142        {
2143            // Floor-division countdown — every self-call shrinks an
2144            // Int param by `Result.withDefault(Int.div(p, k), d)`
2145            // (literal k >= 2, possibly through a unary wrapper fn)
2146            // and the guard chain at every self-call site implies
2147            // `p >= 1`. Both side-conditions are validated above;
2148            // backends emit a native well-founded def.
2149            plans.insert(
2150                fd.name.clone(),
2151                RecursionPlan::IntFloorDivCountdown {
2152                    param_index,
2153                    divisor,
2154                    helper_fn,
2155                },
2156            );
2157        } else if supports_single_sizeof_structural(fd, inputs)
2158            || (single_list_structural_param_index(fd).is_none()
2159                && single_adt_structural_param_index(fd, inputs).is_some())
2160        {
2161            // Single-param ADT structural recursion (the OR's second arm) covers
2162            // the accumulator-fold inner loop (`triTR` / `factTR`): one recursive-
2163            // ADT param strictly shrinks every call while a sibling accumulator is
2164            // threaded. Lean infers structural termination on the shrinking param,
2165            // so it emits as a plain `def` (no fuel) just like the multi-param
2166            // measure case. Gated on NOT being list-structural so a fn that also
2167            // peels a `List` (`drop(n, xs)` shrinks both the `Nat` and the list)
2168            // keeps its `ListStructural` plan — its laws induct on the list, and
2169            // stealing it to the ADT driver regressed `drop`-concat decomposition.
2170            plans.insert(fd.name.clone(), RecursionPlan::SizeOfStructural);
2171        } else if let Some(param_index) = single_list_structural_param_index(fd) {
2172            plans.insert(
2173                fd.name.clone(),
2174                RecursionPlan::ListStructural { param_index },
2175            );
2176        } else if supports_single_string_pos_advance(fd) {
2177            plans.insert(fd.name.clone(), RecursionPlan::StringPosAdvance);
2178        } else {
2179            issues.push(ProofModeIssue {
2180                line: fd.line,
2181                message: format!(
2182                    "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)",
2183                    fd.name
2184                ),
2185            });
2186        }
2187    }
2188
2189    (plans, issues)
2190}