Skip to main content

aver/analysis/
shape.rs

1//! Per-fn shape recognition — typed facts over resolved HIR.
2//!
3//! Stage 1 of issue #232 (0.23 "Shape"). This module is the
4//! *recognition-only* layer: walks `ResolvedFnDef` bodies, builds
5//! `Facts` per fn, emits multi-label archetype classifications,
6//! computes call-graph SCCs. **No `ModuleShape` vector / Kind /
7//! Layer / renderer here** — those live one tier up in
8//! `aver::diagnostics::shape` (presentation) or
9//! `aver::codegen::proof_lower` (meaning), and the future Stage 6
10//! patterns / relations (`WrapperOverRecursion`, `ResultPipeline`,
11//! …) land here next to the primitives they're built from.
12
13use std::collections::{HashMap, HashSet};
14
15use crate::ir::FnId;
16use crate::ir::hir::{
17    ResolvedCallee, ResolvedCtor, ResolvedExpr, ResolvedFnDef, ResolvedMatchArm, ResolvedPattern,
18    ResolvedStmt, ResolvedStrPart,
19};
20use crate::types::Type;
21
22// ─── Per-fn AST facts ────────────────────────────────────────────────────────
23
24#[derive(Debug, Default)]
25pub struct Facts {
26    pub calls_to: HashSet<FnId>,
27    pub tail_calls: HashSet<FnId>,
28    pub builtin_calls: Vec<String>,
29    pub ctor_constructs: Vec<ResolvedCtor>,
30    pub ctor_match_patterns: usize,
31    pub other_match_patterns: usize,
32    pub has_match: bool,
33    pub has_error_prop: bool,
34    pub record_creates: usize,
35    pub last_stmt_is_match: bool,
36    pub only_stmt_is_literal: bool,
37    pub body_stmt_count: usize,
38    pub has_interp_str: bool,
39    pub string_builtin_calls: usize,
40    pub has_match_with_err_arm: bool,
41}
42
43fn walk_expr(e: &ResolvedExpr, facts: &mut Facts) {
44    match e {
45        ResolvedExpr::Literal(_) | ResolvedExpr::Ident(_) | ResolvedExpr::Resolved { .. } => {}
46        ResolvedExpr::Attr(inner, _) => walk_expr(&inner.node, facts),
47        ResolvedExpr::Call(callee, args) => {
48            match callee {
49                ResolvedCallee::Fn(id) => {
50                    facts.calls_to.insert(*id);
51                }
52                ResolvedCallee::Builtin(name) => {
53                    if name.starts_with("String.") {
54                        facts.string_builtin_calls += 1;
55                    }
56                    facts.builtin_calls.push(name.clone());
57                }
58                ResolvedCallee::Intrinsic(_) => {}
59                ResolvedCallee::LocalSlot { .. } => {}
60                ResolvedCallee::Unresolved { callee } => walk_expr(&callee.node, facts),
61            }
62            for a in args {
63                walk_expr(&a.node, facts);
64            }
65        }
66        ResolvedExpr::BinOp(_, a, b) => {
67            walk_expr(&a.node, facts);
68            walk_expr(&b.node, facts);
69        }
70        ResolvedExpr::Neg(inner) => walk_expr(&inner.node, facts),
71        ResolvedExpr::Match { subject, arms } => {
72            facts.has_match = true;
73            walk_expr(&subject.node, facts);
74            for arm in arms {
75                count_arm_pattern(&arm.pattern, facts);
76                walk_match_arm(arm, facts);
77            }
78        }
79        ResolvedExpr::Ctor(c, args) => {
80            facts.ctor_constructs.push(c.clone());
81            for a in args {
82                walk_expr(&a.node, facts);
83            }
84        }
85        ResolvedExpr::ErrorProp(inner) => {
86            facts.has_error_prop = true;
87            walk_expr(&inner.node, facts);
88        }
89        ResolvedExpr::InterpolatedStr(parts) => {
90            facts.has_interp_str = true;
91            for p in parts {
92                if let ResolvedStrPart::Parsed(e) = p {
93                    walk_expr(&e.node, facts);
94                }
95            }
96        }
97        ResolvedExpr::List(xs) | ResolvedExpr::Tuple(xs) => {
98            for x in xs {
99                walk_expr(&x.node, facts);
100            }
101        }
102        ResolvedExpr::MapLiteral(pairs) => {
103            for (k, v) in pairs {
104                walk_expr(&k.node, facts);
105                walk_expr(&v.node, facts);
106            }
107        }
108        ResolvedExpr::RecordCreate { fields, .. } => {
109            facts.record_creates += 1;
110            for (_, v) in fields {
111                walk_expr(&v.node, facts);
112            }
113        }
114        ResolvedExpr::RecordUpdate { base, updates, .. } => {
115            walk_expr(&base.node, facts);
116            for (_, v) in updates {
117                walk_expr(&v.node, facts);
118            }
119        }
120        ResolvedExpr::TailCall { target, args } => {
121            facts.tail_calls.insert(*target);
122            facts.calls_to.insert(*target);
123            for a in args {
124                walk_expr(&a.node, facts);
125            }
126        }
127        ResolvedExpr::IndependentProduct(xs, _) => {
128            for x in xs {
129                walk_expr(&x.node, facts);
130            }
131        }
132    }
133}
134
135fn count_arm_pattern(p: &ResolvedPattern, facts: &mut Facts) {
136    match p {
137        ResolvedPattern::Ctor(ctor, _) => {
138            facts.ctor_match_patterns += 1;
139            if matches!(
140                ctor,
141                ResolvedCtor::Builtin(crate::ir::hir::BuiltinCtor::ResultErr)
142            ) {
143                facts.has_match_with_err_arm = true;
144            }
145        }
146        _ => facts.other_match_patterns += 1,
147    }
148}
149
150fn walk_match_arm(arm: &ResolvedMatchArm, facts: &mut Facts) {
151    walk_expr(&arm.body.node, facts);
152}
153
154pub fn extract_facts(fd: &ResolvedFnDef) -> Facts {
155    let mut facts = Facts::default();
156    let stmts = fd.body.stmts();
157    facts.body_stmt_count = stmts.len();
158    for stmt in stmts {
159        match stmt {
160            ResolvedStmt::Binding { value, .. } => walk_expr(&value.node, &mut facts),
161            ResolvedStmt::Expr(value) => walk_expr(&value.node, &mut facts),
162        }
163    }
164    if let Some(last) = stmts.last() {
165        let expr = match last {
166            ResolvedStmt::Binding { value, .. } => &value.node,
167            ResolvedStmt::Expr(value) => &value.node,
168        };
169        facts.last_stmt_is_match = matches!(expr, ResolvedExpr::Match { .. });
170    }
171    facts.only_stmt_is_literal = stmts.len() == 1 && {
172        let expr = match &stmts[0] {
173            ResolvedStmt::Binding { value, .. } => &value.node,
174            ResolvedStmt::Expr(value) => &value.node,
175        };
176        matches!(
177            expr,
178            ResolvedExpr::Literal(_)
179                | ResolvedExpr::List(_)
180                | ResolvedExpr::Tuple(_)
181                | ResolvedExpr::MapLiteral(_)
182                | ResolvedExpr::RecordCreate { .. }
183                | ResolvedExpr::Ctor(_, _)
184        )
185    };
186    facts
187}
188
189// ─── Per-fn classification ───────────────────────────────────────────────────
190
191pub fn classify(fd: &ResolvedFnDef, facts: &Facts, scc: &HashSet<FnId>) -> Vec<Archetype> {
192    let mut labels = Vec::new();
193
194    let self_call = facts.calls_to.contains(&fd.fn_id) || facts.tail_calls.contains(&fd.fn_id);
195    if self_call {
196        labels.push(Archetype::StructuralRecursion);
197    }
198    if scc.contains(&fd.fn_id) {
199        labels.push(Archetype::SccMutual);
200    }
201
202    if facts.last_stmt_is_match {
203        if facts.ctor_match_patterns >= facts.other_match_patterns && facts.ctor_match_patterns > 0
204        {
205            labels.push(Archetype::MatchDispatcher);
206        } else {
207            labels.push(Archetype::MatchOnValue);
208        }
209    }
210
211    if !fd.effects.is_empty() {
212        let total_calls = facts.calls_to.len() + facts.builtin_calls.len();
213        if total_calls >= 2 {
214            labels.push(Archetype::Orchestration);
215        } else {
216            labels.push(Archetype::EffectfulLeaf);
217        }
218    }
219
220    let is_result_ret = type_is_result(&fd.return_type);
221    if is_result_ret && facts.has_error_prop {
222        labels.push(Archetype::PipelineResult);
223    } else if is_result_ret && facts.has_match_with_err_arm {
224        labels.push(Archetype::ManualResultAdapter);
225    }
226
227    let is_string_ret = matches!(&fd.return_type, Type::Named { name, .. } if name == "String");
228    if is_string_ret
229        && fd.effects.is_empty()
230        && (facts.has_interp_str || facts.string_builtin_calls >= 1)
231        && (facts.body_stmt_count >= 2 || facts.has_interp_str)
232    {
233        labels.push(Archetype::RendererFormatter);
234    }
235
236    if facts.body_stmt_count == 1
237        && (!facts.ctor_constructs.is_empty() || facts.record_creates > 0)
238        && !facts.has_match
239    {
240        labels.push(Archetype::ConstructorWrapper);
241    }
242
243    if fd.params.is_empty()
244        && facts.calls_to.is_empty()
245        && facts.builtin_calls.is_empty()
246        && fd.effects.is_empty()
247        && facts.only_stmt_is_literal
248    {
249        labels.push(Archetype::DataAsFunction);
250    }
251
252    if facts.body_stmt_count == 1
253        && !facts.has_match
254        && !self_call
255        && fd.effects.is_empty()
256        && (!facts.builtin_calls.is_empty() || !facts.calls_to.is_empty())
257    {
258        labels.push(Archetype::TrivialHelper);
259    }
260
261    if facts.body_stmt_count == 1
262        && !facts.has_match
263        && !self_call
264        && fd.effects.is_empty()
265        && facts.builtin_calls.is_empty()
266        && facts.calls_to.is_empty()
267        && facts.ctor_constructs.is_empty()
268        && !fd.params.is_empty()
269    {
270        labels.push(Archetype::PureExpression);
271    }
272
273    if facts.body_stmt_count >= 2
274        && !facts.last_stmt_is_match
275        && fd.effects.is_empty()
276        && (!facts.builtin_calls.is_empty() || !facts.calls_to.is_empty())
277        && !self_call
278    {
279        labels.push(Archetype::LetPipeline);
280    }
281
282    labels
283}
284
285pub fn type_is_result(t: &Type) -> bool {
286    matches!(t, Type::Named { name, .. } if name == "Result")
287}
288
289/// Per-fn archetype label. Multi-label per fn — `classify` returns
290/// a `Vec<Archetype>`; `primary_label` picks one by the precedence
291/// declared in [`Archetype::all`]. Stringified only at presentation
292/// boundaries (renderer, JSON, LSP hover).
293///
294/// Stage 3 of #232 ("Shape") replaced the prior `&'static str`-keyed
295/// representation with this typed enum. The string forms are still
296/// the public JSON / text contract; `as_str` / `parse` translate
297/// at the edges.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
299pub enum Archetype {
300    SccMutual,
301    StructuralRecursion,
302    MatchDispatcher,
303    PipelineResult,
304    ManualResultAdapter,
305    RendererFormatter,
306    MatchOnValue,
307    Orchestration,
308    EffectfulLeaf,
309    LetPipeline,
310    ConstructorWrapper,
311    DataAsFunction,
312    TrivialHelper,
313    PureExpression,
314    /// Fallback for fn bodies that don't match any classifier rule.
315    /// Surfaces as `"unclassified"` in JSON / text output.
316    Unclassified,
317}
318
319impl Archetype {
320    /// Precedence-ordered list. `primary_label` walks this in order;
321    /// the first archetype that fires on a fn wins. Renderers also
322    /// use this for histogram tie-breaking.
323    pub fn all() -> &'static [Archetype] {
324        &[
325            Archetype::SccMutual,
326            Archetype::StructuralRecursion,
327            Archetype::MatchDispatcher,
328            Archetype::PipelineResult,
329            Archetype::ManualResultAdapter,
330            Archetype::RendererFormatter,
331            Archetype::MatchOnValue,
332            Archetype::Orchestration,
333            Archetype::EffectfulLeaf,
334            Archetype::LetPipeline,
335            Archetype::ConstructorWrapper,
336            Archetype::DataAsFunction,
337            Archetype::TrivialHelper,
338            Archetype::PureExpression,
339        ]
340    }
341
342    /// Canonical kebab-case label. Used by renderers, JSON, hover.
343    pub fn as_str(&self) -> &'static str {
344        match self {
345            Archetype::SccMutual => "scc-mutual",
346            Archetype::StructuralRecursion => "structural-recursion",
347            Archetype::MatchDispatcher => "match-dispatcher",
348            Archetype::PipelineResult => "pipeline-result",
349            Archetype::ManualResultAdapter => "manual-result-adapter",
350            Archetype::RendererFormatter => "renderer-formatter",
351            Archetype::MatchOnValue => "match-on-value",
352            Archetype::Orchestration => "orchestration",
353            Archetype::EffectfulLeaf => "effectful-leaf",
354            Archetype::LetPipeline => "let-pipeline",
355            Archetype::ConstructorWrapper => "constructor-wrapper",
356            Archetype::DataAsFunction => "data-as-function",
357            Archetype::TrivialHelper => "trivial-helper",
358            Archetype::PureExpression => "pure-expression",
359            Archetype::Unclassified => "unclassified",
360        }
361    }
362
363    /// Parse a string back into the typed form. Used by callers that
364    /// receive the public JSON shape and want to operate on the typed
365    /// enum (e.g. the research test reading per-folder histograms).
366    pub fn parse(s: &str) -> Option<Archetype> {
367        Some(match s {
368            "scc-mutual" => Archetype::SccMutual,
369            "structural-recursion" => Archetype::StructuralRecursion,
370            "match-dispatcher" => Archetype::MatchDispatcher,
371            "pipeline-result" => Archetype::PipelineResult,
372            "manual-result-adapter" => Archetype::ManualResultAdapter,
373            "renderer-formatter" => Archetype::RendererFormatter,
374            "match-on-value" => Archetype::MatchOnValue,
375            "orchestration" => Archetype::Orchestration,
376            "effectful-leaf" => Archetype::EffectfulLeaf,
377            "let-pipeline" => Archetype::LetPipeline,
378            "constructor-wrapper" => Archetype::ConstructorWrapper,
379            "data-as-function" => Archetype::DataAsFunction,
380            "trivial-helper" => Archetype::TrivialHelper,
381            "pure-expression" => Archetype::PureExpression,
382            "unclassified" => Archetype::Unclassified,
383            _ => return None,
384        })
385    }
386}
387
388pub fn primary_label(labels: &[Archetype]) -> Archetype {
389    for &want in Archetype::all() {
390        if labels.contains(&want) {
391            return want;
392        }
393    }
394    Archetype::Unclassified
395}
396
397// ─── Call-graph SCC (multi-node strongly connected components) ───────────────
398
399pub fn compute_sccs(fns: &[&ResolvedFnDef], facts_by_id: &HashMap<FnId, &Facts>) -> HashSet<FnId> {
400    let mut graph: HashMap<FnId, Vec<FnId>> = HashMap::new();
401    let fn_ids: HashSet<FnId> = fns.iter().map(|f| f.fn_id).collect();
402    for fd in fns {
403        if let Some(facts) = facts_by_id.get(&fd.fn_id) {
404            let edges: Vec<FnId> = facts
405                .calls_to
406                .iter()
407                .copied()
408                .filter(|c| fn_ids.contains(c) && *c != fd.fn_id)
409                .collect();
410            graph.insert(fd.fn_id, edges);
411        }
412    }
413    // Mutual-recursion set: members of a multi-element SCC. Self-edges were
414    // excluded when building `graph` above, so a self-recursive fn is a
415    // 1-element component and is intentionally NOT reported here. The Tarjan
416    // core is shared with `call_graph` via `crate::scc`.
417    let nodes: Vec<FnId> = graph.keys().copied().collect();
418    crate::scc::mutually_recursive(&nodes, &graph)
419}
420
421// ─── Program-level shape (stage 4 of #232) ───────────────────────────────────
422
423/// Per-fn recognition output: precedence-picked primary archetype +
424/// the full multi-label set the classifier fired on.
425///
426/// Facts (the AST walker output) intentionally don't escape here —
427/// they're cheap to recompute if a future consumer wants them, and
428/// keeping the `ProgramShape` value small means downstream passes
429/// (proof_lower, future inliner / monomorphizer) can clone it freely.
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct FnRecognition {
432    pub primary: Archetype,
433    pub labels: Vec<Archetype>,
434}
435
436/// Whole-program shape facts produced by [`analyze_program`].
437///
438/// Stage 4 of issue #232 (0.23 "Shape"). This is the *recognition*
439/// substrate every downstream consumer reads from — `aver shape` CLI,
440/// proof_lower's strategy router, future inliner / monomorphizer.
441/// Stage 6 will grow `patterns: Vec<ModulePattern>` and
442/// `relations: Vec<FnRelation>` next to `per_fn` for the higher-arity
443/// recognitions (`WrapperOverRecursion`, `RefinementSmartConstructor`,
444/// `ResultPipelineChain`, …).
445///
446/// Computed once per compilation per the peer-review note ("compute
447/// ProgramShape once per compilation / per HIR snapshot — don't
448/// persistent-cache per FnId yet"). Threaded as a read-only borrow
449/// from the call site; the analysis tier never mutates it.
450#[derive(Debug, Clone, Default)]
451pub struct ProgramShape {
452    /// Per-fn recognition keyed by stable `FnId`.
453    pub per_fn: std::collections::HashMap<FnId, FnRecognition>,
454    /// Multi-node SCC participants in the local call graph. Kept here
455    /// so consumers don't have to recompute Tarjan to ask
456    /// "is this fn part of a mutual-recursion group?".
457    pub sccs: HashSet<FnId>,
458    /// Whole-module typed patterns (stage 6 of #232). Populated by
459    /// [`analyze_program_with_modules`]; [`analyze_program`] leaves
460    /// this empty since it only sees the resolved-fn snapshot, not
461    /// the source items needed to detect module-level shapes like
462    /// `RefinementSmartConstructor`.
463    pub patterns: Vec<ModulePattern>,
464    /// Source-level sum type names that are eligible as induction
465    /// targets: directly self-referential in at least one variant
466    /// and not indirectly recursive through nested generics the
467    /// per-variant emit can't case-split (e.g. `Some(List<Self>)`).
468    /// Mirrors `proof_lower::detect_induction_target`'s inline
469    /// scan so the detector can read this set instead of
470    /// re-walking type defs.
471    pub inductable_sum_types: HashSet<String>,
472}
473
474impl ProgramShape {
475    /// Recognition for one fn by id. Returns `None` for fns that
476    /// weren't included in the call to [`analyze_program`] (e.g. an
477    /// out-of-tree id or a stale lookup against a refreshed view).
478    pub fn for_fn(&self, fn_id: FnId) -> Option<&FnRecognition> {
479        self.per_fn.get(&fn_id)
480    }
481}
482
483/// Build a [`ProgramShape`] over the post-resolver fn snapshot in one
484/// pass. Caller picks which fns participate (typically every
485/// `ResolvedTopLevel::FnDef` of the entry module, sometimes plus
486/// dep-module fns when a cross-module analysis needs the broader
487/// view).
488///
489/// Two-pass internally: facts first (needed to build the call graph
490/// for SCC detection), then classify with the facts + SCC ready.
491/// `O(N)` over fn bodies, `O(N+E)` Tarjan over the call graph; the
492/// per-compilation cache budget the peer-review pinned.
493pub fn analyze_program(resolved_fns: &[&ResolvedFnDef]) -> ProgramShape {
494    let mut facts_by_id: std::collections::HashMap<FnId, Facts> =
495        std::collections::HashMap::with_capacity(resolved_fns.len());
496    for fd in resolved_fns {
497        facts_by_id.insert(fd.fn_id, extract_facts(fd));
498    }
499    let facts_refs: std::collections::HashMap<FnId, &Facts> =
500        facts_by_id.iter().map(|(k, v)| (*k, v)).collect();
501    let sccs = compute_sccs(resolved_fns, &facts_refs);
502
503    let mut per_fn = std::collections::HashMap::with_capacity(resolved_fns.len());
504    for fd in resolved_fns {
505        let facts = &facts_by_id[&fd.fn_id];
506        let labels = classify(fd, facts, &sccs);
507        let primary = primary_label(&labels);
508        per_fn.insert(fd.fn_id, FnRecognition { primary, labels });
509    }
510
511    ProgramShape {
512        per_fn,
513        sccs,
514        patterns: Vec::new(),
515        inductable_sum_types: HashSet::new(),
516    }
517}
518
519/// Same as [`analyze_program`] but also detects module-level patterns
520/// (`ModulePattern::RefinementSmartConstructor`, …) by walking the
521/// source `items` and dep modules. Callers that have both the
522/// resolved-fn snapshot and the source items should prefer this.
523pub fn analyze_program_with_modules(
524    resolved_fns: &[&ResolvedFnDef],
525    entry_items: &[crate::ast::TopLevel],
526    dep_modules: &[crate::codegen::ModuleInfo],
527) -> ProgramShape {
528    let mut shape = analyze_program(resolved_fns);
529    shape.patterns = detect_module_patterns(entry_items, dep_modules);
530    shape.inductable_sum_types = collect_inductable_sum_types(entry_items, dep_modules);
531    shape
532}
533
534// ─── Module-level typed patterns (stage 6 of #232) ───────────────────────────
535
536/// A `ModulePattern` is a *recognized structural fact* about a whole
537/// module's surface — the level above per-fn archetypes — carrying the
538/// **typed payload** downstream consumers need to act on it.
539///
540/// The first variant is `RefinementSmartConstructor`, the canonical
541/// `refinement-via-opaque` shape (single-field record + validating
542/// smart constructor) the proof export already recognizes via
543/// [`crate::codegen::common::refinement_info_for`]. Stage 6 lifts the
544/// recognition into the analysis tier so other consumers (`aver shape`
545/// LSP, future inliner, monomorphizer) don't each re-walk the AST to
546/// ask the same question.
547///
548/// Peer-review note from issue #232: "kind == SmartConstructor is too
549/// compressed to be source of truth for proof routing — proof needs
550/// typed payload". This enum carries that payload (carrier field +
551/// type, constructor fn name, predicate expression).
552///
553/// Stage 6a (this commit) only **detects** the pattern; the proof
554/// export still walks via the legacy `refinement_info_for` API.
555/// Stage 6b refactors that fn into a thin adapter over
556/// `ProgramShape::patterns`. Stage 6c+ adds the next pattern
557/// (`WrapperOverRecursion`, `ResultPipelineChain`, …).
558#[derive(Debug, Clone)]
559pub enum ModulePattern {
560    /// `refinement-via-opaque` shape: a single-field
561    /// `record T { <carrier_field>: <carrier_type> }` paired with a
562    /// validating smart constructor
563    ///   `fn <constructor_fn>(<param_name>: <carrier_type>) -> Result<T, _>`
564    ///   `    match <predicate>`
565    ///   `        true  -> Result.Ok(T(<carrier_field> = <param_name>))`
566    ///   `        false -> Result.Err("...")`
567    RefinementSmartConstructor {
568        /// Where this pattern lives: `None` = entry items,
569        /// `Some(prefix)` = dep module with that prefix. Lets the
570        /// scope-aware adapter (`refinement_info_for_in_scope`) pick
571        /// the predicate from the right module when two modules
572        /// declare a refined record with the same bare name
573        /// (e.g. `A.Natural` vs `B.Natural`).
574        scope: Option<String>,
575        /// Source-level type name (`"Natural"`, `"Positive"`, …).
576        /// FnId / TypeId migration deferred — name keys match what
577        /// the current `refinement_info_for` adapter uses.
578        type_name: String,
579        /// Carrier-field name (e.g. `"value"`). Lean projects through
580        /// `.val` on a `Subtype`; this is the field that gets renamed
581        /// in the lifted view.
582        carrier_field: String,
583        /// Carrier type annotation as written in the record field
584        /// (`"Int"`, `"Float"`, …). Backends emit it as the subset's
585        /// underlying type.
586        carrier_type: String,
587        /// Source-level name of the smart constructor (`"fromInt"`).
588        constructor_fn: String,
589        /// Parameter name on the smart constructor signature
590        /// (`"n"` in `fromInt(n: Int) -> Result<Natural, _>`). Used
591        /// when substituting the law's quantified variable into the
592        /// predicate.
593        param_name: String,
594        /// Cloned bool predicate the smart constructor branches on —
595        /// the body's `match <predicate>` subject. Owned so
596        /// `ProgramShape` doesn't borrow source items.
597        predicate: crate::ast::Spanned<crate::ast::Expr>,
598    },
599    /// `wrapper-over-recursion` shape: a non-recursive `wrapper_fn` whose
600    /// body's only recursive call is to a self-recursive `inner_fn`
601    /// living in the same scope, with `inner_fn` taking the wrapper's
602    /// parameters as a prefix (literally, as `Ident` args) plus at
603    /// least one additional argument (typically an accumulator initial
604    /// value). `fib(n) -> fibTR(n, 0, 1)` is the canonical example;
605    /// `aver fmt` / proof export use this to route the wrapper through
606    /// the inner's induction certificate.
607    ///
608    /// Conservative detection rules (stage 6c):
609    /// - wrapper is itself non-recursive (no self-call)
610    /// - exactly one inner call to a self-recursive same-scope fn
611    /// - every wrapper parameter appears literally (`Ident`) somewhere
612    ///   in the inner's argument list
613    /// - inner's arity is strictly greater than the wrapper's arity
614    ///
615    /// These rules keep false positives near zero on the shipped
616    /// corpus; mutual recursion across fns isn't claimed yet
617    /// (`inner_fn` must self-recurse, not participate in a larger SCC).
618    WrapperOverRecursion {
619        /// Scope of the wrapper (`None` = entry, `Some(prefix)` = dep
620        /// module). `inner_scope` is always equal to `wrapper_scope`
621        /// in stage 6c — cross-module wrappers aren't claimed.
622        wrapper_scope: Option<String>,
623        /// Source-level wrapper fn name (the outer, non-recursive one).
624        wrapper_fn: String,
625        /// Scope of the recursive inner fn. Mirrors `wrapper_scope`
626        /// while stage 6c keeps this same-scope-only.
627        inner_scope: Option<String>,
628        /// Source-level inner fn name (the recursive one).
629        inner_fn: String,
630    },
631    /// `?`-propagating Result pipeline: a fn whose body is a sequence
632    /// of `let x = step()?` bindings followed by a tail expression
633    /// (typically `Result.Ok(final)`). Canonical example:
634    /// `examples/core/result_pipeline.av::validateAndCombine` — six
635    /// `?` steps that short-circuit on the first Err.
636    ///
637    /// Detection rules (stage 6d):
638    /// - fn return type starts with `Result<`
639    /// - body has at least two `Stmt::Binding` whose value is
640    ///   `Expr::ErrorProp(...)` (the `?` operator)
641    /// - the tail stmt is an expression, not a binding
642    ///
643    /// `step_count` is the number of `?` bindings; downstream
644    /// consumers can use it to size the staged result type or to
645    /// pick between inlined and trampoline lowerings. No proof-export
646    /// consumer yet — this is substrate-only.
647    ResultPipelineChain {
648        scope: Option<String>,
649        fn_name: String,
650        step_count: usize,
651        /// Source names of the step fns called via `?` in body
652        /// order. Captured here because the post-pipeline AST
653        /// desugars `?` into nested `match` arms — downstream
654        /// consumers that need the original step list (e.g. the
655        /// proof_lower `ResultPipelineChain` strategy) read from
656        /// this field instead of re-walking.
657        step_fns: Vec<String>,
658    },
659    /// Non-recursive pure renderer: a fn whose return type is `String`,
660    /// effects list is empty, and body contains an `InterpolatedStr`
661    /// or a `String`-typed `+` concatenation. Canonical examples are
662    /// `examples/data/rle.av::showRun` (single interpolation) and the
663    /// `show*` family in `examples/data/fibonacci.av`.
664    ///
665    /// Detection rules (stage 6e):
666    /// - return type is exactly `String`
667    /// - effects list is empty
668    /// - fn does not call itself anywhere in its body
669    /// - body contains at least one `Expr::InterpolatedStr` or
670    ///   `Expr::BinOp(Add, ..)` reachable through nesting
671    ///
672    /// Recursive structural renderers (`showRuns`, `showListIntInner`)
673    /// are intentionally excluded — they belong to a future
674    /// `StructuralRenderer` pattern paired with structural induction.
675    RendererFormatter {
676        scope: Option<String>,
677        fn_name: String,
678    },
679    /// Self-recursive structural fold over a `List<T>` parameter:
680    /// fn body is a single `match <param>` with at minimum
681    /// `[] -> ...` and `[head, ..tail] -> ...` arms, and the fn
682    /// calls itself somewhere in its body (typically passing
683    /// `tail` to recur). `nthOrZero(xs, index)` from
684    /// `examples/data/fibonacci.av` is the canonical example.
685    ///
686    /// Detection rules (stage 6f):
687    /// - body is a single `Stmt::Expr(Match)`
688    /// - subject is `Ident(p)` where `p` is one of the fn's params
689    /// - arms include both `Pattern::EmptyList` and `Pattern::Cons`
690    /// - fn is self-recursive
691    ///
692    /// Aver's stdlib has no `List.map/fold`, so this hand-rolled
693    /// structural fold shows up across the corpus. Recognizing it
694    /// unlocks two future moves: list-induction proof obligation
695    /// emission, and a deforestation rewrite that fuses the fold
696    /// with its consumer.
697    MatchDispatcherFold {
698        scope: Option<String>,
699        fn_name: String,
700        list_param: String,
701    },
702    /// `accumulator-fold` shape: the role-bearing refinement of
703    /// `WrapperOverRecursion` for a tail-recursive LIST fold. A
704    /// non-recursive `wrapper(xs) = loop(xs, neutral)` whose `loop` is
705    /// `match list { [] -> finish(acc); h::t -> loop(t, step) }`, where
706    /// `step` is either a named fold fn `step_fn(acc, h)` or an inline
707    /// binop `acc <step_op> h`, and the nil arm is either `finish_fn(acc)`
708    /// or `acc` itself (`finish_fn = None`, an identity finish).
709    ///
710    /// This is the single shape both the codec-roundtrip and the monoidal
711    /// spec-equivalence proofs key on — the accumulator-generalization
712    /// schema. Carrying the roles here lets BOTH the `--discover` lemma
713    /// chains (`codegen::lemma_discovery`) and the normal-path
714    /// `ProofStrategy` (`codegen::proof_lower::detect_wrapper_over_recursion`)
715    /// read them from one recognizer instead of each re-walking the loop.
716    /// `fib(n) = fibTR(n, 0, 1)` is a `WrapperOverRecursion` but NOT an
717    /// `AccumulatorFold` (its inner recurs on an `Int`, not a `match list`),
718    /// so the two patterns are distinct.
719    AccumulatorFold {
720        scope: Option<String>,
721        wrapper_fn: String,
722        loop_fn: String,
723        list_param: String,
724        acc_param: String,
725        /// Named fold step `step_fn(acc, h)`, or `None` when the step is an
726        /// inline binop (then `step_op` is set).
727        step_fn: Option<String>,
728        /// Inline additive fold step `acc <op> h`, or `None` when the step
729        /// is a named fn (then `step_fn` is set).
730        step_op: Option<crate::ast::BinOp>,
731        /// Nil-arm finishing fn `finish_fn(acc)`, or `None` for an identity
732        /// finish (the nil arm returns `acc` unchanged).
733        finish_fn: Option<String>,
734        /// `None` for the original `List<_>` structural fold. `Some(ty)` for a
735        /// Peano-`Nat` countdown fold (`match n { Z -> acc; S(m) -> loop(m,
736        /// step) }`) over the ADT named `ty` — `factTR`'s shape, where the
737        /// step multiplies/adds the matched subject value rather than a list
738        /// head. The two share the role-extraction but need different backend
739        /// induction skeletons.
740        driver_type: Option<String>,
741        /// For a Peano-`Nat` fold whose step is `combine(subject, acc)`, `true`
742        /// when the folded subject is the combine fn's FIRST argument
743        /// (`mul(n, acc)`), `false` for `mul(acc, n)`. Ignored for `List`.
744        step_value_first: bool,
745    },
746}
747
748/// Walk entry items + dep modules and collect the names of sum types
749/// that pass the proof-export induction eligibility check: directly
750/// self-referential in at least one variant, and not indirectly
751/// recursive through nested generics that the per-variant emit
752/// would have to give up on. Mirrors the inline scan that
753/// `proof_lower::detect_induction_target` used to perform so the
754/// detector can read from `ProgramShape.inductable_sum_types` and
755/// avoid re-walking the AST.
756pub fn collect_inductable_sum_types(
757    entry_items: &[crate::ast::TopLevel],
758    dep_modules: &[crate::codegen::ModuleInfo],
759) -> HashSet<String> {
760    use crate::ast::{TopLevel, TypeDef};
761    let mut out = HashSet::new();
762    let mut consider = |td: &TypeDef| {
763        if let TypeDef::Sum { name, variants, .. } = td
764            && crate::codegen::common::is_recursive_sum(name, variants)
765            && !indirect_rec_variants(variants, name)
766        {
767            out.insert(name.clone());
768        }
769    };
770    for item in entry_items {
771        if let TopLevel::TypeDef(td) = item {
772            consider(td);
773        }
774    }
775    for m in dep_modules {
776        for td in &m.type_defs {
777            consider(td);
778        }
779    }
780    out
781}
782
783/// Mirror of `proof_lower::has_indirect_rec_variants`: a variant
784/// field that contains `type_name` nested past one `<` is rejected
785/// because the per-variant induction case-split can't decompose it.
786fn indirect_rec_variants(variants: &[crate::ast::TypeVariant], type_name: &str) -> bool {
787    for variant in variants {
788        for field in &variant.fields {
789            let f = field.trim();
790            if f == type_name {
791                continue;
792            }
793            let opens = f.matches('<').count();
794            if opens > 1 && f.contains(type_name) {
795                return true;
796            }
797        }
798    }
799    false
800}
801
802/// Walk entry items + dep modules and emit every typed
803/// `ModulePattern` we can recognize. Used by `analyze_program_with_modules`
804/// to populate `ProgramShape.patterns`.
805///
806/// Mirrors the recognition rules in
807/// `codegen::common::refinement_info_for_walk` so downstream consumers
808/// see the same set of refinement records; Stage 6b will retire the
809/// legacy fn and route through this output.
810pub fn detect_module_patterns(
811    entry_items: &[crate::ast::TopLevel],
812    dep_modules: &[crate::codegen::ModuleInfo],
813) -> Vec<ModulePattern> {
814    use crate::ast::{Expr, Stmt, TopLevel, TypeDef};
815
816    let mut out = Vec::new();
817
818    // Per-scope candidate records. `scope = None` = entry items,
819    // `scope = Some(prefix)` = dep module. The smart-constructor
820    // walk searches inside the same scope as the record — that's the
821    // `refinement-via-opaque` invariant (constructor lives next to
822    // the carrier field, since `exposes opaque` hides the field from
823    // other modules).
824    struct CandidateRecord<'a> {
825        scope: Option<String>,
826        type_name: &'a str,
827        carrier_field: &'a str,
828        carrier_type: &'a str,
829        fns: Vec<&'a crate::ast::FnDef>,
830    }
831
832    let entry_fns: Vec<&crate::ast::FnDef> = entry_items
833        .iter()
834        .filter_map(|i| match i {
835            TopLevel::FnDef(fd) => Some(fd),
836            _ => None,
837        })
838        .collect();
839
840    let mut candidates: Vec<CandidateRecord<'_>> = Vec::new();
841    for td in entry_items.iter().filter_map(|i| match i {
842        TopLevel::TypeDef(td) => Some(td),
843        _ => None,
844    }) {
845        if let TypeDef::Product { name, fields, .. } = td
846            && fields.len() == 1
847        {
848            let (fname, ftype) = &fields[0];
849            candidates.push(CandidateRecord {
850                scope: None,
851                type_name: name.as_str(),
852                carrier_field: fname.as_str(),
853                carrier_type: ftype.as_str(),
854                fns: entry_fns.clone(),
855            });
856        }
857    }
858    for m in dep_modules {
859        let module_fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
860        for td in &m.type_defs {
861            if let TypeDef::Product { name, fields, .. } = td
862                && fields.len() == 1
863            {
864                let (fname, ftype) = &fields[0];
865                candidates.push(CandidateRecord {
866                    scope: Some(m.prefix.clone()),
867                    type_name: name.as_str(),
868                    carrier_field: fname.as_str(),
869                    carrier_type: ftype.as_str(),
870                    fns: module_fns.clone(),
871                });
872            }
873        }
874    }
875
876    // Walk fns looking for the smart-constructor shape per candidate
877    // record. Mirrors `refinement_info_for_walk`: return type
878    // `Result<TypeName, _>`, exactly one param, body is a single
879    // `match <pred>` with two arms, bool-shaped `true -> Ok` /
880    // `false -> Err` referencing the carrier field + param. The fn
881    // must live in the same scope as the carrier record.
882    for candidate in &candidates {
883        let CandidateRecord {
884            scope,
885            type_name,
886            carrier_field,
887            carrier_type,
888            fns,
889        } = candidate;
890        for fd in fns {
891            if !fd.return_type.starts_with("Result<") {
892                continue;
893            }
894            if !fd.return_type[7..].starts_with(*type_name) {
895                continue;
896            }
897            if fd.params.len() != 1 {
898                continue;
899            }
900            let (param_name, _) = &fd.params[0];
901            let stmts = fd.body.stmts();
902            if stmts.len() != 1 {
903                continue;
904            }
905            let Stmt::Expr(body_expr) = &stmts[0] else {
906                continue;
907            };
908            let Expr::Match { subject, arms } = &body_expr.node else {
909                continue;
910            };
911            if !crate::codegen::common::is_refinement_bool_ok_err_match(
912                arms,
913                type_name,
914                carrier_field,
915                param_name,
916            ) {
917                continue;
918            }
919            out.push(ModulePattern::RefinementSmartConstructor {
920                scope: scope.clone(),
921                type_name: (*type_name).to_string(),
922                carrier_field: (*carrier_field).to_string(),
923                carrier_type: (*carrier_type).to_string(),
924                constructor_fn: fd.name.clone(),
925                param_name: param_name.clone(),
926                predicate: (**subject).clone(),
927            });
928            break;
929        }
930    }
931
932    // Stage 6c of #232: `WrapperOverRecursion` per-scope detection.
933    // Each scope is searched independently (entry items, then each
934    // dep module) — cross-module wrappers aren't claimed yet.
935    detect_wrapper_over_recursion(None, &entry_fns, &mut out);
936    for m in dep_modules {
937        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
938        detect_wrapper_over_recursion(Some(m.prefix.clone()), &fns, &mut out);
939    }
940
941    // The role-bearing refinement of the above (step/finish extracted), for
942    // the accumulator-generalization proofs that consume the loop's roles.
943    detect_accumulator_fold(None, &entry_fns, &mut out);
944    for m in dep_modules {
945        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
946        detect_accumulator_fold(Some(m.prefix.clone()), &fns, &mut out);
947    }
948
949    // Stage 6d of #232: `ResultPipelineChain` per-scope detection.
950    detect_result_pipeline_chain(None, &entry_fns, &mut out);
951    for m in dep_modules {
952        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
953        detect_result_pipeline_chain(Some(m.prefix.clone()), &fns, &mut out);
954    }
955
956    // Stage 6e of #232: `RendererFormatter` per-scope detection.
957    detect_renderer_formatter(None, &entry_fns, &mut out);
958    for m in dep_modules {
959        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
960        detect_renderer_formatter(Some(m.prefix.clone()), &fns, &mut out);
961    }
962
963    // Stage 6f of #232: `MatchDispatcherFold` per-scope detection.
964    detect_match_dispatcher_fold(None, &entry_fns, &mut out);
965    for m in dep_modules {
966        let fns: Vec<&crate::ast::FnDef> = m.fn_defs.iter().collect();
967        detect_match_dispatcher_fold(Some(m.prefix.clone()), &fns, &mut out);
968    }
969
970    out
971}
972
973/// Per-scope detector for [`ModulePattern::MatchDispatcherFold`]. See
974/// the variant docs for the detection contract.
975fn detect_match_dispatcher_fold(
976    scope: Option<String>,
977    fns: &[&crate::ast::FnDef],
978    out: &mut Vec<ModulePattern>,
979) {
980    use crate::ast::{Expr, Pattern, Stmt};
981    for fd in fns {
982        let stmts = fd.body.stmts();
983        if stmts.len() != 1 {
984            continue;
985        }
986        let Stmt::Expr(body_expr) = &stmts[0] else {
987            continue;
988        };
989        let Expr::Match { subject, arms } = &body_expr.node else {
990            continue;
991        };
992        // Pre-pipeline: subject is `Expr::Ident(name)`. Post-pipeline:
993        // the resolver rewrites local/param idents to
994        // `Expr::Resolved { name, .. }`. Both forms identify the
995        // matched parameter.
996        let subj_name = match &subject.node {
997            Expr::Ident(n) => n.as_str(),
998            Expr::Resolved { name, .. } => name.as_str(),
999            _ => continue,
1000        };
1001        if !fd.params.iter().any(|(n, _)| n == subj_name) {
1002            continue;
1003        }
1004        let has_nil = arms.iter().any(|a| matches!(a.pattern, Pattern::EmptyList));
1005        let has_cons = arms
1006            .iter()
1007            .any(|a| matches!(a.pattern, Pattern::Cons(_, _)));
1008        if !(has_nil && has_cons) {
1009            continue;
1010        }
1011        if !body_calls_name(&fd.body, &fd.name) {
1012            continue;
1013        }
1014        out.push(ModulePattern::MatchDispatcherFold {
1015            scope: scope.clone(),
1016            fn_name: fd.name.clone(),
1017            list_param: subj_name.to_string(),
1018        });
1019    }
1020}
1021
1022/// Per-scope detector for [`ModulePattern::RendererFormatter`]. See
1023/// the variant docs for the detection contract.
1024fn detect_renderer_formatter(
1025    scope: Option<String>,
1026    fns: &[&crate::ast::FnDef],
1027    out: &mut Vec<ModulePattern>,
1028) {
1029    for fd in fns {
1030        if fd.return_type != "String" {
1031            continue;
1032        }
1033        if !fd.effects.is_empty() {
1034            continue;
1035        }
1036        if body_calls_name(&fd.body, &fd.name) {
1037            continue;
1038        }
1039        if !body_has_string_building(&fd.body) {
1040            continue;
1041        }
1042        out.push(ModulePattern::RendererFormatter {
1043            scope: scope.clone(),
1044            fn_name: fd.name.clone(),
1045        });
1046    }
1047}
1048
1049/// Walk `body` looking for any `Expr::InterpolatedStr` or string-typed
1050/// `+` (`BinOp(Add, ..)`). Conservative: any addition counts since
1051/// the typechecker has already restricted what `+` can do at a
1052/// `String`-returning callsite — false positives here would be
1053/// numeric adds that never reach the return slot, which the
1054/// `return_type == "String"` guard already excludes for trivial
1055/// arithmetic-only bodies.
1056fn body_has_string_building(body: &crate::ast::FnBody) -> bool {
1057    for stmt in body.stmts() {
1058        let expr = match stmt {
1059            crate::ast::Stmt::Binding(_, _, e) => e,
1060            crate::ast::Stmt::Expr(e) => e,
1061        };
1062        if expr_has_string_building(expr) {
1063            return true;
1064        }
1065    }
1066    false
1067}
1068
1069fn expr_has_string_building(expr: &crate::ast::Spanned<crate::ast::Expr>) -> bool {
1070    use crate::ast::Expr;
1071    match &expr.node {
1072        Expr::InterpolatedStr(_) => true,
1073        Expr::BinOp(crate::ast::BinOp::Add, _, _) => true,
1074        Expr::FnCall(callee, args) => {
1075            expr_has_string_building(callee) || args.iter().any(expr_has_string_building)
1076        }
1077        Expr::TailCall(td) => td.args.iter().any(expr_has_string_building),
1078        Expr::Match { subject, arms } => {
1079            expr_has_string_building(subject)
1080                || arms.iter().any(|a| expr_has_string_building(&a.body))
1081        }
1082        Expr::BinOp(_, l, r) => expr_has_string_building(l) || expr_has_string_building(r),
1083        Expr::Neg(e) | Expr::Attr(e, _) | Expr::ErrorProp(e) => expr_has_string_building(e),
1084        Expr::Constructor(_, Some(e)) => expr_has_string_building(e),
1085        Expr::List(xs) | Expr::Tuple(xs) | Expr::IndependentProduct(xs, _) => {
1086            xs.iter().any(expr_has_string_building)
1087        }
1088        Expr::MapLiteral(pairs) => pairs
1089            .iter()
1090            .any(|(k, v)| expr_has_string_building(k) || expr_has_string_building(v)),
1091        Expr::RecordCreate { fields, .. } => {
1092            fields.iter().any(|(_, e)| expr_has_string_building(e))
1093        }
1094        Expr::RecordUpdate { base, updates, .. } => {
1095            expr_has_string_building(base)
1096                || updates.iter().any(|(_, e)| expr_has_string_building(e))
1097        }
1098        Expr::Literal(_) | Expr::Ident(_) | Expr::Constructor(_, None) | Expr::Resolved { .. } => {
1099            false
1100        }
1101    }
1102}
1103
1104/// Per-scope detector for [`ModulePattern::ResultPipelineChain`].
1105/// Counts `Stmt::Binding(_, _, ErrorProp(...))` (the `?` operator)
1106/// in each fn body; emits the pattern when there are ≥2 such
1107/// bindings, the fn returns `Result<…>`, and the tail stmt is an
1108/// expression (`Stmt::Expr`, not another binding).
1109fn detect_result_pipeline_chain(
1110    scope: Option<String>,
1111    fns: &[&crate::ast::FnDef],
1112    out: &mut Vec<ModulePattern>,
1113) {
1114    use crate::ast::{Expr, Stmt};
1115    for fd in fns {
1116        if !fd.return_type.starts_with("Result<") {
1117            continue;
1118        }
1119        let stmts = fd.body.stmts();
1120        if stmts.len() < 2 {
1121            continue;
1122        }
1123        if !matches!(stmts.last(), Some(Stmt::Expr(_))) {
1124            continue;
1125        }
1126        let mut step_fns: Vec<String> = Vec::new();
1127        for stmt in stmts {
1128            if let Stmt::Binding(_, _, value) = stmt
1129                && let Expr::ErrorProp(inner) = &value.node
1130                && let Expr::FnCall(callee, _) = &inner.node
1131                && let Expr::Ident(name) = &callee.node
1132            {
1133                step_fns.push(name.clone());
1134            }
1135        }
1136        if step_fns.len() < 2 {
1137            continue;
1138        }
1139        let step_count = step_fns.len();
1140        out.push(ModulePattern::ResultPipelineChain {
1141            scope: scope.clone(),
1142            fn_name: fd.name.clone(),
1143            step_count,
1144            step_fns,
1145        });
1146    }
1147}
1148
1149/// Per-scope detector for [`ModulePattern::WrapperOverRecursion`].
1150/// Builds the self-recursive set for `fns` (fns that call themselves
1151/// by name anywhere in their body), then walks each non-recursive fn's
1152/// body looking for exactly one qualifying inner call. See the variant
1153/// docs on `WrapperOverRecursion` for the detection contract.
1154fn detect_wrapper_over_recursion(
1155    scope: Option<String>,
1156    fns: &[&crate::ast::FnDef],
1157    out: &mut Vec<ModulePattern>,
1158) {
1159    if fns.is_empty() {
1160        return;
1161    }
1162
1163    let mut recursive: HashSet<String> = HashSet::new();
1164    for fd in fns {
1165        if body_calls_name(&fd.body, &fd.name) {
1166            recursive.insert(fd.name.clone());
1167        }
1168    }
1169    if recursive.is_empty() {
1170        return;
1171    }
1172
1173    for fd in fns {
1174        if recursive.contains(&fd.name) {
1175            continue;
1176        }
1177        if fd.params.is_empty() {
1178            continue;
1179        }
1180        let outer_params: Vec<&str> = fd.params.iter().map(|(n, _)| n.as_str()).collect();
1181        let mut hits: Vec<String> = Vec::new();
1182        collect_qualifying_inner_calls(&fd.body, &outer_params, &recursive, &mut hits);
1183        hits.sort();
1184        hits.dedup();
1185        if hits.len() != 1 {
1186            continue;
1187        }
1188        let inner = hits.into_iter().next().unwrap();
1189        out.push(ModulePattern::WrapperOverRecursion {
1190            wrapper_scope: scope.clone(),
1191            wrapper_fn: fd.name.clone(),
1192            inner_scope: scope.clone(),
1193            inner_fn: inner,
1194        });
1195    }
1196}
1197
1198/// The function name + args of `e`, whether a direct `FnCall(Ident, _)` or a
1199/// TCO-rewritten `TailCall` (the loop's self-call is tail position).
1200fn call_target(
1201    e: &crate::ast::Spanned<crate::ast::Expr>,
1202) -> Option<(&str, &[crate::ast::Spanned<crate::ast::Expr>])> {
1203    use crate::ast::Expr;
1204    match &e.node {
1205        Expr::FnCall(callee, args) => match &callee.node {
1206            Expr::Ident(n) => Some((n.as_str(), args.as_slice())),
1207            _ => None,
1208        },
1209        Expr::TailCall(td) => Some((td.target.as_str(), td.args.as_slice())),
1210        _ => None,
1211    }
1212}
1213
1214/// `acc.<field>`-free identifier name (`Ident` or resolved), if `e` is one.
1215fn plain_ident(e: &crate::ast::Spanned<crate::ast::Expr>) -> Option<&str> {
1216    match &e.node {
1217        crate::ast::Expr::Ident(n) => Some(n.as_str()),
1218        crate::ast::Expr::Resolved { name, .. } => Some(name.as_str()),
1219        _ => None,
1220    }
1221}
1222
1223/// `AccumulatorFold` detection: the role-bearing refinement of
1224/// `WrapperOverRecursion`. Finds the same wrapper→loop pairs, then inspects the
1225/// loop body for the `match list { [] -> finish(acc); h::t -> loop(t, step) }`
1226/// fold shape and extracts the step/finish roles. Loops that aren't list-folds
1227/// (e.g. `fibTR` recurring on an `Int`) yield no `AccumulatorFold`.
1228fn detect_accumulator_fold(
1229    scope: Option<String>,
1230    fns: &[&crate::ast::FnDef],
1231    out: &mut Vec<ModulePattern>,
1232) {
1233    use crate::ast::{Expr, Pattern, Stmt};
1234
1235    let mut recursive: HashSet<String> = HashSet::new();
1236    for fd in fns {
1237        if body_calls_name(&fd.body, &fd.name) {
1238            recursive.insert(fd.name.clone());
1239        }
1240    }
1241    if recursive.is_empty() {
1242        return;
1243    }
1244
1245    for fd in fns {
1246        if recursive.contains(&fd.name) || fd.params.is_empty() {
1247            continue;
1248        }
1249        let outer_params: Vec<&str> = fd.params.iter().map(|(n, _)| n.as_str()).collect();
1250        let mut hits: Vec<String> = Vec::new();
1251        collect_qualifying_inner_calls(&fd.body, &outer_params, &recursive, &mut hits);
1252        hits.sort();
1253        hits.dedup();
1254        if hits.len() != 1 {
1255            continue;
1256        }
1257        let loop_fn = hits.into_iter().next().unwrap();
1258
1259        // The loop fn must be a 2-param `(list, acc)` structural fold.
1260        let Some(lf) = fns.iter().find(|f| f.name == loop_fn) else {
1261            continue;
1262        };
1263        if lf.params.len() != 2 {
1264            continue;
1265        }
1266        let list_param = lf.params[0].0.clone();
1267        let acc_param = lf.params[1].0.clone();
1268        let Some(Stmt::Expr(body)) = lf.body.stmts().last() else {
1269            continue;
1270        };
1271        let Expr::Match { subject, arms } = &body.node else {
1272            continue;
1273        };
1274        if plain_ident(subject) != Some(list_param.as_str()) || arms.len() != 2 {
1275            continue;
1276        }
1277
1278        // A `List<_>` fold presents `[]`/`h::t` arms; a Peano-`Nat` countdown
1279        // (`factTR`) presents two `Constructor` arms (`Z`/`S(m)`). They share
1280        // the wrapper→loop discovery above but extract roles differently.
1281        let list_shaped = arms
1282            .iter()
1283            .any(|a| matches!(a.pattern, Pattern::EmptyList | Pattern::Cons(_, _)));
1284        if !list_shaped {
1285            // ── Peano-`Nat` countdown fold ──────────────────────────────
1286            // `match n { Z -> acc; S(m) -> loop(m, combine(n, acc)) }`, where
1287            // the step's folded value is the matched subject `n` itself (not a
1288            // list head). Base arm returns the accumulator (identity finish).
1289            let mut base_seen = false;
1290            let mut combine_fn: Option<String> = None;
1291            let mut value_first = false;
1292            let mut step_seen = false;
1293            let mut ok = true;
1294            for arm in arms {
1295                let Pattern::Constructor(_, binders) = &arm.pattern else {
1296                    ok = false;
1297                    continue;
1298                };
1299                match binders.len() {
1300                    0 => {
1301                        if plain_ident(&arm.body) == Some(acc_param.as_str()) {
1302                            base_seen = true;
1303                        } else {
1304                            ok = false;
1305                        }
1306                    }
1307                    1 => {
1308                        let bind = &binders[0];
1309                        let Some((callee, cargs)) = call_target(&arm.body) else {
1310                            ok = false;
1311                            continue;
1312                        };
1313                        if callee != loop_fn
1314                            || cargs.len() != 2
1315                            || plain_ident(&cargs[0]) != Some(bind.as_str())
1316                        {
1317                            ok = false;
1318                            continue;
1319                        }
1320                        // step = combine(subject, acc) | combine(acc, subject),
1321                        // a named 2-arg monoid fn over the matched param + acc.
1322                        let Expr::FnCall(sc, sargs) = &cargs[1].node else {
1323                            ok = false;
1324                            continue;
1325                        };
1326                        let Expr::Ident(sname) = &sc.node else {
1327                            ok = false;
1328                            continue;
1329                        };
1330                        if sargs.len() != 2 {
1331                            ok = false;
1332                            continue;
1333                        }
1334                        let a0 = plain_ident(&sargs[0]);
1335                        let a1 = plain_ident(&sargs[1]);
1336                        if a0 == Some(list_param.as_str()) && a1 == Some(acc_param.as_str()) {
1337                            value_first = true;
1338                        } else if a0 == Some(acc_param.as_str()) && a1 == Some(list_param.as_str())
1339                        {
1340                            value_first = false;
1341                        } else {
1342                            ok = false;
1343                            continue;
1344                        }
1345                        combine_fn = Some(sname.clone());
1346                        step_seen = true;
1347                    }
1348                    _ => ok = false,
1349                }
1350            }
1351            if base_seen && step_seen && ok && combine_fn.is_some() {
1352                out.push(ModulePattern::AccumulatorFold {
1353                    scope: scope.clone(),
1354                    wrapper_fn: fd.name.clone(),
1355                    loop_fn,
1356                    driver_type: Some(lf.params[0].1.clone()),
1357                    list_param,
1358                    acc_param,
1359                    step_fn: combine_fn,
1360                    step_op: None,
1361                    finish_fn: None,
1362                    step_value_first: value_first,
1363                });
1364            }
1365            continue;
1366        }
1367
1368        let mut finish_fn: Option<Option<String>> = None; // outer Option = "arm seen"
1369        let mut step_fn: Option<String> = None;
1370        let mut step_op: Option<crate::ast::BinOp> = None;
1371        let mut step_seen = false;
1372        let mut ok = true;
1373        for arm in arms {
1374            match &arm.pattern {
1375                Pattern::EmptyList => {
1376                    // nil -> finish_fn(acc)  |  nil -> acc
1377                    if let Some((name, fargs)) = call_target(&arm.body) {
1378                        if fargs.len() == 1 && plain_ident(&fargs[0]) == Some(acc_param.as_str()) {
1379                            finish_fn = Some(Some(name.to_string()));
1380                        } else {
1381                            ok = false;
1382                        }
1383                    } else if plain_ident(&arm.body) == Some(acc_param.as_str()) {
1384                        finish_fn = Some(None);
1385                    } else {
1386                        ok = false;
1387                    }
1388                }
1389                Pattern::Cons(h, t) => {
1390                    // cons -> loop(t, step), step = step_fn(acc, h) | acc <op> h
1391                    let Some((callee, cargs)) = call_target(&arm.body) else {
1392                        ok = false;
1393                        continue;
1394                    };
1395                    if callee != loop_fn
1396                        || cargs.len() != 2
1397                        || plain_ident(&cargs[0]) != Some(t.as_str())
1398                    {
1399                        ok = false;
1400                        continue;
1401                    }
1402                    match &cargs[1].node {
1403                        Expr::FnCall(sc, sargs) => {
1404                            if let Expr::Ident(sname) = &sc.node
1405                                && sargs.len() == 2
1406                                && plain_ident(&sargs[0]) == Some(acc_param.as_str())
1407                                && plain_ident(&sargs[1]) == Some(h.as_str())
1408                            {
1409                                step_fn = Some(sname.clone());
1410                                step_seen = true;
1411                            } else {
1412                                ok = false;
1413                            }
1414                        }
1415                        Expr::BinOp(op, l, r) => {
1416                            let ln = plain_ident(l);
1417                            let rn = plain_ident(r);
1418                            let acc_h = ln == Some(acc_param.as_str()) && rn == Some(h.as_str());
1419                            let h_acc = ln == Some(h.as_str()) && rn == Some(acc_param.as_str());
1420                            if acc_h || h_acc {
1421                                step_op = Some(*op);
1422                                step_seen = true;
1423                            } else {
1424                                ok = false;
1425                            }
1426                        }
1427                        _ => ok = false,
1428                    }
1429                }
1430                _ => ok = false,
1431            }
1432        }
1433
1434        let (Some(finish_fn), true, true) = (finish_fn, step_seen, ok) else {
1435            continue;
1436        };
1437        out.push(ModulePattern::AccumulatorFold {
1438            scope: scope.clone(),
1439            wrapper_fn: fd.name.clone(),
1440            loop_fn,
1441            list_param,
1442            acc_param,
1443            step_fn,
1444            step_op,
1445            finish_fn,
1446            driver_type: None,
1447            step_value_first: false,
1448        });
1449    }
1450}
1451
1452/// Whether `body` contains any `FnCall(Ident(name), _)` reachable
1453/// through expression nesting. Used to build the self-recursive set
1454/// and to find qualifying inner calls.
1455fn body_calls_name(body: &crate::ast::FnBody, name: &str) -> bool {
1456    for stmt in body.stmts() {
1457        let expr = match stmt {
1458            crate::ast::Stmt::Binding(_, _, e) => e,
1459            crate::ast::Stmt::Expr(e) => e,
1460        };
1461        if expr_calls_name(expr, name) {
1462            return true;
1463        }
1464    }
1465    false
1466}
1467
1468fn expr_calls_name(expr: &crate::ast::Spanned<crate::ast::Expr>, name: &str) -> bool {
1469    use crate::ast::Expr;
1470    match &expr.node {
1471        Expr::FnCall(callee, args) => {
1472            if let Expr::Ident(n) = &callee.node
1473                && n == name
1474            {
1475                return true;
1476            }
1477            if expr_calls_name(callee, name) {
1478                return true;
1479            }
1480            args.iter().any(|a| expr_calls_name(a, name))
1481        }
1482        Expr::TailCall(td) => td.target == name || td.args.iter().any(|a| expr_calls_name(a, name)),
1483        Expr::Match { subject, arms } => {
1484            if expr_calls_name(subject, name) {
1485                return true;
1486            }
1487            arms.iter().any(|a| expr_calls_name(&a.body, name))
1488        }
1489        Expr::BinOp(_, l, r) => expr_calls_name(l, name) || expr_calls_name(r, name),
1490        Expr::Neg(e) | Expr::Attr(e, _) | Expr::ErrorProp(e) => expr_calls_name(e, name),
1491        Expr::Constructor(_, Some(e)) => expr_calls_name(e, name),
1492        Expr::List(xs) | Expr::Tuple(xs) | Expr::IndependentProduct(xs, _) => {
1493            xs.iter().any(|x| expr_calls_name(x, name))
1494        }
1495        Expr::MapLiteral(pairs) => pairs
1496            .iter()
1497            .any(|(k, v)| expr_calls_name(k, name) || expr_calls_name(v, name)),
1498        Expr::RecordCreate { fields, .. } => fields.iter().any(|(_, e)| expr_calls_name(e, name)),
1499        Expr::RecordUpdate { base, updates, .. } => {
1500            expr_calls_name(base, name) || updates.iter().any(|(_, e)| expr_calls_name(e, name))
1501        }
1502        Expr::InterpolatedStr(parts) => parts.iter().any(|p| match p {
1503            crate::ast::StrPart::Parsed(e) => expr_calls_name(e, name),
1504            crate::ast::StrPart::Literal(_) => false,
1505        }),
1506        Expr::Literal(_) | Expr::Ident(_) | Expr::Constructor(_, None) | Expr::Resolved { .. } => {
1507            false
1508        }
1509    }
1510}
1511
1512/// Walk `body` and push every inner-fn name that satisfies the
1513/// `WrapperOverRecursion` qualification rules: callee is a same-scope
1514/// self-recursive fn in `recursive`, arity strictly greater than
1515/// `outer_params.len()`, and every outer-param name appears literally
1516/// as an `Ident` argument somewhere in the call's argument list.
1517fn collect_qualifying_inner_calls(
1518    body: &crate::ast::FnBody,
1519    outer_params: &[&str],
1520    recursive: &HashSet<String>,
1521    out: &mut Vec<String>,
1522) {
1523    for stmt in body.stmts() {
1524        let expr = match stmt {
1525            crate::ast::Stmt::Binding(_, _, e) => e,
1526            crate::ast::Stmt::Expr(e) => e,
1527        };
1528        collect_qualifying_in_expr(expr, outer_params, recursive, out);
1529    }
1530}
1531
1532fn collect_qualifying_in_expr(
1533    expr: &crate::ast::Spanned<crate::ast::Expr>,
1534    outer_params: &[&str],
1535    recursive: &HashSet<String>,
1536    out: &mut Vec<String>,
1537) {
1538    use crate::ast::Expr;
1539    let try_qualify = |callee: &str, args: &[crate::ast::Spanned<Expr>], out: &mut Vec<String>| {
1540        if !recursive.contains(callee) {
1541            return;
1542        }
1543        if args.len() <= outer_params.len() {
1544            return;
1545        }
1546        // Pre-pipeline args are `Expr::Ident(name)`; post-pipeline the
1547        // resolver rewrites local/param idents to
1548        // `Expr::Resolved { name, .. }`. Both shapes need to count.
1549        let mut arg_idents: HashSet<&str> = HashSet::new();
1550        for a in args {
1551            match &a.node {
1552                Expr::Ident(n) => {
1553                    arg_idents.insert(n.as_str());
1554                }
1555                Expr::Resolved { name, .. } => {
1556                    arg_idents.insert(name.as_str());
1557                }
1558                _ => {}
1559            }
1560        }
1561        if outer_params.iter().all(|p| arg_idents.contains(*p)) {
1562            out.push(callee.to_string());
1563        }
1564    };
1565    if let Expr::FnCall(callee, args) = &expr.node
1566        && let Expr::Ident(name) = &callee.node
1567    {
1568        try_qualify(name, args, out);
1569    }
1570    // Post-pipeline AST: tail-position calls become `TailCall`,
1571    // which loses the `FnCall(Ident, ...)` wrapper. The
1572    // `fib(n) -> fibTR(n, 0, 1)` shape is a typical case — pipeline
1573    // recognizes the tail call inside the `match` arm even though
1574    // `fib` itself isn't recursive.
1575    if let Expr::TailCall(td) = &expr.node {
1576        try_qualify(&td.target, &td.args, out);
1577    }
1578    match &expr.node {
1579        Expr::FnCall(callee, args) => {
1580            collect_qualifying_in_expr(callee, outer_params, recursive, out);
1581            for a in args {
1582                collect_qualifying_in_expr(a, outer_params, recursive, out);
1583            }
1584        }
1585        Expr::Match { subject, arms } => {
1586            collect_qualifying_in_expr(subject, outer_params, recursive, out);
1587            for a in arms {
1588                collect_qualifying_in_expr(&a.body, outer_params, recursive, out);
1589            }
1590        }
1591        Expr::BinOp(_, l, r) => {
1592            collect_qualifying_in_expr(l, outer_params, recursive, out);
1593            collect_qualifying_in_expr(r, outer_params, recursive, out);
1594        }
1595        Expr::Neg(e) | Expr::Attr(e, _) | Expr::ErrorProp(e) => {
1596            collect_qualifying_in_expr(e, outer_params, recursive, out);
1597        }
1598        Expr::Constructor(_, Some(e)) => {
1599            collect_qualifying_in_expr(e, outer_params, recursive, out);
1600        }
1601        Expr::List(xs) | Expr::Tuple(xs) | Expr::IndependentProduct(xs, _) => {
1602            for x in xs {
1603                collect_qualifying_in_expr(x, outer_params, recursive, out);
1604            }
1605        }
1606        Expr::MapLiteral(pairs) => {
1607            for (k, v) in pairs {
1608                collect_qualifying_in_expr(k, outer_params, recursive, out);
1609                collect_qualifying_in_expr(v, outer_params, recursive, out);
1610            }
1611        }
1612        Expr::RecordCreate { fields, .. } => {
1613            for (_, e) in fields {
1614                collect_qualifying_in_expr(e, outer_params, recursive, out);
1615            }
1616        }
1617        Expr::RecordUpdate { base, updates, .. } => {
1618            collect_qualifying_in_expr(base, outer_params, recursive, out);
1619            for (_, e) in updates {
1620                collect_qualifying_in_expr(e, outer_params, recursive, out);
1621            }
1622        }
1623        Expr::InterpolatedStr(parts) => {
1624            for p in parts {
1625                if let crate::ast::StrPart::Parsed(e) = p {
1626                    collect_qualifying_in_expr(e, outer_params, recursive, out);
1627                }
1628            }
1629        }
1630        Expr::TailCall(td) => {
1631            for a in &td.args {
1632                collect_qualifying_in_expr(a, outer_params, recursive, out);
1633            }
1634        }
1635        Expr::Literal(_) | Expr::Ident(_) | Expr::Constructor(_, None) | Expr::Resolved { .. } => {}
1636    }
1637}