Skip to main content

aver/codegen/proof_lower/
mod.rs

1//! Build `ProofIR` from a `CodegenContext`.
2//!
3//! The lowering producer: types live in `src/ir/proof_ir.rs`, this
4//! file fills them in from a typechecked + analysed codegen
5//! context. Output lands in `CodegenContext.proof_ir`; both proof
6//! backends read from the same field, so any classifier-side
7//! decision flows consistently to Lean and Dafny without each
8//! backend re-running shape detection.
9//!
10//! Populates three IR sections: `refined_types` (refinement-via-
11//! opaque records → Lean Subtype / Dafny subset type),
12//! `fn_contracts` (per-pure-fn recursion shape: native /
13//! sized-fuel / linear recurrence), and `law_theorems` (per-verify-
14//! law strategy + quantifier decomposition + claim shape, with
15//! Oracle-Lift'd impl-spec calls for effectful equivalence).
16//!
17//! `tests/proof_ir_diff.rs` pins the producer's output for each
18//! canonical source pattern — divergence between the classifier and
19//! the IR populator surfaces there.
20//!
21//! # Epic #170 Phase 7 invariant — AST discovery + typed identity
22//!
23//! This module is the **last consumer** of raw `crate::ast::Expr`
24//! patterns in the codegen layer. That is intentional, not
25//! migration debt.
26//!
27//! ## What's AST-shaped (syntax-discovery-only)
28//!
29//! Detector helpers in this file (`detect_*`, `walk_for_*`,
30//! `callee_matches_name`, `call_named_args`, `binary_call_var_const`,
31//! `matches_ident_expr`) walk `ast::Expr` directly. They are
32//! **pattern matchers** over source shape — they look for things
33//! like `match n { 0 -> base; _ -> rec(n - 1) }` or
34//! `Map.has(outer(m, k), k)` to decide which `ProofStrategy` /
35//! `RecursionPlan` variant lowers a given fn or law. The pattern
36//! belongs in source-shape; rewriting them on `ResolvedExpr` would
37//! be the same logic spelled in a different enum, no extra safety.
38//!
39//! Every detector helper carries a `syntax-discovery-only` comment
40//! at its definition.
41//!
42//! ## What's identity-sensitive (typed IDs)
43//!
44//! Decisions that depend on **which fn / type / ctor** a name
45//! refers to (not just "does this name appear") MUST go through
46//! `SymbolTable` or `ProofIR.refined_types` (`TypeId`-keyed) /
47//! `ProofIR.fn_contracts` (`FnId`-keyed). Examples:
48//!
49//! - Refinement-carrier lookups go through `find_refined_type` /
50//!   `resolve_refined_type_in_with_key`, both of which canonicalise
51//!   the name through the symbol table before reaching the IR map.
52//! - Fn-contract lookups go through `find_fn_contract_for_fn` —
53//!   pointer-eq scope on `&FnDef` resolves to the right `FnId`.
54//! - The Lean native-guarded rewriter pins target by `FnId` via
55//!   `rewrite_native_guarded_calls_resolved_expr` (PR 169).
56//!
57//! ## What stays raw-AST as a documented identity exception
58//!
59//! Builtin matchers (`callee_is X for X ∈ {"Bool.and", "Map.set",
60//! …}`) compare against the canonical builtin namespace, which is
61//! global by spec — no per-scope identity to leak. Verify-law
62//! callsites all walk `vb.fn_name` (entry-only by parser grammar);
63//! the `EntryFnIndex` newtype in `verify_law.rs` pins the
64//! entry-only contract at the type level (PR 177).
65//!
66//! Full `ResolvedProofLowerView` + semantic matcher API
67//! (`callee_is_builtin`, `callee_is_fn(FnId)`, `ctor_is`,
68//! `ident_name`, `int_lit`) deferred per
69//! `project_phase_e_scope_b_deferred` memory until a real trigger
70//! lands (module-scoped verify, dotted law targets, LSP rename,
71//! cross-scope inliner).
72
73use std::collections::{HashMap, HashSet};
74
75use crate::ast::{Expr, FnDef, Literal, Spanned, TopLevel, TypeDef};
76use crate::codegen::common::expr_to_dotted_name;
77use crate::codegen::recursion::RecursionPlan;
78use crate::codegen::{CodegenContext, ModuleInfo};
79use crate::ir::proof_ir::{
80    DecreaseProof, FnContract, Measure, NativeIntCountdownBody, Predicate, PreservationProof,
81    ProofIR, QuantifierType, RecursionContract, RefinedTypeDecl,
82};
83
84/// Backend-neutral view of the data `proof_lower` needs. Built once
85/// per lowering call; lets the pipeline pass it through without
86/// requiring a fully-assembled `CodegenContext` (which only exists
87/// after `build_context` runs). Legacy callers still build the view
88/// from `&CodegenContext` via [`ProofLowerInputs::from_ctx`].
89///
90/// All fields are borrows — the struct never owns memory; the pipeline
91/// and `build_context` both already own the data and just lend it.
92///
93/// Post-Step-7c: every helper the lowerer touches
94/// (`refinement_info_for`, `analyze_plans`, the `detect.rs` shape
95/// checkers) reads its inputs through this view. No more
96/// `&CodegenContext` reach-through — the struct stands on its own.
97pub struct ProofLowerInputs<'a> {
98    /// Entry-file top-level items, post-pipeline (TCO etc. applied).
99    pub entry_items: &'a [TopLevel],
100    /// Dependent modules already split into type/fn defs.
101    pub dep_modules: &'a [ModuleInfo],
102    /// Set of dep module prefix strings (e.g. `"Models.User"`).
103    pub module_prefixes: &'a HashSet<String>,
104    /// Recursive fn ids from the `analyze` pipeline stage. Keyed
105    /// by opaque [`crate::ir::FnId`] so entry+module same-bare-name
106    /// fns don't merge. Per-scope helpers below project back to
107    /// `HashSet<String>` for consumers that operate on a single
108    /// scope (the DAG invariant keeps bare-name unambiguous within
109    /// a scope).
110    pub recursive_fns: &'a HashSet<crate::ir::FnId>,
111    /// Resolved-identity table (#138 phase E). When `Some`, the
112    /// populate-side resolves `FnKey` / `TypeKey` to `FnId` /
113    /// `TypeId` once at the IR boundary and keys `ProofIR.fn_contracts`
114    /// / `ProofIR.refined_types` / `LawTheorem.fn_id` by the opaque
115    /// IDs. Callers that haven't wired in the symbol-table stage
116    /// pass `None` and fall through to legacy key-typed maps
117    /// (transitional during phase E migration).
118    pub symbol_table: &'a crate::ir::SymbolTable,
119    /// Optional `ProgramShape` substrate (Stage 6b of #232). When
120    /// `Some`, `refinement_info_for` reads from the typed
121    /// `ModulePattern::RefinementSmartConstructor` entries instead of
122    /// re-walking the AST. `None` keeps the legacy walk path —
123    /// preserved for test fixtures that build `ProofLowerInputs` by
124    /// hand without going through the pipeline.
125    pub program_shape: Option<&'a crate::analysis::shape::ProgramShape>,
126}
127
128impl<'a> ProofLowerInputs<'a> {
129    /// Build a view from a fully-assembled `CodegenContext` — used
130    /// by `refresh_facts` (test helper) and by any caller that
131    /// already owns a built context. Reads only the fields the
132    /// lowerer actually needs.
133    pub fn from_ctx(ctx: &'a CodegenContext) -> Self {
134        Self {
135            entry_items: &ctx.items,
136            dep_modules: &ctx.modules,
137            module_prefixes: &ctx.module_prefixes,
138            recursive_fns: &ctx.recursive_fns,
139            symbol_table: &ctx.symbol_table,
140            program_shape: ctx.program_shape.as_ref(),
141        }
142    }
143
144    /// All pure fn defs across entry items and dep modules, in walk
145    /// order (entry first, then deps). `is_pure_fn` lives in the
146    /// Lean toplevel module today; pure_fns reaches there since the
147    /// pure-ness criterion is the same for every proof backend.
148    pub fn pure_fns(&self) -> Vec<&'a FnDef> {
149        // Order matches the legacy `lean::pure_fns(ctx)`: deps first,
150        // entry last. `call_graph::ordered_fn_components` is order-
151        // sensitive (SCC discovery order changes which member is
152        // chosen as the representative); flipping the order shifted
153        // some classifications between fuel and "outside subset".
154        self.dep_modules
155            .iter()
156            .flat_map(|m| m.fn_defs.iter())
157            .chain(self.entry_items.iter().filter_map(|item| match item {
158                TopLevel::FnDef(fd) => Some(fd),
159                _ => None,
160            }))
161            .filter(|fd| crate::codegen::common::is_pure_fn(fd))
162            .collect()
163    }
164
165    /// Recursive pure fn names. Filters `recursive_fns` by pure-ness.
166    /// Returns bare names (pure_fns view is the whole program here,
167    /// so any FnId in `recursive_fns` that maps back to a pure fn
168    /// gets its bare name surfaced for downstream classifiers).
169    pub fn recursive_pure_fn_names(&self) -> HashSet<String> {
170        let symbols = self.symbol_table;
171        let pure_ids: HashSet<crate::ir::FnId> = self
172            .pure_fns()
173            .into_iter()
174            .filter_map(|fd| {
175                let scope = self
176                    .dep_modules
177                    .iter()
178                    .find(|m| m.fn_defs.iter().any(|d| std::ptr::eq(d, fd)))
179                    .map(|m| m.prefix.as_str());
180                // **syntax-discovery-only** (epic #170 Phase 8
181                // guardrail): scope was just resolved via pointer-eq
182                // against dep modules — the `None` arm is the
183                // correct entry-scope key by construction (same
184                // shape as `fn_key_for_decl` in `codegen::common`).
185                let key = match scope {
186                    Some(prefix) => crate::ir::FnKey::in_module(prefix.to_string(), &fd.name),
187                    None => crate::ir::FnKey::entry(&fd.name),
188                };
189                symbols.fn_id_of(&key)
190            })
191            .collect();
192        self.recursive_fns
193            .intersection(&pure_ids)
194            .map(|id| symbols.fn_entry(*id).key.name.clone())
195            .collect()
196    }
197
198    /// Pure fns restricted to a single scope: `None` = entry only,
199    /// `Some(prefix)` = the dep module with that prefix only. Aver's
200    /// module DAG invariant rules out cross-module recursion SCCs,
201    /// so per-scope classification is the canonical view —
202    /// `populate_fn_contracts` walks this per scope to give each
203    /// `Module.fn` its own canonical key in `ir.fn_contracts`
204    /// instead of letting two same-bare-name fns silently merge.
205    pub fn pure_fns_in_scope(&self, scope: Option<&str>) -> Vec<&'a FnDef> {
206        match scope {
207            None => self
208                .entry_items
209                .iter()
210                .filter_map(|item| match item {
211                    TopLevel::FnDef(fd) => Some(fd),
212                    _ => None,
213                })
214                .filter(|fd| crate::codegen::common::is_pure_fn(fd))
215                .collect(),
216            Some(prefix) => self
217                .dep_modules
218                .iter()
219                .filter(|m| m.prefix == prefix)
220                .flat_map(|m| m.fn_defs.iter())
221                .filter(|fd| crate::codegen::common::is_pure_fn(fd))
222                .collect(),
223        }
224    }
225
226    /// Recursive pure fn names restricted to a single scope. Filters
227    /// the FnId-keyed `recursive_fns` to the ones whose canonical
228    /// scope matches `scope`, then projects back to bare names for
229    /// scope-local consumers (DAG invariant keeps bare-name
230    /// unambiguous within a single scope).
231    pub fn recursive_pure_fn_names_in_scope(&self, scope: Option<&str>) -> HashSet<String> {
232        let symbols = self.symbol_table;
233        let pure_ids: HashSet<crate::ir::FnId> = self
234            .pure_fns_in_scope(scope)
235            .into_iter()
236            .filter_map(|fd| {
237                // **syntax-discovery-only** (epic #170 Phase 8
238                // guardrail): scope is the caller's stated scope —
239                // `None` = entry, `Some(prefix)` = dep module. Both
240                // arms below are the correct key for the matching
241                // arm; bare-name keying is safe because the caller
242                // has already narrowed to a single scope.
243                let key = match scope {
244                    Some(prefix) => crate::ir::FnKey::in_module(prefix.to_string(), &fd.name),
245                    None => crate::ir::FnKey::entry(&fd.name),
246                };
247                symbols.fn_id_of(&key)
248            })
249            .collect();
250        self.recursive_fns
251            .intersection(&pure_ids)
252            .map(|id| symbols.fn_entry(*id).key.name.clone())
253            .collect()
254    }
255
256    /// Iterator over (`None` = entry, `Some(prefix)` = each dep
257    /// module) — drives `populate_fn_contracts`'s per-scope walk.
258    pub fn scopes(&self) -> Vec<Option<String>> {
259        let mut out = vec![None];
260        for m in self.dep_modules {
261            out.push(Some(m.prefix.clone()));
262        }
263        out
264    }
265
266    /// Scope of the dep module that owns `fd`, or `None` for entry
267    /// module fns. Pointer-eq match against `dep_modules`, mirroring
268    /// `crate::codegen::common::fn_owning_scope_for` but reading off
269    /// the lowering view (which doesn't carry a full `CodegenContext`).
270    pub fn fn_owning_scope(&self, fd: &FnDef) -> Option<&'a str> {
271        for m in self.dep_modules {
272            for f in &m.fn_defs {
273                if std::ptr::eq(f, fd) {
274                    return Some(m.prefix.as_str());
275                }
276            }
277        }
278        None
279    }
280
281    /// Resolve a raw-AST expression to its `ResolvedExpr` form under
282    /// the given scope. ProofIR stores resolved expressions (Phase E
283    /// PR 12 Scope A), so this helper is called at every producer
284    /// site that lifts a `Spanned<crate::ast::Expr>` slice from the
285    /// source into an IR field. Mirrors
286    /// `CodegenContext::resolve_expr` but reads only the
287    /// `symbol_table` carried on this view — proof lowering runs
288    /// inside the pipeline, before a full `CodegenContext` exists.
289    pub fn resolve_expr(
290        &self,
291        expr: &crate::ast::Spanned<crate::ast::Expr>,
292        scope: Option<&str>,
293    ) -> crate::ast::Spanned<crate::ir::hir::ResolvedExpr> {
294        use crate::ir::hir::{ResolveCtx, ResolvedStmt};
295        let mut rctx = ResolveCtx::new(self.symbol_table);
296        rctx.current_module = scope.map(String::from);
297        let stmt = crate::ast::Stmt::Expr(expr.clone());
298        match crate::ir::hir::resolve::resolve_stmt_external(&rctx, &stmt) {
299            ResolvedStmt::Expr(s) => s,
300            ResolvedStmt::Binding { value, .. } => value,
301        }
302    }
303
304    /// Names of every recursive user-defined type across entry + deps.
305    pub fn recursive_type_names(&self) -> HashSet<String> {
306        self.entry_items
307            .iter()
308            .filter_map(|item| match item {
309                TopLevel::TypeDef(td) => Some(td),
310                _ => None,
311            })
312            .chain(self.dep_modules.iter().flat_map(|m| m.type_defs.iter()))
313            .filter(|td| crate::codegen::common::is_recursive_type_def(td))
314            .map(|td| crate::codegen::common::type_def_name(td).to_string())
315            .collect()
316    }
317
318    /// Find a fn def by name across entry + deps. Falls back to the
319    /// last segment of a dotted call (e.g. `Module.fn` resolves to
320    /// `fn` when no exact-match candidate exists).
321    pub fn find_fn_def_by_call_name(&self, call_name: &str) -> Option<&'a FnDef> {
322        let find_exact = |name: &str| -> Option<&'a FnDef> {
323            self.dep_modules
324                .iter()
325                .flat_map(|m| m.fn_defs.iter())
326                .chain(self.entry_items.iter().filter_map(|item| match item {
327                    TopLevel::FnDef(fd) => Some(fd),
328                    _ => None,
329                }))
330                .find(|fd| fd.name == name)
331        };
332        find_exact(call_name).or_else(|| {
333            let short = call_name.rsplit('.').next()?;
334            find_exact(short)
335        })
336    }
337
338    /// Find a type def by bare name across entry + deps. None on miss
339    /// or when the name resolves to a non-Product / non-Sum shape.
340    pub fn find_type_def(&self, type_name: &str) -> Option<&'a TypeDef> {
341        self.entry_items
342            .iter()
343            .filter_map(|item| match item {
344                TopLevel::TypeDef(td) => Some(td),
345                _ => None,
346            })
347            .chain(self.dep_modules.iter().flat_map(|m| m.type_defs.iter()))
348            .find(|td| crate::codegen::common::type_def_name(td) == type_name)
349    }
350}
351
352/// Run every proof-export lowering in one shot — convenience for
353/// callers that want a fully-populated ProofIR. The pipeline calls
354/// the three `populate_*` fns directly so it can run them as
355/// independent stages and short-circuit on typecheck failure.
356pub fn lower(inputs: &ProofLowerInputs) -> ProofIR {
357    let mut ir = ProofIR::default();
358    populate_refined_types(inputs, &mut ir);
359    populate_fn_contracts(inputs, &mut ir);
360    populate_law_theorems(inputs, &mut ir);
361    ir
362}
363
364/// Refinement-via-opaque lift. Walks every type definition (entry +
365/// dep modules), classifies the records that pair a single carrier
366/// field with a validating smart constructor, and emits
367/// `RefinedTypeDecl` entries into `ir.refined_types`. Backends
368/// (Lean → Subtype, Dafny → subset type) render these directly.
369pub fn populate_refined_types(inputs: &ProofLowerInputs, ir: &mut ProofIR) {
370    // Walk entry items first, then dep modules. The map is keyed by
371    // opaque `TypeId` resolved through the symbol table — same
372    // collision-safe shape as `fn_contracts: HashMap<FnId, _>`. The
373    // typechecker explicitly permits two modules to expose distinct
374    // types of the same bare name (`A.Shape` vs `B.Shape`; see
375    // `tests/typechecker_spec::cross_module_same_named_types_do_not_
376    // merge`); opaque IDs make their predicates impossible to merge
377    // by construction. Producer resolves `TypeKey -> TypeId` once
378    // here; consumers (`find_refined_type_scoped`) resolve through
379    // the same symbol table at lookup time.
380    //
381    // SymbolTable is always present (`ProofLowerInputs.symbol_table`
382    // is `&SymbolTable`, not `Option<&_>` — the pipeline builds it
383    // unconditionally). Synthetic-ctx callers (test helpers) thread
384    // their own through `from_ctx` / direct construction.
385    let symbols = inputs.symbol_table;
386
387    let entry_typedefs = inputs.entry_items.iter().filter_map(|item| match item {
388        TopLevel::TypeDef(td) => Some((None::<&str>, td)),
389        _ => None,
390    });
391    let module_typedefs = inputs.dep_modules.iter().flat_map(|m| {
392        m.type_defs
393            .iter()
394            .map(move |td| (Some(m.prefix.as_str()), td))
395    });
396
397    for (module_prefix, td) in entry_typedefs.chain(module_typedefs) {
398        let TypeDef::Product { name, fields, .. } = td else {
399            continue;
400        };
401        if fields.len() != 1 {
402            continue;
403        }
404        let type_key = match module_prefix {
405            Some(prefix) => crate::ir::TypeKey::in_module(prefix.to_string(), name),
406            None => crate::ir::TypeKey::entry(name),
407        };
408        let Some(canonical_key) = symbols.type_id_of(&type_key) else {
409            // Type isn't in the symbol table — built-ins (Result.Ok
410            // etc.) are excluded by construction; for user types
411            // this is a wiring bug surfaced via the symbol-table
412            // builder, so just skip.
413            continue;
414        };
415        if ir.refined_types.contains_key(&canonical_key) {
416            // Same TypeId already populated — possible if a module
417            // is walked twice through dep aliasing. Skip so we don't
418            // overwrite a verified-witness entry with a predicate-
419            // eval fallback witness.
420            continue;
421        }
422        // Scope the smart-constructor lookup to the same module the
423        // record lives in. Refinement-via-opaque keeps the record
424        // opaque (`exposes opaque [X]`); a smart constructor in any
425        // other module couldn't reach the carrier field anyway.
426        // Without the scope, two modules each declaring a `Natural`
427        // with different predicates would both pick up whichever
428        // smart constructor walked first.
429        let Some(info) =
430            crate::codegen::common::refinement_info_for_in_scope(name, inputs, module_prefix)
431        else {
432            continue;
433        };
434        let invariant = Predicate {
435            free_vars: vec![(
436                info.param_name.to_string(),
437                crate::ir::proof_ir::QuantifierType::Plain(info.carrier_type.to_string()),
438            )],
439            expr: inputs.resolve_expr(info.predicate, module_prefix),
440        };
441        let witness = pick_witness(
442            name,
443            canonical_key,
444            inputs,
445            info.predicate,
446            info.param_name,
447            module_prefix,
448        );
449        // Round-4 finding 1: a `None` witness means we couldn't
450        // exhibit any inhabitant satisfying the predicate. Inserting
451        // the slot anyway makes Dafny silently fall back to
452        // `witness 0` even when the predicate excludes 0 — producing
453        // an unsound subset type. Skip the lift entirely: the
454        // backend will emit a plain `datatype` instead, which is
455        // honest about the missing invariant. The pure-fn / law
456        // paths still typecheck against the plain record.
457        let Some(witness) = witness else {
458            continue;
459        };
460        ir.refined_types.insert(
461            canonical_key,
462            RefinedTypeDecl {
463                name: name.clone(),
464                carrier_type: info.carrier_type.to_string(),
465                carrier_field: info.carrier_field.to_string(),
466                predicate_param: info.param_name.to_string(),
467                invariant,
468                witness: Some(witness),
469                // Filled in immediately below by `populate_refined_type_intervals`,
470                // which runs the interval analysis once over the just-built
471                // `refined_types` map. Left empty here so the two passes share
472                // a single source of truth (`interval::analyze`) instead of
473                // each construction site re-deriving the bound.
474                interval: None,
475                op_classes: Vec::new(),
476            },
477        );
478    }
479
480    // Back-fill each decl's derived interval + per-op classification by
481    // running the existing per-module interval analysis over the map we
482    // just built. Reuses `interval::analyze` verbatim (no forked logic),
483    // so the persisted fact on every `RefinedTypeDecl` is byte-identical
484    // to what `aver compile --explain-passes` reports for the same type.
485    // This makes the bound a queryable fact on the standard refinement-
486    // lower path — the home a future carrier-lowering codegen recognizer
487    // reads via `ctx.proof_ir.refined_types` (TypeId-keyed) without
488    // re-running the analysis behind the diagnostic flag.
489    populate_refined_type_intervals(inputs, ir);
490}
491
492/// Attach the interval analysis result to each `RefinedTypeDecl` in
493/// `ir.refined_types`. Called once at the tail of
494/// [`populate_refined_types`]; the analysis is keyed by the same opaque
495/// `TypeId` the decl map uses, so the join is a direct id lookup.
496fn populate_refined_type_intervals(inputs: &ProofLowerInputs, ir: &mut ProofIR) {
497    let analysis = crate::ir::interval::analyze(&ir.refined_types, inputs);
498    for (type_id, decl) in ir.refined_types.iter_mut() {
499        let Some(per_type) = analysis.types.get(type_id) else {
500            continue;
501        };
502        // `interval_known` distinguishes a recognized bound from the
503        // conservative `Interval::unbounded()` decline; persist `None`
504        // for the decline so consumers don't mistake `[-inf, +inf]` for
505        // a real enclosure.
506        decl.interval = per_type.interval_known.then_some(per_type.interval);
507        decl.op_classes = per_type.ops.clone();
508    }
509}
510
511/// ETAP-2 SLICE 0+1: derive, per refinement-via-opaque type in scope, the
512/// constant interval its smart-constructor invariant proves over the
513/// carrier. The table is keyed by the opaque type's *bare* Aver name (e.g.
514/// `"IntRange"`). The bare-`i64` MIR pass matches a carrier
515/// parameter/local/return slot against this key — it extracts the bare name
516/// from the slot's `MirParam.ty` (which the lowerer fills with the Debug
517/// form `Named { id: …, name: "IntRange" }`, NOT the bare name; see
518/// `bare_named_type` in `bare_i64`) and seeds the slot with the proven bound.
519///
520/// The value is `interval_of_invariant(&predicate)` — byte-identical to the
521/// bound [`populate_refined_type_intervals`] persists on each
522/// `RefinedTypeDecl` (both build the same [`Predicate`] from
523/// [`refinement_info_for_in_scope`] + [`ProofLowerInputs::resolve_expr`] and
524/// run the same `interval` recognizer). No forked logic.
525///
526/// **Fail-closed.** A type whose invariant the recognizer does not
527/// understand returns `interval_known == false`; that entry is OMITTED from
528/// the table entirely, so the MIR pass never sees it and the carrier stays
529/// boxed. A carrier whose proven bound does not `fits_i64` is also kept
530/// (the table carries the raw `(Interval, bool)` so the seed site can apply
531/// `fits_i64` itself — see `carrier_interval` in `bare_i64`).
532pub fn carrier_interval_table(
533    inputs: &ProofLowerInputs,
534) -> HashMap<String, (crate::ir::interval::Interval, bool)> {
535    let mut table = HashMap::new();
536
537    let entry_typedefs = inputs.entry_items.iter().filter_map(|item| match item {
538        TopLevel::TypeDef(td) => Some((None::<&str>, td)),
539        _ => None,
540    });
541    let module_typedefs = inputs.dep_modules.iter().flat_map(|m| {
542        m.type_defs
543            .iter()
544            .map(move |td| (Some(m.prefix.as_str()), td))
545    });
546
547    for (module_prefix, td) in entry_typedefs.chain(module_typedefs) {
548        // Refinement-via-opaque is a single-carrier-field product; mirror the
549        // exact eligibility `populate_refined_types` applies so the keyed
550        // bound is the same fact the proof side carries.
551        let TypeDef::Product { name, fields, .. } = td else {
552            continue;
553        };
554        if fields.len() != 1 {
555            continue;
556        }
557        let Some(info) =
558            crate::codegen::common::refinement_info_for_in_scope(name, inputs, module_prefix)
559        else {
560            continue;
561        };
562        let invariant = Predicate {
563            free_vars: vec![(
564                info.param_name.to_string(),
565                QuantifierType::Plain(info.carrier_type.to_string()),
566            )],
567            expr: inputs.resolve_expr(info.predicate, module_prefix),
568        };
569        let (interval, interval_known) = crate::ir::interval::interval_of_invariant(&invariant);
570        // Fail-closed: an unrecognized invariant (`interval_known == false`)
571        // is OMITTED, so the carrier stays boxed.
572        if !interval_known {
573            continue;
574        }
575        // Key by the bare type name — the `MirParam.ty` string. Two modules
576        // may each declare a same-named carrier with different predicates;
577        // the bare name collides. Keep the FIRST (entry walks before deps),
578        // and on a same-name collision intersect to the tighter common bound
579        // (fail-closed: a narrower interval is always still a valid
580        // over-approximation of either inhabitant set, and a mismatch can
581        // only ever shrink eligibility, never wrongly widen it).
582        table
583            .entry(name.clone())
584            .and_modify(|(iv, known): &mut (crate::ir::interval::Interval, bool)| {
585                *iv = iv.intersect(interval);
586                *known = true;
587            })
588            .or_insert((interval, true));
589    }
590
591    table
592}
593
594/// ETAP-2 multi-field carrier-`i64`: derive, per `(record-type, Int-field)`
595/// pair in scope, the constant interval a MULTI-ARG smart constructor's guard
596/// proves over that field. This generalizes [`carrier_interval_table`] from
597/// the single-`value`-field carrier TYPE to a multi-field record whose 2+-arg
598/// smart constructor bounds each field independently.
599///
600/// The recognized shape is:
601/// ```text
602/// record Coord { x: Int, y: Int }                 // 2+ Int fields
603/// fn coord(x: Int, y: Int) -> Result<Coord, String>
604///     match <guard conjunction over x, y>
605///         true  -> Result.Ok(Coord(x = x, y = y)) // param p_j -> field f_i
606///         false -> Result.Err("...")
607/// ```
608/// The Ok-branch `RecordCreate` maps each constructor PARAM to a record FIELD
609/// (`Coord(x = x, y = y)`). For each field, the guard is split on its
610/// `Bool.and` tree and only the leaf comparisons mentioning that field's bound
611/// param ALONE are kept (a cross-field condition like `x + y <= 50` mentions
612/// two params and is DROPPED — conservative/sound: a narrower per-field bound
613/// is always a valid over-approximation). Running [`interval_of_invariant`]
614/// over those single-var leaves yields the field's interval.
615///
616/// **Fail-closed.** A field with no proven single-var `fits_i64` bound is
617/// OMITTED (the field stays boxed `$AverInt`). A non-`Int` field, a param that
618/// the Ok branch does not bind one-to-one to a field, or an unrecognized guard
619/// all decline. The single-field path ([`carrier_interval_table`]) is
620/// untouched — this table is ADDITIVE and keyed by `(record, field)`.
621///
622/// Returns a map keyed by `(bare record name, field name)`.
623pub fn field_carrier_interval_table(
624    inputs: &ProofLowerInputs,
625) -> HashMap<(String, String), (crate::ir::interval::Interval, bool)> {
626    let mut table = HashMap::new();
627
628    let entry_typedefs = inputs.entry_items.iter().filter_map(|item| match item {
629        TopLevel::TypeDef(td) => Some((None::<&str>, td)),
630        _ => None,
631    });
632    let module_typedefs = inputs.dep_modules.iter().flat_map(|m| {
633        m.type_defs
634            .iter()
635            .map(move |td| (Some(m.prefix.as_str()), td))
636    });
637
638    for (module_prefix, td) in entry_typedefs.chain(module_typedefs) {
639        let TypeDef::Product { name, fields, .. } = td else {
640            continue;
641        };
642        // Single-field products are the existing carrier-TYPE path; skip them
643        // here so the two tables never both claim the same record.
644        if fields.len() < 2 {
645            continue;
646        }
647        // Every field must be a plain `Int` for the multi-field i64 erasure;
648        // a record mixing Int and non-Int fields keeps its non-Int fields
649        // boxed (only the Int fields with a proven bound become eligible).
650        let Some((ctor, ctor_prefix)) =
651            find_multi_field_smart_ctor(name, fields, inputs, module_prefix)
652        else {
653            continue;
654        };
655        // Map record FIELD name -> the constructor PARAM name that feeds it,
656        // read from the Ok-branch `RecordCreate`.
657        let field_to_param = match ctor_field_param_map(ctor, name, fields) {
658            Some(m) => m,
659            None => continue,
660        };
661        let guard = ctor_guard_predicate(ctor);
662        let Some(guard) = guard else { continue };
663        for (fname, ftype) in fields {
664            if ftype.trim() != "Int" {
665                continue;
666            }
667            let Some(param) = field_to_param.get(fname) else {
668                continue;
669            };
670            // Project the guard onto single-variable leaves mentioning ONLY
671            // this param. A cross-field leaf (two distinct params) is dropped.
672            let resolved_guard = inputs.resolve_expr(guard, ctor_prefix);
673            let leaves =
674                crate::codegen::common::flatten_bool_and_conjuncts_resolved(&resolved_guard);
675            let single_var: Vec<_> = leaves
676                .into_iter()
677                .filter(|leaf| resolved_leaf_mentions_only(leaf, param))
678                .collect();
679            if single_var.is_empty() {
680                continue;
681            }
682            // Rebuild a conjunction predicate over the kept leaves and run the
683            // SAME interval recognizer the single-field path uses.
684            let conj = rebuild_bool_and(single_var);
685            let invariant = Predicate {
686                free_vars: vec![(param.clone(), QuantifierType::Plain("Int".to_string()))],
687                expr: conj,
688            };
689            let (interval, interval_known) = crate::ir::interval::interval_of_invariant(&invariant);
690            if !interval_known {
691                continue;
692            }
693            // Same collision discipline as the type-keyed table: on a
694            // same-`(record, field)` collision across modules, intersect to
695            // the tighter common bound (fail-closed).
696            table
697                .entry((name.clone(), fname.clone()))
698                .and_modify(|(iv, known): &mut (crate::ir::interval::Interval, bool)| {
699                    *iv = iv.intersect(interval);
700                    *known = true;
701                })
702                .or_insert((interval, true));
703        }
704    }
705
706    table
707}
708
709/// Find the single recognized multi-arg smart constructor for `record_name`:
710/// a pure fn `mk(p1, ..., pN) -> Result<Rec, String>` whose body is a single
711/// two-arm `true -> Result.Ok(Rec(...)) | false -> Result.Err(_)` match. The
712/// param count must be >= 2 (a one-arg ctor is the single-field carrier path).
713/// Returns the `&FnDef` plus the module scope it was found in.
714fn find_multi_field_smart_ctor<'a>(
715    record_name: &str,
716    fields: &[(String, String)],
717    inputs: &ProofLowerInputs<'a>,
718    record_scope: Option<&str>,
719) -> Option<(&'a FnDef, Option<&'a str>)> {
720    let entry_fns = inputs.entry_items.iter().filter_map(|item| match item {
721        TopLevel::FnDef(fd) => Some((None::<&str>, fd)),
722        _ => None,
723    });
724    let module_fns = inputs.dep_modules.iter().flat_map(|m| {
725        m.fn_defs
726            .iter()
727            .map(move |fd| (Some(m.prefix.as_str()), fd))
728    });
729    for (scope, fd) in entry_fns.chain(module_fns) {
730        // The constructor lives in the same module as the record (opaque
731        // refinement is single-module); skip a same-named fn in another scope.
732        if scope != record_scope {
733            continue;
734        }
735        if !fd.return_type.starts_with("Result<") {
736            continue;
737        }
738        if !fd.return_type[7..].starts_with(record_name) {
739            continue;
740        }
741        if fd.params.len() < 2 {
742            continue;
743        }
744        let stmts = fd.body.stmts();
745        if stmts.len() != 1 {
746            continue;
747        }
748        let crate::ast::Stmt::Expr(body_expr) = &stmts[0] else {
749            continue;
750        };
751        let Expr::Match { arms, .. } = &body_expr.node else {
752            continue;
753        };
754        if !is_multi_field_ok_err_match(arms, record_name, fields) {
755            continue;
756        }
757        return Some((fd, scope));
758    }
759    None
760}
761
762/// True iff `arms` is the canonical multi-field smart-ctor shape:
763/// `true -> Result.Ok(Rec(f_i = p_j, ...))` covering EVERY field, and
764/// `false -> Result.Err(_)`.
765fn is_multi_field_ok_err_match(
766    arms: &[crate::ast::MatchArm],
767    record_name: &str,
768    fields: &[(String, String)],
769) -> bool {
770    if arms.len() != 2 {
771        return false;
772    }
773    let mut true_ok = false;
774    let mut false_err = false;
775    for arm in arms {
776        match &arm.pattern {
777            crate::ast::Pattern::Literal(Literal::Bool(true)) => {
778                if multi_field_ok_constructor(&arm.body, record_name, fields).is_some() {
779                    true_ok = true;
780                }
781            }
782            crate::ast::Pattern::Literal(Literal::Bool(false)) => {
783                if multi_field_is_err_constructor(&arm.body) {
784                    false_err = true;
785                }
786            }
787            _ => return false,
788        }
789    }
790    true_ok && false_err
791}
792
793/// Local mirror of `common::is_err_constructor` (which is private): the arm
794/// body is `Result.Err(_)`.
795fn multi_field_is_err_constructor(expr: &Spanned<Expr>) -> bool {
796    match &expr.node {
797        Expr::Constructor(name, Some(_)) => name == "Result.Err",
798        Expr::FnCall(callee, args) if args.len() == 1 => {
799            matches!(expr_to_dotted_name(&callee.node), Some(name) if name == "Result.Err")
800        }
801        _ => false,
802    }
803}
804
805/// Inspect a `Result.Ok(Rec(f_i = p_j, ...))` arm body and return the
806/// field-name -> param-name map when every field is set to a bare identifier.
807/// `None` if the body is not the expected `Result.Ok` of a `RecordCreate` of
808/// `record_name` covering exactly the declared fields with identifier values.
809fn multi_field_ok_constructor(
810    expr: &Spanned<Expr>,
811    record_name: &str,
812    fields: &[(String, String)],
813) -> Option<HashMap<String, String>> {
814    let (ctor_name, ctor_arg_node) = match &expr.node {
815        Expr::Constructor(name, Some(arg)) => (name.clone(), &arg.node),
816        Expr::FnCall(callee, args) if args.len() == 1 => {
817            let name = expr_to_dotted_name(&callee.node)?;
818            (name, &args[0].node)
819        }
820        _ => return None,
821    };
822    if ctor_name != "Result.Ok" {
823        return None;
824    }
825    let (t, create_fields) = match ctor_arg_node {
826        Expr::RecordCreate { type_name, fields } => (type_name.as_str(), fields),
827        _ => return None,
828    };
829    if t != record_name || create_fields.len() != fields.len() {
830        return None;
831    }
832    let mut map = HashMap::new();
833    for (fname, fvalue) in create_fields {
834        let param = match &fvalue.node {
835            Expr::Ident(n) | Expr::Resolved { name: n, .. } => n.clone(),
836            _ => return None,
837        };
838        map.insert(fname.clone(), param);
839    }
840    // Every declared field must be assigned by the Ok branch.
841    if fields.iter().any(|(fname, _)| !map.contains_key(fname)) {
842        return None;
843    }
844    Some(map)
845}
846
847/// The field -> param map of the smart constructor's Ok branch (the
848/// `true -> Result.Ok(Rec(...))` arm). Walks the body's single match.
849fn ctor_field_param_map(
850    fd: &FnDef,
851    record_name: &str,
852    fields: &[(String, String)],
853) -> Option<HashMap<String, String>> {
854    let stmts = fd.body.stmts();
855    let crate::ast::Stmt::Expr(body_expr) = stmts.first()? else {
856        return None;
857    };
858    let Expr::Match { arms, .. } = &body_expr.node else {
859        return None;
860    };
861    for arm in arms {
862        if matches!(
863            &arm.pattern,
864            crate::ast::Pattern::Literal(Literal::Bool(true))
865        ) {
866            return multi_field_ok_constructor(&arm.body, record_name, fields);
867        }
868    }
869    None
870}
871
872/// The smart constructor's guard predicate (the `Match` subject the Ok/Err
873/// arms branch on).
874fn ctor_guard_predicate(fd: &FnDef) -> Option<&Spanned<Expr>> {
875    let stmts = fd.body.stmts();
876    let crate::ast::Stmt::Expr(body_expr) = stmts.first()? else {
877        return None;
878    };
879    let Expr::Match { subject, .. } = &body_expr.node else {
880        return None;
881    };
882    Some(subject)
883}
884
885/// True iff every identifier leaf mentioned in `leaf` is `param` (so the leaf
886/// is a SINGLE-VARIABLE condition over that one param). A leaf naming two
887/// distinct params (a cross-field condition like `x + y <= 50`) returns false
888/// and is dropped by the caller — conservative/sound per-field projection.
889fn resolved_leaf_mentions_only(leaf: &Spanned<crate::ir::hir::ResolvedExpr>, param: &str) -> bool {
890    let mut only = true;
891    let mut saw = false;
892    collect_resolved_idents(leaf, &mut |name| {
893        if name == param {
894            saw = true;
895        } else {
896            only = false;
897        }
898    });
899    only && saw
900}
901
902/// Walk a resolved expression, invoking `f` for every identifier leaf
903/// (`Ident` / `Resolved`).
904fn collect_resolved_idents(e: &Spanned<crate::ir::hir::ResolvedExpr>, f: &mut impl FnMut(&str)) {
905    use crate::ir::hir::ResolvedExpr;
906    match &e.node {
907        ResolvedExpr::Ident(n) | ResolvedExpr::Resolved { name: n, .. } => f(n),
908        ResolvedExpr::BinOp(_, l, r) => {
909            collect_resolved_idents(l, f);
910            collect_resolved_idents(r, f);
911        }
912        ResolvedExpr::Neg(i) => collect_resolved_idents(i, f),
913        ResolvedExpr::Call(_, args) => {
914            for a in args {
915                collect_resolved_idents(a, f);
916            }
917        }
918        ResolvedExpr::Attr(o, _) => collect_resolved_idents(o, f),
919        _ => {}
920    }
921}
922
923/// Rebuild a `Bool.and` conjunction over the kept single-variable leaves.
924/// A single leaf returns itself; >1 fold into nested `Bool.and(...)` calls,
925/// matching the shape [`interval_of_invariant`] recognizes.
926fn rebuild_bool_and(
927    mut leaves: Vec<Spanned<crate::ir::hir::ResolvedExpr>>,
928) -> Spanned<crate::ir::hir::ResolvedExpr> {
929    use crate::ir::hir::{ResolvedCallee, ResolvedExpr};
930    let mut acc = leaves.remove(0);
931    for leaf in leaves {
932        let line = acc.line;
933        acc = Spanned::new(
934            ResolvedExpr::Call(
935                ResolvedCallee::Builtin("Bool.and".to_string()),
936                vec![acc, leaf],
937            ),
938            line,
939        );
940    }
941    acc
942}
943
944/// ETAP-2 multi-field carrier-`i64`: the per-`(record, field)` ELIGIBLE map —
945/// [`field_carrier_interval_table`] tightened the same way the single-field
946/// path tightens its proven-bound set. An entry survives only when its bound
947/// is recognized AND `fits_i64` AND the owning record is NOT demoted by any
948/// whole-program scan ([`multi_field_record_demotions`]):
949///   - the record is constructed UNGATED (a bare `RecordCreate` outside its
950///     own smart-ctor whose args are not all in-bounds literals) — that bypass
951///     could store an out-of-`i64` value the construct bridge would TRAP on;
952///   - the record (or a record reaching it) is used as a `Map` KEY — the
953///     Map-key codegen was not updated for the i64-erased fields;
954///   - the record (or a record reaching it) is used DIRECTLY as a `Map` VALUE
955///     (or through a record field / `Option` / `Result` that keeps it an inline
956///     struct ref in the values array) — that trips a separate, pre-existing
957///     record-as-Map-value validation bug, so the whole record stays boxed
958///     there. A carrier used as a `List` / `Vector` / `Tuple` ELEMENT now STAYS
959///     native: the per-field record eq/hash helpers dispatch a raw `i64.eq` /
960///     `i32.wrap_i64` for its i64-erased fields, so `List<Coord>` keeps
961///     `(field i64)(field i64)` elements that compile + run native. (`Option` /
962///     `Result` payloads are NOT demoted either — they hold the element as an
963///     inline struct ref, so the smart-ctor boundary
964///     `coord(...) -> Result<Coord, String>` keeps the native-i64 win.)
965///
966/// A demoted record keeps EVERY field boxed (the whole struct stays the
967/// pre-slice all-`$AverInt` layout). wasm-gc only; the Rust path passes the
968/// empty registry and never erases a field.
969///
970/// Returns a map keyed by `(bare record name, field name)`; an empty map
971/// reproduces the pre-slice all-`$AverInt` multi-field record byte-for-byte.
972pub fn field_carrier_eligible_intervals(
973    inputs: &ProofLowerInputs,
974    instantiations: &crate::ir::mir::InstantiationRegistry,
975) -> HashMap<(String, String), (crate::ir::interval::Interval, bool)> {
976    let table = field_carrier_interval_table(inputs);
977    if table.is_empty() {
978        return table;
979    }
980    // Proven-bound candidates: bound recognized AND `fits_i64`. The set of
981    // RECORD names with at least one such field drives the demotion scans
982    // (a demotion is per-record — a record constructed ungated / used as a
983    // Map key keeps ALL its fields boxed).
984    let record_candidates: HashSet<String> = table
985        .iter()
986        .filter(|(_, (iv, known))| *known && iv.fits_i64())
987        .map(|((rec, _), _)| rec.clone())
988        .collect();
989    if std::env::var("AVER_CARRIER_I64_SKIP_DEMOTION").is_ok() {
990        return table
991            .into_iter()
992            .filter(|((rec, _), (iv, known))| {
993                *known && iv.fits_i64() && record_candidates.contains(rec)
994            })
995            .collect();
996    }
997    let demoted_records =
998        multi_field_record_demotions(inputs, &record_candidates, &table, instantiations);
999    table
1000        .into_iter()
1001        .filter(|((rec, _), (iv, known))| {
1002            *known
1003                && iv.fits_i64()
1004                && record_candidates.contains(rec)
1005                && !demoted_records.contains(rec)
1006        })
1007        .collect()
1008}
1009
1010/// ETAP-2 multi-field carrier-`i64`: the RECORD-level fail-closed demotion
1011/// scan — the multi-field generalization of [`carrier_eligibility_demotions`].
1012/// A multi-field bounded record is demoted (every field kept boxed) when:
1013///   - **Scan 1 (ungated construction).** A `RecordCreate` / `RecordUpdate` of
1014///     the record in a fn OTHER than its recognized multi-arg smart constructor
1015///     can smuggle an out-of-`i64` value past the per-field gate (the construct
1016///     bridge `__aint_to_i64_checked` would TRAP). SAFE only when every field
1017///     argument is a constant literal provably inside that field's proven
1018///     interval; any non-literal / out-of-range field argument demotes.
1019///   - **Scan 2 (Map-key usage).** The record — directly or transitively as a
1020///     field of a record/Option/List/Tuple used as a Map KEY — reaches a
1021///     `Map<K, V>` key position; the Map-key codegen still expects the boxed
1022///     struct. Driven by the inference-complete resolved Map-key types, with
1023///     the textual annotation scan as a cheap backstop. Both reuse the SAME
1024///     `carriers_reachable_from` / `collect_map_key_carriers` closures the
1025///     single-field path uses.
1026///   - **Scan 3 (direct Map-VALUE usage).** The record reaches a `Map` VALUE
1027///     DIRECTLY (or through a record field / `Option` / `Result` that keeps it
1028///     an inline struct ref in the values array). That trips a separate,
1029///     pre-existing record-as-Map-value validation bug, so the record stays
1030///     boxed there. The walk STOPS at a `List` / `Vector` / `Tuple` boundary: a
1031///     carrier used as such a container ELEMENT now stays native i64 (the
1032///     per-field record eq/hash helpers dispatch the raw `i64.eq` /
1033///     `i32.wrap_i64` for an i64-erased field), so a carrier reachable only
1034///     THROUGH such a container — e.g. a `Map<K, List<Coord>>` value — is NOT
1035///     demoted. Driven by the inference-complete instantiation registry (every
1036///     `Map` the program uses), via `carriers_reachable_as_map_value`.
1037///
1038/// Fail-closed: a trip can only ever SHRINK the eligible set.
1039fn multi_field_record_demotions(
1040    inputs: &ProofLowerInputs,
1041    candidates: &HashSet<String>,
1042    field_intervals: &HashMap<(String, String), (crate::ir::interval::Interval, bool)>,
1043    instantiations: &crate::ir::mir::InstantiationRegistry,
1044) -> HashSet<String> {
1045    let mut demoted: HashSet<String> = HashSet::new();
1046    if candidates.is_empty() {
1047        return demoted;
1048    }
1049
1050    // Map each candidate record to its recognized multi-arg smart-ctor fn name
1051    // (the only construct site that gates the fields). A record with no
1052    // recognizable smart-ctor never reached `field_carrier_interval_table`, so
1053    // every candidate has one — but resolve defensively and demote on failure.
1054    let mut ctor_fn_of: HashMap<String, String> = HashMap::new();
1055    let record_defs = collect_product_defs(inputs);
1056    for name in candidates {
1057        let Some((fields, scope)) = record_defs.get(name) else {
1058            demoted.insert(name.clone());
1059            continue;
1060        };
1061        match find_multi_field_smart_ctor(name, fields, inputs, *scope) {
1062            Some((ctor, _)) => {
1063                ctor_fn_of.insert(name.clone(), ctor.name.clone());
1064            }
1065            None => {
1066                demoted.insert(name.clone());
1067            }
1068        }
1069    }
1070
1071    // ---- Scan 1: ungated construction --------------------------------------
1072    let all_fn_defs = inputs
1073        .entry_items
1074        .iter()
1075        .filter_map(|it| match it {
1076            TopLevel::FnDef(fd) => Some(fd),
1077            _ => None,
1078        })
1079        .chain(inputs.dep_modules.iter().flat_map(|m| m.fn_defs.iter()));
1080    for fd in all_fn_defs {
1081        for stmt in fd.body.stmts() {
1082            let expr = match stmt {
1083                crate::ast::Stmt::Binding(_, _, e) | crate::ast::Stmt::Expr(e) => e,
1084            };
1085            carrier_walk_expr(expr, &mut |e| {
1086                let (type_name, create_fields): (&String, &[(String, Spanned<Expr>)]) = match e {
1087                    Expr::RecordCreate { type_name, fields } => (type_name, fields),
1088                    Expr::RecordUpdate {
1089                        type_name, updates, ..
1090                    } => (type_name, updates),
1091                    _ => return,
1092                };
1093                if !candidates.contains(type_name) {
1094                    return;
1095                }
1096                // The construct inside the record's own smart-ctor is gated.
1097                if ctor_fn_of.get(type_name) == Some(&fd.name) {
1098                    return;
1099                }
1100                // Outside the smart-ctor: safe iff EVERY provided field value is
1101                // a constant literal inside that field's proven interval (an
1102                // in-bounds literal cannot smuggle an out-of-bound value past
1103                // the gate). A `RecordUpdate` that omits a bounded field copies
1104                // it from the (already-gated) base, which is safe too.
1105                let all_safe = create_fields.iter().all(|(fname, value)| {
1106                    match field_intervals.get(&(type_name.clone(), fname.clone())) {
1107                        // A non-bounded field (no eligible interval) is stored
1108                        // boxed regardless, so it can't smuggle an i64-overflow.
1109                        None => true,
1110                        Some((iv, true)) => literal_in_interval(value, *iv),
1111                        Some((_, false)) => false,
1112                    }
1113                });
1114                if !all_safe {
1115                    demoted.insert(type_name.clone());
1116                }
1117            });
1118        }
1119    }
1120
1121    // ---- Scan 2: Map-key usage (direct + transitive) -----------------------
1122    let record_fields = collect_record_fields(inputs);
1123    for (key, _value) in &instantiations.maps {
1124        carriers_reachable_from(
1125            key,
1126            candidates,
1127            &record_fields,
1128            &mut HashSet::new(),
1129            &mut demoted,
1130        );
1131    }
1132    for ty_str in all_type_annotations(inputs) {
1133        let ty = crate::types::parse_type_str(&ty_str);
1134        collect_map_key_carriers(&ty, candidates, &record_fields, &mut demoted);
1135    }
1136
1137    // ---- Scan 3: Map-VALUE usage (direct + transitive, list/vec/tuple-stopped)
1138    // A multi-field carrier reachable DIRECTLY as a `Map` VALUE (or through a
1139    // record field / `Option` / `Result` that keeps it an inline struct ref in
1140    // the values backing array) keeps all its fields BOXED — that case trips a
1141    // separate, pre-existing record-as-Map-value validation bug that is out of
1142    // scope here, so we fail closed exactly as before.
1143    //
1144    // A carrier used as a `List` / `Vector` / `Tuple` ELEMENT now STAYS native
1145    // i64: the per-field record eq/hash helpers dispatch a raw `i64.eq` /
1146    // `i32.wrap_i64` for an i64-erased field (gated on `is_eligible_carrier_
1147    // field`), so `List<Coord>` keeps `(field i64)(field i64)` elements and
1148    // `List.contains` / `==` over them compiles and runs native. The reachability
1149    // walk therefore STOPS at a `List` / `Vector` / `Tuple` boundary — a carrier
1150    // only reachable THROUGH such a container (e.g. `Map<K, List<Coord>>`, where
1151    // the Map value is a list ref whose elements are native) is NOT demoted.
1152    //
1153    // `Option` / `Result` payloads are likewise inline struct refs, so a carrier
1154    // behind them as a Map value stays eligible (the common smart-ctor boundary
1155    // `coord(...) -> Result<Coord, String>` keeps the native-i64 win).
1156    let mut map_value_seen: HashSet<String> = HashSet::new();
1157    for (_key, value) in &instantiations.maps {
1158        carriers_reachable_as_map_value(
1159            value,
1160            candidates,
1161            &record_fields,
1162            &mut map_value_seen,
1163            &mut demoted,
1164        );
1165    }
1166
1167    demoted
1168}
1169
1170/// ETAP-2 multi-field carrier-`i64`: the Map-VALUE reachability walk for the
1171/// demotion scan — like [`carriers_reachable_from`] but it STOPS at a `List` /
1172/// `Vector` / `Tuple` boundary. A carrier stored as a `List` / `Vector` /
1173/// `Tuple` element keeps its i64 fields native (the container's per-element
1174/// eq/hash dispatches the raw i64 ops), so reaching one only THROUGH such a
1175/// container (e.g. a `Map<K, List<Coord>>` value, a list-of-tuples value) does
1176/// NOT demote it. A carrier reachable as a DIRECT Map value, or through a
1177/// record field / `Option` / `Result` (all of which hold it as an inline struct
1178/// ref in the values backing array), still demotes — the record-as-Map-value
1179/// validation bug that motivates this scan is unchanged by the container slice.
1180fn carriers_reachable_as_map_value(
1181    value: &crate::ast::Type,
1182    candidates: &HashSet<String>,
1183    record_fields: &HashMap<String, Vec<String>>,
1184    seen: &mut HashSet<String>,
1185    demoted: &mut HashSet<String>,
1186) {
1187    use crate::ast::Type;
1188    let Some(name) = value.named_name() else {
1189        match value {
1190            // `Option` / `Result` keep the payload an inline struct ref in the
1191            // Map values array, so a carrier behind them is still a direct
1192            // value — descend.
1193            Type::Option(a) => {
1194                carriers_reachable_as_map_value(a, candidates, record_fields, seen, demoted);
1195            }
1196            Type::Result(a, b) => {
1197                carriers_reachable_as_map_value(a, candidates, record_fields, seen, demoted);
1198                carriers_reachable_as_map_value(b, candidates, record_fields, seen, demoted);
1199            }
1200            // A nested `Map` value is itself a Map values array — descend into
1201            // its value (its key is a separate Scan-2 concern, handled there).
1202            Type::Map(_k, v) => {
1203                carriers_reachable_as_map_value(v, candidates, record_fields, seen, demoted);
1204            }
1205            // `List` / `Vector` / `Tuple` make the carrier a native container
1206            // ELEMENT (now eligible) — STOP, do not demote anything inside.
1207            Type::List(_) | Type::Vector(_) | Type::Tuple(_) => {}
1208            _ => {}
1209        }
1210        return;
1211    };
1212    if !seen.insert(name.to_string()) {
1213        return;
1214    }
1215    if candidates.contains(name) {
1216        demoted.insert(name.to_string());
1217    }
1218    if let Some(fields) = record_fields.get(name) {
1219        for field_ty in fields {
1220            let parsed = crate::types::parse_type_str(field_ty);
1221            carriers_reachable_as_map_value(&parsed, candidates, record_fields, seen, demoted);
1222        }
1223    }
1224}
1225
1226/// A candidate record's declarations as the demotion scan needs them: its
1227/// `(field, type)` list plus the module scope it was found in (`None` = entry).
1228type ProductDef<'a> = (&'a [(String, String)], Option<&'a str>);
1229
1230/// Bare `Product` type name → its [`ProductDef`]. Lets the demotion scan
1231/// re-locate a candidate record's field declarations + smart constructor.
1232fn collect_product_defs<'a>(inputs: &ProofLowerInputs<'a>) -> HashMap<String, ProductDef<'a>> {
1233    let mut out: HashMap<String, ProductDef<'a>> = HashMap::new();
1234    for item in inputs.entry_items {
1235        if let TopLevel::TypeDef(TypeDef::Product { name, fields, .. }) = item {
1236            out.entry(name.clone()).or_insert((fields.as_slice(), None));
1237        }
1238    }
1239    for m in inputs.dep_modules {
1240        for td in &m.type_defs {
1241            if let TypeDef::Product { name, fields, .. } = td {
1242                out.entry(name.clone())
1243                    .or_insert((fields.as_slice(), Some(m.prefix.as_str())));
1244            }
1245        }
1246    }
1247    out
1248}
1249
1250/// A `RecordCreate`/`RecordUpdate` field value is a constant integer literal
1251/// (`5` or `-5`) provably within the proven interval `iv`. Any other shape
1252/// (a param, a call, arithmetic) is not a provable constant ⇒ `false`.
1253fn literal_in_interval(value: &Spanned<Expr>, iv: crate::ir::interval::Interval) -> bool {
1254    let k: i128 = match &value.node {
1255        Expr::Literal(Literal::Int(n)) => *n as i128,
1256        Expr::Neg(inner) => match &inner.node {
1257            Expr::Literal(Literal::Int(n)) => -(*n as i128),
1258            _ => return false,
1259        },
1260        _ => return false,
1261    };
1262    iv.contains_point(k)
1263}
1264
1265/// ETAP-2 carrier-`i64` SLICE 2b FOLLOW-UP: the fail-closed eligibility
1266/// tightening. The bare carrier interval ([`carrier_interval_table`]) proves
1267/// only that the smart-constructor's invariant `fits_i64`; it does NOT prove
1268/// that every value of the carrier type actually went through that gate, nor
1269/// that the carrier's codegen is exercised only in positions the i64 erasure
1270/// supports. This scan removes a carrier from the eligible set when either
1271/// assumption is violated, so the carrier stays boxed (`$AverInt`) — the
1272/// safe, pre-slice representation that the VM and the boxed wasm-gc path
1273/// agree on.
1274///
1275/// Two whole-program scans, both fail-closed (a trip can only ever SHRINK the
1276/// eligible set, never widen it):
1277///
1278/// 1. **Ungated construction (closes the bare-constructor bypass).** A bare
1279///    record constructor `IntRange(value = n)` callable in the defining
1280///    module bypasses the smart-ctor's `0 <= n <= 100` gate. With `n`
1281///    overflowing `i64` the VM keeps full precision but the wasm-gc construct
1282///    bridge `__aint_to_i64_checked` TRAPS. A carrier `RecordCreate`d outside
1283///    its own recognized smart-constructor function is therefore ineligible —
1284///    UNLESS the construct's carrier-field argument is a constant literal that
1285///    provably lies inside the carrier's proven interval. Such a literal
1286///    construct (`IntRange(value = 0)` against a `[0, 100]` bound) cannot
1287///    smuggle an out-of-bound / i64-overflowing value past the gate, so it is
1288///    SAFE and does not demote — that pattern is exactly the in-bounds Err
1289///    fallback the slice's own carriers use (`unwrap`'s `IntRange(value = 0)`).
1290///    A non-literal argument, or a literal OUTSIDE the interval, is ungated
1291///    and DOES demote (this is the `mk(n) = IntRange(value = n)` bypass).
1292///
1293/// 2. **Map-key usage (closes the i64-erased Map-KEY codegen gap).** A
1294///    carrier used as a `Map` KEY type — directly (`Map<IntRange, V>`) or
1295///    transitively as a field of a record/type used as a key
1296///    (`Map<Coord, V>` with `Coord { x: IntRange }`) — fails wasm validation,
1297///    because the Map-key codegen was not updated for the i64-erased carrier.
1298///    Such a carrier is ineligible (the boxed key path it used before still
1299///    compiles). Map VALUES are unaffected and stay eligible.
1300///
1301///    The COMPLETE source of truth for which carriers are Map keys is
1302///    `resolved_map_keys` — the resolved `Map<K, V>` key types harvested from
1303///    the typed MIR (`ir::mir::discover_instantiations`). Because those types
1304///    come from inference, a carrier used as a key reaches this scan whether
1305///    its `Map` type was written as a fn-signature annotation, a LOCAL-BINDING
1306///    annotation (`m: Map<IntRange, Int> = …`), or NO annotation at all
1307///    (`m = Map.set({}, c, 5)`). A textual annotation scan cannot see the
1308///    last two; the resolved key type can. The annotation scan is retained as
1309///    a cheap fail-closed backstop only.
1310///
1311/// Returns the set of carrier type names (bare names, the same keys
1312/// [`carrier_interval_table`] uses) to REMOVE from the eligible set. The
1313/// caller subtracts this from the proven-bound set.
1314///
1315/// `resolved_map_keys`: every `Map<K, _>` key type the program instantiates,
1316/// per the typed-MIR instantiation registry. This is the inference-complete
1317/// Map-key signal that drives Scan 2.
1318pub fn carrier_eligibility_demotions(
1319    inputs: &ProofLowerInputs,
1320    candidates: &HashSet<String>,
1321    intervals: &HashMap<String, (crate::ir::interval::Interval, bool)>,
1322    resolved_map_keys: &[crate::ast::Type],
1323) -> HashSet<String> {
1324    let mut demoted: HashSet<String> = HashSet::new();
1325    if candidates.is_empty() {
1326        return demoted;
1327    }
1328
1329    // Map each candidate carrier to its recognized smart-constructor fn name.
1330    // `refinement_info_for_in_scope` is THE source of truth for "the
1331    // smart-ctor function" — the exact fn the interval recognizer keyed off.
1332    // For the wasm-gc path `dep_modules` is empty (the program is flattened
1333    // into `entry_items`), so the entry scope (`None`) resolves every
1334    // carrier; we still consult dep scopes for generality.
1335    let mut ctor_fn_of: HashMap<String, String> = HashMap::new();
1336    for name in candidates {
1337        if let Some(info) = crate::codegen::common::refinement_info_for_in_scope(name, inputs, None)
1338        {
1339            ctor_fn_of.insert(name.clone(), info.constructor_fn.to_string());
1340        } else {
1341            for m in inputs.dep_modules {
1342                if let Some(info) = crate::codegen::common::refinement_info_for_in_scope(
1343                    name,
1344                    inputs,
1345                    Some(m.prefix.as_str()),
1346                ) {
1347                    ctor_fn_of.insert(name.clone(), info.constructor_fn.to_string());
1348                    break;
1349                }
1350            }
1351        }
1352    }
1353
1354    // ---- Scan 1: ungated construction --------------------------------------
1355    // Walk every fn body in the program. A `RecordCreate { type_name }` whose
1356    // `type_name` is a candidate carrier and which sits in a fn OTHER than
1357    // that carrier's smart-constructor is an ungated construct ⇒ demote,
1358    // UNLESS its carrier-field argument is a constant literal provably inside
1359    // the carrier's proven interval (an in-bounds literal can't smuggle an
1360    // out-of-bound value past the gate — fail-closed but not over-eager). If
1361    // we can't cleanly identify the smart-ctor (no entry in `ctor_fn_of`),
1362    // every non-literal-safe construct demotes — fail-closed.
1363    let all_fn_defs = inputs
1364        .entry_items
1365        .iter()
1366        .filter_map(|it| match it {
1367            TopLevel::FnDef(fd) => Some(fd),
1368            _ => None,
1369        })
1370        .chain(inputs.dep_modules.iter().flat_map(|m| m.fn_defs.iter()));
1371    for fd in all_fn_defs {
1372        for stmt in fd.body.stmts() {
1373            let expr = match stmt {
1374                crate::ast::Stmt::Binding(_, _, e) | crate::ast::Stmt::Expr(e) => e,
1375            };
1376            carrier_walk_expr(expr, &mut |e| {
1377                // Both a fresh construct (`RecordCreate`) and a record-update
1378                // (`RecordUpdate`, which sets the carrier's only field to an
1379                // arbitrary value) can smuggle a value past the smart-ctor
1380                // gate; treat both the same.
1381                let (type_name, fields): (&String, &[(String, Spanned<Expr>)]) = match e {
1382                    Expr::RecordCreate { type_name, fields } => (type_name, fields),
1383                    Expr::RecordUpdate {
1384                        type_name, updates, ..
1385                    } => (type_name, updates),
1386                    _ => return,
1387                };
1388                if !candidates.contains(type_name) {
1389                    return;
1390                }
1391                // The construct inside the carrier's own smart-ctor is the
1392                // gated one — never demotes.
1393                if ctor_fn_of.get(type_name) == Some(&fd.name) {
1394                    return;
1395                }
1396                // Outside the smart-ctor: safe iff the (single) carrier field
1397                // is a constant literal inside the proven interval.
1398                let safe_literal = matches!(
1399                    intervals.get(type_name),
1400                    Some((iv, true)) if construct_arg_is_in_interval(fields, *iv)
1401                );
1402                if !safe_literal {
1403                    demoted.insert(type_name.clone());
1404                }
1405            });
1406        }
1407    }
1408
1409    // ---- Scan 2: Map-key usage (direct + transitive) -----------------------
1410    // Build, per record type, the set of carrier names reachable through its
1411    // (transitive) fields, so a carrier nested inside a record used as a key
1412    // is demoted too.
1413    let record_fields = collect_record_fields(inputs);
1414
1415    // PRIMARY (complete) source of truth: the RESOLVED Map-key types harvested
1416    // from the typed MIR (`discover_instantiations`). Every `Map<K, V>` the
1417    // program actually instantiates is here — including ones whose key type is
1418    // only known after INFERENCE (a local-binding `m: Map<…>` annotation that
1419    // the textual scan never walks, or a fully inferred `m = Map.set({}, c,
1420    // 5)` with no annotation at all). A textual annotation scan is
1421    // fundamentally incomplete for these; the resolved key type is not. Demote
1422    // every carrier reachable from each resolved key (directly, or
1423    // transitively through a record / Option / List / Tuple field — the same
1424    // `carriers_reachable_from` closure the annotation path uses).
1425    for key in resolved_map_keys {
1426        carriers_reachable_from(
1427            key,
1428            candidates,
1429            &record_fields,
1430            &mut HashSet::new(),
1431            &mut demoted,
1432        );
1433    }
1434
1435    // BACKSTOP (cheap): the original textual scan over fn-param / fn-return /
1436    // record-field annotations. Redundant with the resolved-IR path above for
1437    // any Map the MIR sees, but kept so a Map type that appears ONLY in an
1438    // annotation position the MIR instantiation walk doesn't reach (e.g. a
1439    // signature whose body never constructs/uses the Map) still trips. It can
1440    // only ever ADD to `demoted` (fail-closed).
1441    for ty_str in all_type_annotations(inputs) {
1442        let ty = crate::types::parse_type_str(&ty_str);
1443        collect_map_key_carriers(&ty, candidates, &record_fields, &mut demoted);
1444    }
1445
1446    demoted
1447}
1448
1449/// A single-carrier-field `RecordCreate`'s field argument is a constant
1450/// integer literal provably within the proven interval `iv`. Handles the
1451/// bare literal `IntRange(value = 0)` and the negated literal
1452/// `IntRange(value = -5)` (parsed as `Expr::Neg(Literal(Int))`). Any other
1453/// shape (a parameter, a call, an arithmetic expression) is NOT a provable
1454/// constant ⇒ returns `false` ⇒ the construct is treated as ungated.
1455fn construct_arg_is_in_interval(
1456    fields: &[(String, Spanned<Expr>)],
1457    iv: crate::ir::interval::Interval,
1458) -> bool {
1459    // Refinement-via-opaque carriers are single-field products.
1460    let [(_, value)] = fields else {
1461        return false;
1462    };
1463    let k: i128 = match &value.node {
1464        Expr::Literal(Literal::Int(n)) => *n as i128,
1465        Expr::Neg(inner) => match &inner.node {
1466            Expr::Literal(Literal::Int(n)) => -(*n as i128),
1467            _ => return false,
1468        },
1469        _ => return false,
1470    };
1471    iv.contains_point(k)
1472}
1473
1474/// Local AST visitor (the proof-lower module has no shared walker). Pre-order
1475/// visit of every sub-expression. Mirrors `call_graph::walk_expr`.
1476fn carrier_walk_expr(expr: &Spanned<Expr>, visit: &mut impl FnMut(&Expr)) {
1477    visit(&expr.node);
1478    match &expr.node {
1479        Expr::FnCall(func, args) => {
1480            carrier_walk_expr(func, visit);
1481            for arg in args {
1482                carrier_walk_expr(arg, visit);
1483            }
1484        }
1485        Expr::TailCall(boxed) => {
1486            for arg in &boxed.args {
1487                carrier_walk_expr(arg, visit);
1488            }
1489        }
1490        Expr::Attr(obj, _) => carrier_walk_expr(obj, visit),
1491        Expr::BinOp(_, l, r) => {
1492            carrier_walk_expr(l, visit);
1493            carrier_walk_expr(r, visit);
1494        }
1495        Expr::Neg(inner) | Expr::ErrorProp(inner) => carrier_walk_expr(inner, visit),
1496        Expr::Match { subject, arms, .. } => {
1497            carrier_walk_expr(subject, visit);
1498            for arm in arms {
1499                carrier_walk_expr(&arm.body, visit);
1500            }
1501        }
1502        Expr::List(items) | Expr::Tuple(items) | Expr::IndependentProduct(items, _) => {
1503            for item in items {
1504                carrier_walk_expr(item, visit);
1505            }
1506        }
1507        Expr::MapLiteral(entries) => {
1508            for (k, v) in entries {
1509                carrier_walk_expr(k, visit);
1510                carrier_walk_expr(v, visit);
1511            }
1512        }
1513        Expr::Constructor(_, maybe) => {
1514            if let Some(inner) = maybe {
1515                carrier_walk_expr(inner, visit);
1516            }
1517        }
1518        Expr::InterpolatedStr(parts) => {
1519            for part in parts {
1520                if let crate::ast::StrPart::Parsed(e) = part {
1521                    carrier_walk_expr(e, visit);
1522                }
1523            }
1524        }
1525        Expr::RecordCreate { fields, .. } => {
1526            for (_, e) in fields {
1527                carrier_walk_expr(e, visit);
1528            }
1529        }
1530        Expr::RecordUpdate { base, updates, .. } => {
1531            carrier_walk_expr(base, visit);
1532            for (_, e) in updates {
1533                carrier_walk_expr(e, visit);
1534            }
1535        }
1536        Expr::Literal(_) | Expr::Ident(_) | Expr::Resolved { .. } => {}
1537    }
1538}
1539
1540/// Bare record-type name → its field type strings (`Product` types only).
1541/// Used by Scan 2 to chase a carrier nested inside a record used as a Map
1542/// key.
1543fn collect_record_fields(inputs: &ProofLowerInputs) -> HashMap<String, Vec<String>> {
1544    let mut out: HashMap<String, Vec<String>> = HashMap::new();
1545    let entry_tds = inputs.entry_items.iter().filter_map(|it| match it {
1546        TopLevel::TypeDef(td) => Some(td),
1547        _ => None,
1548    });
1549    let dep_tds = inputs.dep_modules.iter().flat_map(|m| m.type_defs.iter());
1550    for td in entry_tds.chain(dep_tds) {
1551        if let TypeDef::Product { name, fields, .. } = td {
1552            out.entry(name.clone())
1553                .or_default()
1554                .extend(fields.iter().map(|(_, ty)| ty.clone()));
1555        }
1556    }
1557    out
1558}
1559
1560/// Every type annotation string in the program: fn param types, fn return
1561/// types, and record field types. A `Map<…>` anywhere here is a Map-key
1562/// usage candidate for Scan 2.
1563fn all_type_annotations(inputs: &ProofLowerInputs) -> Vec<String> {
1564    let mut out: Vec<String> = Vec::new();
1565    let push_fn = |fd: &FnDef, out: &mut Vec<String>| {
1566        for (_, ty) in &fd.params {
1567            out.push(ty.clone());
1568        }
1569        out.push(fd.return_type.clone());
1570    };
1571    for it in inputs.entry_items {
1572        match it {
1573            TopLevel::FnDef(fd) => push_fn(fd, &mut out),
1574            TopLevel::TypeDef(TypeDef::Product { fields, .. }) => {
1575                out.extend(fields.iter().map(|(_, ty)| ty.clone()));
1576            }
1577            _ => {}
1578        }
1579    }
1580    for m in inputs.dep_modules {
1581        for fd in &m.fn_defs {
1582            push_fn(fd, &mut out);
1583        }
1584        for td in &m.type_defs {
1585            if let TypeDef::Product { fields, .. } = td {
1586                out.extend(fields.iter().map(|(_, ty)| ty.clone()));
1587            }
1588        }
1589    }
1590    out
1591}
1592
1593/// Walk a parsed `Type`. For every `Map<K, _>` encountered, add every
1594/// candidate carrier REACHABLE from `K` (directly, or transitively through a
1595/// record's fields) to `demoted`. Recurses through every type constructor so
1596/// a `Map` buried inside `List<Map<…>>`, a `Result`/`Option` payload, a
1597/// tuple, etc. is still found.
1598fn collect_map_key_carriers(
1599    ty: &crate::ast::Type,
1600    candidates: &HashSet<String>,
1601    record_fields: &HashMap<String, Vec<String>>,
1602    demoted: &mut HashSet<String>,
1603) {
1604    use crate::ast::Type;
1605    match ty {
1606        Type::Map(key, value) => {
1607            carriers_reachable_from(key, candidates, record_fields, &mut HashSet::new(), demoted);
1608            // The KEY is the hazard; recurse into both so a nested Map in
1609            // either position is still inspected.
1610            collect_map_key_carriers(key, candidates, record_fields, demoted);
1611            collect_map_key_carriers(value, candidates, record_fields, demoted);
1612        }
1613        Type::Result(a, b) => {
1614            collect_map_key_carriers(a, candidates, record_fields, demoted);
1615            collect_map_key_carriers(b, candidates, record_fields, demoted);
1616        }
1617        Type::Option(a) | Type::List(a) | Type::Vector(a) => {
1618            collect_map_key_carriers(a, candidates, record_fields, demoted);
1619        }
1620        Type::Tuple(items) => {
1621            for t in items {
1622                collect_map_key_carriers(t, candidates, record_fields, demoted);
1623            }
1624        }
1625        Type::Fn(params, ret, _) => {
1626            for p in params {
1627                collect_map_key_carriers(p, candidates, record_fields, demoted);
1628            }
1629            collect_map_key_carriers(ret, candidates, record_fields, demoted);
1630        }
1631        _ => {}
1632    }
1633}
1634
1635/// Collect every candidate carrier reachable from a Map-KEY type `key`:
1636/// `key` itself if it names a carrier, plus (transitively) any carrier among
1637/// the fields of a record `key` names. `seen` guards self-referential
1638/// records.
1639fn carriers_reachable_from(
1640    key: &crate::ast::Type,
1641    candidates: &HashSet<String>,
1642    record_fields: &HashMap<String, Vec<String>>,
1643    seen: &mut HashSet<String>,
1644    demoted: &mut HashSet<String>,
1645) {
1646    use crate::ast::Type;
1647    let Some(name) = key.named_name() else {
1648        // A Map key that is itself a container (`Map<List<Carrier>, V>`) —
1649        // chase the inner element types too.
1650        match key {
1651            Type::Option(a) | Type::List(a) | Type::Vector(a) => {
1652                carriers_reachable_from(a, candidates, record_fields, seen, demoted);
1653            }
1654            Type::Tuple(items) => {
1655                for t in items {
1656                    carriers_reachable_from(t, candidates, record_fields, seen, demoted);
1657                }
1658            }
1659            Type::Map(k, v) => {
1660                carriers_reachable_from(k, candidates, record_fields, seen, demoted);
1661                carriers_reachable_from(v, candidates, record_fields, seen, demoted);
1662            }
1663            Type::Result(a, b) => {
1664                carriers_reachable_from(a, candidates, record_fields, seen, demoted);
1665                carriers_reachable_from(b, candidates, record_fields, seen, demoted);
1666            }
1667            _ => {}
1668        }
1669        return;
1670    };
1671    if !seen.insert(name.to_string()) {
1672        return;
1673    }
1674    if candidates.contains(name) {
1675        demoted.insert(name.to_string());
1676    }
1677    if let Some(fields) = record_fields.get(name) {
1678        for field_ty in fields {
1679            let parsed = crate::types::parse_type_str(field_ty);
1680            carriers_reachable_from(&parsed, candidates, record_fields, seen, demoted);
1681        }
1682    }
1683}
1684
1685/// Walk `analyze_plans(inputs)` and populate `ProofIR.fn_contracts`.
1686///
1687/// Translation pass over the classifier output (`RecursionPlan`) —
1688/// no re-implementation. The diff test (`tests/proof_ir_diff.rs`)
1689/// pins what each `RecursionPlan` variant lowers to so divergence
1690/// between the classifier and the IR populator surfaces there.
1691/// Coverage today: `IntCountdownGuarded`, `LinearRecurrence2`,
1692/// `Sized*` (length / sizeOf / string-pos / int-ascending). Fuel-
1693/// only and Mutual* plans don't materialise as `FnContract` (their
1694/// recursion shape doesn't need IR-level pre-decisions; backends
1695/// emit fuel scaffolding inline).
1696pub fn populate_fn_contracts(inputs: &ProofLowerInputs, ir: &mut ProofIR) {
1697    // Round-5 finding: walk per-scope so two modules each with a
1698    // recursive `foo` (or entry + module both declaring `foo`)
1699    // don't collide on the bare-name `plans: HashMap<String, _>`.
1700    // Aver's module DAG invariant rules out cross-module recursion
1701    // SCCs, so per-scope classification is the canonical view and
1702    // each `Module.fn` gets its own slot in `ir.fn_contracts`.
1703    for scope in inputs.scopes() {
1704        let (plans, issues) =
1705            crate::codegen::recursion::analyze_plans_in_scope(inputs, scope.as_deref(), false);
1706        ir.unclassified_fns
1707            .extend(issues.into_iter().map(|issue| crate::ir::UnclassifiedFn {
1708                line: issue.line,
1709                message: issue.message,
1710            }));
1711        populate_fn_contracts_for_scope(inputs, ir, scope.as_deref(), &plans);
1712    }
1713}
1714
1715fn populate_fn_contracts_for_scope(
1716    inputs: &ProofLowerInputs,
1717    ir: &mut ProofIR,
1718    scope: Option<&str>,
1719    plans: &HashMap<String, RecursionPlan>,
1720) {
1721    let scoped_fns: Vec<&FnDef> = inputs.pure_fns_in_scope(scope);
1722    let qualify = |bare: &str| -> crate::ir::FnKey {
1723        match scope {
1724            Some(prefix) => crate::ir::FnKey::in_module(prefix.to_string(), bare),
1725            None => crate::ir::FnKey::entry(bare),
1726        }
1727    };
1728    // Contracts key by opaque `FnId`; SymbolTable is always present
1729    // (pipeline builds it unconditionally, `ProofLowerInputs.symbol_
1730    // table: &SymbolTable`).
1731    let symbols = inputs.symbol_table;
1732
1733    for (fn_name, plan) in plans {
1734        let Some(fd) = scoped_fns.iter().find(|fd| fd.name == *fn_name) else {
1735            continue;
1736        };
1737        let fn_key = qualify(fn_name);
1738        let Some(canonical_key) = symbols.fn_id_of(&fn_key) else {
1739            continue;
1740        };
1741
1742        // IntCountdown — fuel-encoded countdown on a single Int param.
1743        // Distinct from IntCountdownGuarded: external callers may pass
1744        // negatives (the classifier rejected closed-world status), so
1745        // backends emit a fuel helper with `n.natAbs + 1` initial fuel
1746        // rather than a native def with a precondition.
1747        if let RecursionPlan::IntCountdown { param_index } = plan {
1748            if let Some((param_name, _)) = fd.params.get(*param_index) {
1749                ir.fn_contracts.insert(
1750                    canonical_key,
1751                    FnContract {
1752                        source_name: fn_name.clone(),
1753                        recursion: Some(RecursionContract::Fuel {
1754                            fuel_metric: crate::ir::FuelMetric::NatAbsPlusOne {
1755                                param: param_name.clone(),
1756                            },
1757                        }),
1758                    },
1759                );
1760            }
1761            continue;
1762        }
1763
1764        // IntFloorDivCountdown — guard-validated literal-divisor
1765        // floor-division shrink. The classifier proved both
1766        // side-conditions (every self-call shrinks the param through
1767        // `Result.withDefault(Int.div(p, k), d)` with literal k >= 2,
1768        // and every self-call site's guard chain implies `p >= 1`),
1769        // so backends emit a native well-founded def on `p.toNat`.
1770        if let RecursionPlan::IntFloorDivCountdown {
1771            param_index,
1772            divisor,
1773            helper_fn,
1774        } = plan
1775        {
1776            if let Some((param_name, _)) = fd.params.get(*param_index) {
1777                ir.fn_contracts.insert(
1778                    canonical_key,
1779                    FnContract {
1780                        source_name: fn_name.clone(),
1781                        recursion: Some(RecursionContract::WellFoundedToNat {
1782                            param: param_name.clone(),
1783                            floor_div: Some(crate::ir::FloorDivShrink {
1784                                divisor: *divisor,
1785                                helper_fn: helper_fn.clone(),
1786                            }),
1787                        }),
1788                    },
1789                );
1790            }
1791            continue;
1792        }
1793
1794        // IntAscending — fuel formula `(bound - n).natAbs + 1`. The
1795        // bound stays as `Spanned<Expr>` so backends render it through
1796        // their own emitters (it can be a literal, a fn param, or a
1797        // small arith expression).
1798        if let RecursionPlan::IntAscending { param_index, bound } = plan {
1799            if let Some((param_name, _)) = fd.params.get(*param_index) {
1800                ir.fn_contracts.insert(
1801                    canonical_key,
1802                    FnContract {
1803                        source_name: fn_name.clone(),
1804                        recursion: Some(RecursionContract::Fuel {
1805                            fuel_metric: crate::ir::FuelMetric::BoundMinusParamNatAbsPlusOne {
1806                                param: param_name.clone(),
1807                                bound: inputs.resolve_expr(bound, scope),
1808                            },
1809                        }),
1810                    },
1811                );
1812            }
1813            continue;
1814        }
1815
1816        // ListStructural — structural recursion on a List<_> param.
1817        // Lean/Dafny don't actually use a fuel helper for this on
1818        // recent backends (structural recursion is natively
1819        // terminating); the metric stays as `SeqLenPlusOne` for
1820        // backend-symmetric framing, and the consumer ignores it
1821        // when emitting plain structural recursion.
1822        if let RecursionPlan::ListStructural { param_index } = plan {
1823            if let Some((param_name, _)) = fd.params.get(*param_index) {
1824                ir.fn_contracts.insert(
1825                    canonical_key,
1826                    FnContract {
1827                        source_name: fn_name.clone(),
1828                        recursion: Some(RecursionContract::Fuel {
1829                            fuel_metric: crate::ir::FuelMetric::SeqLenPlusOne {
1830                                param: param_name.clone(),
1831                            },
1832                        }),
1833                    },
1834                );
1835            }
1836            continue;
1837        }
1838
1839        // SizeOfStructural — recursion on a user ADT (e.g. an AST
1840        // type). Fuel metric `sizeOf(call_frame) + 1`. The classifier
1841        // doesn't pin a single bound param — `sizeOf` measures the
1842        // whole frame — so the IR variant carries no param name.
1843        if matches!(plan, RecursionPlan::SizeOfStructural) {
1844            ir.fn_contracts.insert(
1845                canonical_key,
1846                FnContract {
1847                    source_name: fn_name.clone(),
1848                    recursion: Some(RecursionContract::Fuel {
1849                        fuel_metric: crate::ir::FuelMetric::SizeOfPlusOne,
1850                    }),
1851                },
1852            );
1853            continue;
1854        }
1855
1856        // StringPosAdvance — `(s, pos)`-shape recursion: `s` invariant
1857        // (first param, String), `pos` advances (second param, Int).
1858        // Fuel formula `s.length - pos`.
1859        if matches!(plan, RecursionPlan::StringPosAdvance) {
1860            if let (Some((string_param, _)), Some((pos_param, _))) =
1861                (fd.params.first(), fd.params.get(1))
1862            {
1863                ir.fn_contracts.insert(
1864                    canonical_key,
1865                    FnContract {
1866                        source_name: fn_name.clone(),
1867                        recursion: Some(RecursionContract::Fuel {
1868                            fuel_metric: crate::ir::FuelMetric::StringLenMinusPos {
1869                                string_param: string_param.clone(),
1870                                pos_param: pos_param.clone(),
1871                            },
1872                        }),
1873                    },
1874                );
1875            }
1876            continue;
1877        }
1878
1879        // Mutual-recursion SCCs — each member of the SCC gets its own
1880        // plan with the same family. All three lower to a Lex fuel
1881        // metric; the params vector + rank distinguish per-shape /
1882        // per-member roles.
1883        //
1884        // - MutualIntCountdown: every member counts down its first
1885        //   Int param; rank stays 0 (no inter-member ranking — every
1886        //   edge decreases the shared dimension).
1887        // - MutualStringPosAdvance { rank }: (s, pos) shape across
1888        //   the SCC; rank distinguishes members for same-measure
1889        //   inter-fn edges.
1890        // - MutualSizeOfRanked { rank }: sizeOf measures the whole
1891        //   call frame; rank distinguishes members. No bound param —
1892        //   the empty params vec signals "frame-level measure".
1893        match plan {
1894            RecursionPlan::MutualIntCountdown => {
1895                let params = fd
1896                    .params
1897                    .first()
1898                    .map(|(n, _)| vec![n.clone()])
1899                    .unwrap_or_default();
1900                ir.fn_contracts.insert(
1901                    canonical_key,
1902                    FnContract {
1903                        source_name: fn_name.clone(),
1904                        recursion: Some(RecursionContract::Fuel {
1905                            fuel_metric: crate::ir::FuelMetric::Lex { params, rank: 0 },
1906                        }),
1907                    },
1908                );
1909                continue;
1910            }
1911            RecursionPlan::MutualStringPosAdvance { rank } => {
1912                let params = fd.params.iter().take(2).map(|(n, _)| n.clone()).collect();
1913                ir.fn_contracts.insert(
1914                    canonical_key,
1915                    FnContract {
1916                        source_name: fn_name.clone(),
1917                        recursion: Some(RecursionContract::Fuel {
1918                            fuel_metric: crate::ir::FuelMetric::Lex {
1919                                params,
1920                                rank: *rank,
1921                            },
1922                        }),
1923                    },
1924                );
1925                continue;
1926            }
1927            RecursionPlan::MutualSizeOfRanked { rank } => {
1928                ir.fn_contracts.insert(
1929                    canonical_key,
1930                    FnContract {
1931                        source_name: fn_name.clone(),
1932                        recursion: Some(RecursionContract::Fuel {
1933                            fuel_metric: crate::ir::FuelMetric::Lex {
1934                                params: Vec::new(),
1935                                rank: *rank,
1936                            },
1937                        }),
1938                    },
1939                );
1940                continue;
1941            }
1942            RecursionPlan::LinearRecurrence2 => {
1943                ir.fn_contracts.insert(
1944                    canonical_key,
1945                    FnContract {
1946                        source_name: fn_name.clone(),
1947                        recursion: Some(RecursionContract::LinearRecurrence2),
1948                    },
1949                );
1950                continue;
1951            }
1952            _ => {}
1953        }
1954
1955        let RecursionPlan::IntCountdownGuarded {
1956            param_index,
1957            base_arm_literal,
1958            base_arm_body,
1959            wildcard_arm_body,
1960            precondition,
1961        } = plan
1962        else {
1963            continue;
1964        };
1965        let Some((countdown_param_name, _)) = fd.params.get(*param_index) else {
1966            continue;
1967        };
1968
1969        let precondition_predicates: Vec<Predicate> = precondition
1970            .iter()
1971            .map(|clause| Predicate {
1972                free_vars: vec![(
1973                    countdown_param_name.clone(),
1974                    QuantifierType::Plain("Int".to_string()),
1975                )],
1976                expr: inputs.resolve_expr(clause, scope),
1977            })
1978            .collect();
1979
1980        ir.fn_contracts.insert(
1981            canonical_key,
1982            FnContract {
1983                source_name: fn_name.clone(),
1984                recursion: Some(RecursionContract::Native {
1985                    precondition: precondition_predicates,
1986                    measure: Measure::NatAbsInt {
1987                        param: countdown_param_name.clone(),
1988                    },
1989                    preservation: PreservationProof::IntCountdownLiteralZero,
1990                    decrease: DecreaseProof::NatAbsCountdown,
1991                    body: NativeIntCountdownBody {
1992                        base_arm_literal: *base_arm_literal,
1993                        base_arm_body: inputs.resolve_expr(base_arm_body, scope),
1994                        wildcard_arm_body: inputs.resolve_expr(wildcard_arm_body, scope),
1995                    },
1996                }),
1997            },
1998        );
1999    }
2000}
2001
2002/// Walk every verify block, lift `VerifyKind::Law` entries into
2003/// `ProofIR.law_theorems`.
2004///
2005/// Extracts the law's shape (quantifiers from `givens`, premises
2006/// from `when`, claim from `lhs == rhs`) and pins a `ProofStrategy`
2007/// via [`classify_law_strategy`]. Covered strategies: Reflexive,
2008/// Commutative / Associative / IdentityElement / AntiCommutative /
2009/// UnaryEqualsBinary (arithmetic wrappers), Induction (recursive
2010/// ADTs), LibraryAxiom (Map set/get), MapUpdatePostcondition,
2011/// MapKeyTrackedIncrement, SpecEquivalence{,SimpNormalized},
2012/// LinearIntSpecEquivalence, EffectfulSpecEquivalence (with Oracle
2013/// Lift), LinearArithmetic (catch-all over an unfold chain).
2014/// Unmatched shapes pin `BackendDispatch` and fall through to the
2015/// backend's residual chain (linear_recurrence2 emit + sampled /
2016/// guarded-domain fallback).
2017pub fn populate_law_theorems(inputs: &ProofLowerInputs, ir: &mut ProofIR) {
2018    use crate::ast::{TopLevel, VerifyKind};
2019    use crate::ir::{LawTheorem, Predicate, Quantifier, QuantifierType};
2020
2021    let symbols = inputs.symbol_table;
2022
2023    // Verify-law blocks to lower, each tagged with the prefix of the
2024    // dep module that owns it (`None` = entry). The entry's own verify
2025    // blocks come from `entry_items`; dependency modules' proven laws
2026    // come from `ModuleInfo.verify_laws` (the cross-file law pool — a
2027    // dep law is lowered with the SAME strategy classification an entry
2028    // law of that shape gets, so it auto-proves and can be cited by a
2029    // consumer). The DAG invariant keeps the bare fn name unambiguous
2030    // within each scope.
2031    let entry_verifies = inputs.entry_items.iter().filter_map(|item| match item {
2032        TopLevel::Verify(vb) => Some((None, vb)),
2033        _ => None,
2034    });
2035    let dep_verifies = inputs.dep_modules.iter().flat_map(|m| {
2036        m.verify_laws
2037            .iter()
2038            .map(move |vb| (Some(m.prefix.as_str()), vb))
2039    });
2040    for (owning_prefix, vb) in entry_verifies.chain(dep_verifies) {
2041        let VerifyKind::Law(law) = &vb.kind else {
2042            continue;
2043        };
2044
2045        let quantifiers: Vec<Quantifier> = law
2046            .givens
2047            .iter()
2048            .map(|g| Quantifier {
2049                name: g.name.clone(),
2050                binder_type: QuantifierType::Plain(g.type_name.clone()),
2051            })
2052            .collect();
2053
2054        // The fn this law targets, keyed by its owning scope. For an
2055        // entry law the bare name resolves to an entry `FnId`; for a
2056        // dep law it resolves through `FnKey::in_module(prefix, name)`.
2057        // When the fn isn't in the symbol table (verify block targeting
2058        // a fn that doesn't exist), skip the law silently — the
2059        // typechecker / verify-driver surfaces the missing target
2060        // elsewhere.
2061        let target_key = match owning_prefix {
2062            Some(prefix) => crate::ir::FnKey::in_module(prefix.to_string(), &vb.fn_name),
2063            None => crate::ir::FnKey::entry(&vb.fn_name),
2064        };
2065        let Some(fn_id) = symbols.fn_id_of(&target_key) else {
2066            continue;
2067        };
2068
2069        // Scope for resolving the law's expressions: derived from the
2070        // target fn's owning module, NOT hardcoded to entry.
2071        let law_scope: Option<String> = symbols
2072            .fn_entry(fn_id)
2073            .key
2074            .scope_str()
2075            .map(|s| s.to_string());
2076        let law_scope_ref = law_scope.as_deref();
2077
2078        let premises: Vec<Predicate> = match &law.when {
2079            Some(when_expr) => vec![Predicate {
2080                free_vars: quantifiers
2081                    .iter()
2082                    .map(|q| (q.name.clone(), q.binder_type.clone()))
2083                    .collect(),
2084                expr: inputs.resolve_expr(when_expr, law_scope_ref),
2085            }],
2086            None => Vec::new(),
2087        };
2088
2089        let strategy = classify_law_strategy(
2090            law,
2091            &vb.fn_name,
2092            inputs,
2093            &ir.refined_types,
2094            &ir.fn_contracts,
2095            law_scope_ref,
2096        );
2097
2098        ir.law_theorems.push(LawTheorem {
2099            fn_id,
2100            law_name: law.name.clone(),
2101            quantifiers,
2102            premises,
2103            claim_lhs: inputs.resolve_expr(&law.lhs, law_scope_ref),
2104            claim_rhs: inputs.resolve_expr(&law.rhs, law_scope_ref),
2105            strategy,
2106        });
2107    }
2108
2109    // Demand-driven well-founded graduation for the floor-division
2110    // window family: the figures' proof templates rest on the
2111    // power-of-two fn's defining equations and functional-induction
2112    // principle, which the fuel encoding destroys (the fuel arg on
2113    // the recursive call differs from the callee's own measure, so
2114    // nothing universal is provable through `__fuel`). Upgrade the
2115    // cited pow fn's contract from `Fuel { NatAbsPlusOne }` to the
2116    // native `WellFoundedToNat` form (`floor_div: None` — the guarded
2117    // subtractive countdown whose `n <= 0` base guard puts `n >= 1`
2118    // in the decreasing goal's context, so `omega` closes the
2119    // measure bare). Scoped on purpose: a pow-shaped fn in a file
2120    // with no recognized window law keeps its established fuel
2121    // emission, so nothing outside the family moves.
2122    let window_pow_fns: HashSet<String> = ir
2123        .law_theorems
2124        .iter()
2125        .filter_map(|t| match &t.strategy {
2126            crate::ir::ProofStrategy::FloorDivWindow { figure } => Some(match figure {
2127                crate::ir::FloorWindowFigure::PowPositive { pow_fn } => pow_fn.clone(),
2128                crate::ir::FloorWindowFigure::PowSumSplit { pow_fn } => pow_fn.clone(),
2129                crate::ir::FloorWindowFigure::SigWindow { pow_fn, .. } => pow_fn.clone(),
2130                crate::ir::FloorWindowFigure::ProductWindow { pow_fn, .. } => pow_fn.clone(),
2131            }),
2132            _ => None,
2133        })
2134        .collect();
2135    for pow_fn in window_pow_fns {
2136        let Some(fn_id) = symbols.fn_id_of(&crate::ir::FnKey::entry(&pow_fn)) else {
2137            continue;
2138        };
2139        let Some(contract) = ir.fn_contracts.get_mut(&fn_id) else {
2140            continue;
2141        };
2142        if let Some(crate::ir::RecursionContract::Fuel {
2143            fuel_metric: crate::ir::FuelMetric::NatAbsPlusOne { param },
2144        }) = &contract.recursion
2145        {
2146            contract.recursion = Some(crate::ir::RecursionContract::WellFoundedToNat {
2147                param: param.clone(),
2148                floor_div: None,
2149            });
2150        }
2151    }
2152}
2153
2154/// Pick the strategy `LawLower` should pin on a `(fn, law)` pair.
2155///
2156/// Decision order — specific algebraic properties first, then
2157/// generic linear-arithmetic catch-all, then `BackendDispatch`:
2158/// 1. `Reflexive` — `law.lhs ≡ law.rhs` syntactically.
2159/// 2. `Commutative { op }` — fn body is `a <op> b`, claim is
2160///    `f(a, b) = f(b, a)` (op restricted to commutative ones).
2161/// 3. `Associative { op }` — same body, 3 givens, assoc claim.
2162/// 4. `IdentityElement { op }` — `f(a, e) = a` (or `f(e, a) = a`),
2163///    where `e` is the op's identity. Covers Add/Mul both-sided
2164///    plus Sub right-sided.
2165/// 5. `AntiCommutative { op: Sub, neg_on_rhs }` — `f(a, b) =
2166///    -f(b, a)` form. Sub-only (Mul has no anti-commutative law).
2167/// 6. `UnaryEqualsBinary { inner_fn }` — outer fn is unary, claim
2168///    binds it to the inner binary fn at a constant.
2169/// 7. `LinearArithmetic { unfold_fns, ... }` — catch-all when the
2170///    law reduces to linear arith after unfolding the call chain.
2171/// 8. `EnumConstantFold { unfold_fns }` — ground law over fixed
2172///    enum/ADT constructor args, scalar return (#466).
2173/// 9. `FiniteDomainCases { givens }` — every given ranges over a
2174///    closed finite domain (Bool / fieldless enum, product ≤ 16);
2175///    closes by exhaustive `cases` enumeration.
2176/// 10. `RingIdentity { unfold_fns }` — unconditional ring identity
2177///     over Int-component records (cross-multiplication equality);
2178///     runs before the prelude-simp rung, which would otherwise claim
2179///     the shape and park it on a caught sorry.
2180/// 11. `IntDecimalRoundtrip { … }` — canonical decimal-Int
2181///     parse/serialize roundtrip over a recognized string-pos scanner;
2182///     runs before the prelude-simp rung, which would otherwise claim
2183///     the shape and park it on a caught sorry.
2184/// 12. `SimpOverPreludeLemmas { … }` — builtin-roundtrip shape; the
2185///     Lean backend renders it AFTER its legacy chain, so it fires
2186///     exactly where the bare-`sorry` universal used to.
2187/// 13. `BackendDispatch` — backend's ad-hoc chain decides.
2188///
2189/// (The induction/spec-equivalence/Map families detected between
2190/// these rungs are documented at their detector sites below.)
2191fn classify_law_strategy(
2192    law: &crate::ast::VerifyLaw,
2193    fn_name: &str,
2194    inputs: &ProofLowerInputs,
2195    refined_types: &std::collections::HashMap<crate::ir::TypeId, crate::ir::RefinedTypeDecl>,
2196    fn_contracts: &std::collections::HashMap<crate::ir::FnId, crate::ir::FnContract>,
2197    scope: Option<&str>,
2198) -> crate::ir::ProofStrategy {
2199    use crate::ir::ProofStrategy;
2200
2201    // Result-pipeline chain equivalence (stage 8b of #232) — `?`
2202    // propagation `chain_qm(x)` vs nested-match `chain_manual(x)`.
2203    // Both sides unfold to the same nested match; the proof closes
2204    // by `unfold + repeat split`.
2205    if law.when.is_none()
2206        && let Some(s) = detect_result_pipeline_chain_equivalence(law, fn_name, inputs)
2207    {
2208        return s;
2209    }
2210    // Wrapper-over-recursion with monoidal accumulator (stage 8 of
2211    // #232) — runs before generic induction because its aux-lemma
2212    // template closes laws naive induction can't (e.g. `sum(xs) ==
2213    // sumDirect(xs)` where `sum(xs) = sumTR(xs, 0)`). Detected
2214    // when `fn_name` is registered as a `WrapperOverRecursion`
2215    // pattern in `ProgramShape` AND the law shape is
2216    // `wrapper(g) == other(g)` AND the inner fn body matches the
2217    // monoidal-accumulator template.
2218    if law.when.is_none()
2219        && let Some(s) = detect_wrapper_over_recursion(law, fn_name, inputs)
2220    {
2221        return s;
2222    }
2223    // Tail-recursive fold with a FIXED base param (TIP prop_35,
2224    // `exp x y = qexp x y one`). The 2-given inline-wrapper shape the
2225    // `WrapperOverRecursion` recognizer can't reach: a 3-arg loop whose
2226    // extra leading param is held fixed and whose combine multiplies the
2227    // accumulator by that fixed param. Emits the accumulator-generalization
2228    // lemma plus the main universal law.
2229    if law.when.is_none()
2230        && let Some(s) = detect_tailrec_fixed_base_fold(law, inputs)
2231    {
2232        return s;
2233    }
2234    // Structural induction runs first — when any given binds a
2235    // recursive ADT, induction over its variants is the canonical
2236    // proof. Reflexive could also fire on `f(t) = f(t)` for `t: Tree`
2237    // but induction subsumes (one trivial case per variant) and is
2238    // the legacy chain's first pick. `when` clauses block induction
2239    // — a non-closing `when` law would emit a 2-arm induction ladder
2240    // (2 sorries) instead of the bounded sampled-domain fallback,
2241    // regressing output cleanliness; a non-regressing when-aware
2242    // induction path is a follow-up.
2243    if law.when.is_none()
2244        && let Some(param) = detect_induction_target(law, inputs)
2245    {
2246        return ProofStrategy::Induction { param };
2247    }
2248    if law.lhs == law.rhs {
2249        return ProofStrategy::Reflexive;
2250    }
2251    // Binary-wrapper-shaped laws first. `wrapper_binop` returns
2252    // `None` for non-binary fns — unary wrappers are tried after
2253    // this block falls through.
2254    if let Some(op) = wrapper_binop(fn_name, inputs) {
2255        if detect_wrapper_commutative(law, fn_name, op) {
2256            return ProofStrategy::Commutative { op };
2257        }
2258        if detect_wrapper_associative(law, fn_name, op) {
2259            return ProofStrategy::Associative { op };
2260        }
2261        if detect_wrapper_identity(law, fn_name, op) {
2262            return ProofStrategy::IdentityElement { op };
2263        }
2264        // Sub right-identity collapses into IdentityElement —
2265        // same emit (`simp [fn]`), different lhs/rhs shape. The
2266        // detector validates the right-side `f(a, 0) = a` form
2267        // (`f(0, a) = -a` doesn't equal `a`, so Sub is one-sided).
2268        if matches!(op, crate::ast::BinOp::Sub) && detect_wrapper_sub_right_identity(law, fn_name) {
2269            return ProofStrategy::IdentityElement { op };
2270        }
2271        // Anti-commutative is Sub-specific (Add/Mul are
2272        // commutative, no anti-commutativity). The op tag keeps
2273        // it parameterised even though only Sub currently fires.
2274        if matches!(op, crate::ast::BinOp::Sub)
2275            && let Some(neg_on_rhs) = detect_wrapper_sub_anti_commutative(law, fn_name)
2276        {
2277            return ProofStrategy::AntiCommutative { op, neg_on_rhs };
2278        }
2279    }
2280    // Unary fn equal to binary fn at a constant — `fn_name` is the
2281    // unary outer; the binary fn name is captured for backends.
2282    if let Some(inner_fn) = detect_wrapper_unary_equivalence(law, fn_name, inputs) {
2283        return ProofStrategy::UnaryEqualsBinary { inner_fn };
2284    }
2285    // Library axiom instances — Map.has-after-set, Map.get-after-set.
2286    // Specific shape, single-line `simpa using axiom` emit on Lean.
2287    if let Some((axiom, args)) = detect_map_set_axiom(law) {
2288        let resolved_args: Vec<_> = args.iter().map(|a| inputs.resolve_expr(a, scope)).collect();
2289        return ProofStrategy::LibraryAxiom {
2290            axiom,
2291            args: resolved_args,
2292        };
2293    }
2294    // Tracked-counter increment: specialised body template + `+ 1`
2295    // rhs. Checked before the more general MapUpdatePostcondition so
2296    // the tighter strategy wins for this shape.
2297    if let Some(inc) = detect_map_key_tracked_increment(law, fn_name, inputs) {
2298        return ProofStrategy::MapKeyTrackedIncrement {
2299            outer_fn: inc.outer_fn,
2300            map_arg: inputs.resolve_expr(&inc.map_arg, scope),
2301            key_arg: inputs.resolve_expr(&inc.key_arg, scope),
2302        };
2303    }
2304    // Post-condition of an inline-defined map-update fn — case-split
2305    // over `Map.get m k` and apply the `Map.set` axioms.
2306    if let Some(post) = detect_map_update_postcondition(law, fn_name, inputs) {
2307        return ProofStrategy::MapUpdatePostcondition {
2308            outer_fn: post.outer_fn,
2309            kind: post.kind,
2310            map_arg: inputs.resolve_expr(&post.map_arg, scope),
2311            key_arg: inputs.resolve_expr(&post.key_arg, scope),
2312            extra_unfolds: post.extra_unfolds,
2313        };
2314    }
2315    // Functional equivalence of `vb.fn_name` and a same-named spec
2316    // fn whose body is syntactically identical to the impl's.
2317    if let Some(extra_unfolds) = detect_spec_equivalence(law, fn_name, inputs) {
2318        return ProofStrategy::SpecEquivalence { extra_unfolds };
2319    }
2320    // Broader spec equivalence — bodies differ syntactically but
2321    // normalize to same under substitution + arithmetic identity
2322    // folding. Runs after the strict `SpecEquivalence` so the
2323    // tighter detector wins when both would match.
2324    if let Some(extra_unfolds) = detect_simp_normalized_spec_equivalence(law, fn_name, inputs) {
2325        return ProofStrategy::SpecEquivalenceSimpNormalized { extra_unfolds };
2326    }
2327    // Linear-Int spec equivalence — substituted bodies are pure
2328    // linear arithmetic over Int givens; decided by `omega` / LIA.
2329    if let Some((unfolded_impl, unfolded_spec)) =
2330        detect_linear_int_spec_equivalence(law, fn_name, inputs)
2331    {
2332        return ProofStrategy::LinearIntSpecEquivalence {
2333            unfolded_impl: inputs.resolve_expr(&unfolded_impl, scope),
2334            unfolded_spec: inputs.resolve_expr(&unfolded_spec, scope),
2335        };
2336    }
2337    // Effectful counterpart — Oracle Lift normalises both sides
2338    // (oracle args injected into impl call) and the lowerer matches
2339    // the canonical `impl(args) == spec(args)` shape on the
2340    // rewritten form. Fires on real oracle-spec laws like
2341    // `pickPair() => pairSpec(BranchPath.Root, rnd)`.
2342    if let Some(spec_fn) = detect_effectful_spec_equivalence(law, fn_name, inputs) {
2343        return ProofStrategy::EffectfulSpecEquivalence {
2344            impl_fn: fn_name.to_string(),
2345            spec_fn,
2346        };
2347    }
2348    // Second-order linear recurrence (fib / fibSpec shape). Detector
2349    // validates impl as tail-rec wrapper, spec as direct second-order
2350    // recurrence, helper as their shared affine worker — all three
2351    // shapes pinned in `lean::recurrence`. Backends consume the
2352    // (impl_fn, spec_fn, helper_fn) names from IR; the proof template
2353    // differs per target (Lean Nat-helper + induction; Dafny still
2354    // pending — issue #116).
2355    if let Some((spec_fn, helper_fn)) =
2356        detect_linear_recurrence2_spec_equivalence(law, fn_name, inputs)
2357    {
2358        return ProofStrategy::LinearRecurrence2SpecEquivalence {
2359            impl_fn: fn_name.to_string(),
2360            spec_fn,
2361            helper_fn,
2362        };
2363    }
2364    // Linear arithmetic over an unfold chain — generic catch-all.
2365    // Named for the semantic, not the backend tactic.
2366    if let Some(plan) = detect_simp_omega_unfold(law, fn_name, inputs, refined_types) {
2367        return ProofStrategy::LinearArithmetic {
2368            unfold_fns: plan.unfold_fns,
2369            wrapper_return: plan.wrapper_return,
2370            smart_guard: plan.smart_guard,
2371            lifted: plan.lifted,
2372        };
2373    }
2374    // Ground constant-fold over fixed ADT/enum constructors — the
2375    // last typed fallback before `BackendDispatch`. Fires only for the
2376    // narrow shape no earlier detector accepts: a non-recursive fn with
2377    // ≥1 non-Int param, whose every non-Int param is pinned to a
2378    // constructor literal at the law's call site(s). LinearArithmetic
2379    // rejected it (non-Int param), Induction rejected it (no recursive
2380    // ADT given) — so this can't steal a law another strategy owns.
2381    if law.when.is_none()
2382        && let Some(unfold_fns) = detect_enum_constant_fold(law, fn_name, inputs)
2383    {
2384        return ProofStrategy::EnumConstantFold { unfold_fns };
2385    }
2386    // Closed finite-domain enumeration — the final typed fallback
2387    // before `BackendDispatch`. Fires when EVERY given ranges over a
2388    // closed, small domain (Bool or an all-fieldless user enum, ≤ 16
2389    // total combinations): exhaustive `cases` over the givens yields
2390    // ground goals per leaf, so deliberately NO call-shape inspection,
2391    // NO return-type gate and NO recursion gate — closed enumeration
2392    // makes those irrelevant (fuel-wrapped callees compute through
2393    // constant-measure constructor args). That is exactly why this is
2394    // a NEW detector and not a relaxation of `EnumConstantFold`, whose
2395    // literal-pinning / non-recursive / scalar-return gates are
2396    // load-bearing for its simp cascade.
2397    if law.when.is_none()
2398        && let Some(givens) = detect_finite_domain_cases(law, inputs)
2399    {
2400        return ProofStrategy::FiniteDomainCases { givens };
2401    }
2402    // Unconditional ring identity over Int-component records — runs
2403    // BEFORE the prelude-simp rung because that rung would otherwise
2404    // claim the shape (record givens, non-recursive pure cone) and
2405    // park it on a caught sorry: its minimal simp set has no AC-ring
2406    // normalization, and the permutational package this strategy
2407    // emits cannot be added there (it would loop or destroy the
2408    // normal forms other strategies rely on). Every earlier rung has
2409    // already declined: LinearArithmetic rejects non-Int record
2410    // givens, EnumConstantFold needs constructor-literal-pinned
2411    // params, FiniteDomainCases needs closed finite domains — so the
2412    // pin cannot steal a law a cheaper strategy closes today.
2413    if law.when.is_none()
2414        && let Some(unfold_fns) = detect_ring_identity(law, fn_name, inputs)
2415    {
2416        return ProofStrategy::RingIdentity { unfold_fns };
2417    }
2418    // Decimal-Int parse/serialize roundtrip — runs BEFORE the prelude-
2419    // simp rung because that rung would otherwise claim the shape (the
2420    // lhs cone is fuel-wrapped with measure-closed args) and park it on
2421    // a caught sorry the scanner barrier guarantees. The detector
2422    // validates the ENTIRE canonical parser shape (head-char dispatch
2423    // arms, single recognized scanner, slice + `Int.fromString` leaf),
2424    // so it cannot fire on the #469 prelude-simp laws (`finishInt` /
2425    // `finishNumber` / `afterIntChar` / `finishString` — wrong arity or
2426    // non-literal second arg at the law call site).
2427    if law.when.is_none()
2428        && let Some(s) = detect_int_decimal_roundtrip(law, fn_name, inputs, fn_contracts)
2429    {
2430        return s;
2431    }
2432    // Escaped-string parse/serialize roundtrip — the string-escape
2433    // sibling of the decimal roundtrip above, and like it placed
2434    // BEFORE the prelude-simp rung, which would otherwise claim the
2435    // shape (fuel-wrapped lhs cone) and park it on a caught sorry.
2436    // The detector validates the ENTIRE producer/consumer pair
2437    // (classifier escape table aligned arm-by-arm with the consumer's
2438    // escape dispatcher, control-escape prefix, threshold agreement,
2439    // fuel contracts), so it cannot fire on any shape whose
2440    // synthesized suffix-invariant proof would not close.
2441    if law.when.is_none()
2442        && let Some(s) = detect_string_escape_roundtrip(law, inputs, fn_contracts)
2443    {
2444        return s;
2445    }
2446    // Floor-division window family — laws over a power-of-two fn, a
2447    // guard-validated floor-halving binary-exponent fn, and the
2448    // window predicates built from them. The detectors are
2449    // deliberately narrow (exactly the hand-validated figures —
2450    // pow positivity, the pow sum homomorphism, the significand
2451    // window, the product window) and key on structure plus the
2452    // exponent fn's `WellFoundedToNat` contract, never on names.
2453    // Runs after every cheaper rung declined: LinearArithmetic
2454    // rejects the `Result.withDefault` cone and recursive callees,
2455    // Induction needs a recursive-ADT given, EnumConstantFold /
2456    // FiniteDomainCases need non-Int / closed domains — so the pin
2457    // cannot steal a law another strategy closes today.
2458    if let Some(figure) = detect_floor_window(law, fn_name, inputs, fn_contracts) {
2459        return ProofStrategy::FloorDivWindow { figure };
2460    }
2461    // Builtin-roundtrip simp over the prelude's spec-lemma registry —
2462    // the very last typed fallback. The Lean backend deliberately
2463    // renders this strategy AFTER its whole legacy ad-hoc chain (see
2464    // `lean::law_auto`), so pinning it here cannot steal a law any
2465    // legacy fallback closes today: it fires exactly where the
2466    // sampled-sorry path used to emit a bare-`sorry` universal.
2467    if law.when.is_none()
2468        && let Some(s) = detect_simp_over_prelude_lemmas(law, fn_name, inputs, fn_contracts)
2469    {
2470        return s;
2471    }
2472    ProofStrategy::BackendDispatch
2473}
2474
2475mod finite_domain;
2476mod floor_window;
2477mod induction;
2478mod int_decimal_roundtrip;
2479mod map_laws;
2480mod refinement;
2481mod ring;
2482mod simp;
2483mod spec_equivalence;
2484mod string_escape_roundtrip;
2485mod wrapper_laws;
2486
2487pub(crate) use induction::LawProofCone;
2488
2489use finite_domain::*;
2490use floor_window::*;
2491use induction::*;
2492use int_decimal_roundtrip::*;
2493use map_laws::*;
2494use refinement::*;
2495use ring::*;
2496use simp::*;
2497use spec_equivalence::*;
2498use string_escape_roundtrip::*;
2499use wrapper_laws::*;