aver-lang 0.26.0

VM and transpiler for Aver, a statically-typed language designed for AI-assisted development
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
use std::collections::{HashMap, HashSet};

use super::expr::aver_name_to_lean;
use super::fn_def::{emit_fn_body_for, lower_pure_question_bang_for_emit};
use super::is_pure_fn;
use super::render::{emit_doc_comment, emit_fn_params, ret_type_or_unit};
use crate::ast::*;
use crate::codegen::CodegenContext;

// ---------------------------------------------------------------------------
// Termination-as-a-law: length-monotonicity WF for computed-arg mutual SCCs.
//
// A mutual SCC like quicksort's `sort` / `sortWithPivot` recurses through
// COMPUTED list arguments (`sort (smallerOrEqual rest pivot)`) that Lean's
// structural recursion can't see decrease. Instead of a fuel encoding, this
// path synthesises + KERNEL-PROVES a length-monotonicity lemma for each
// partition helper (`(g s …).length ≤ s.length`) and emits a genuine
// well-founded `mutual` block whose per-def `decreasing_by` cites the lemma.
//
// Soundness rides on the Lean kernel: each `…_len_le` lemma is proved by the
// validated `induction … split … List.length_cons; omega` tactic, or the SCC
// is rejected and the caller falls back to fuel. A lemma that doesn't hold
// (helper grows the list) makes `lake build` fail rather than slip through.
// ---------------------------------------------------------------------------

/// How a recursive call's measure-list argument relates to the caller's
/// measure-list param.
#[derive(Clone)]
enum LexListArg {
    /// The argument is the caller's measure param itself or a `cons`-tail
    /// subterm of it — the structural-subterm case. Tie-break by rank.
    Subterm {
        /// `true` when the argument is a strict subterm (the cons-tail),
        /// i.e. the callee receives `caller_len - 1`. `false` when it is
        /// the param verbatim (callee receives `caller_len`).
        strict: bool,
    },
    /// The argument is `g(s, …)` where `g` is a non-growing structural
    /// list filter and `s` is the caller's measure param or its cons-tail.
    /// `helper` is the Aver fn name of `g`; `lemma_args` is the Lean source
    /// of `g`'s actual arguments (`rest pivot`), used to instantiate the
    /// synthesised `<g>_len_le` lemma in `decreasing_by`.
    Filtered { helper: String, lemma_args: String },
}

/// One recognised recursive edge inside the SCC, in body source order
/// (the order Lean generates `decreasing_by` goals).
struct LexListEdge {
    callee: String,
    arg: LexListArg,
}

/// A length lemma to synthesise: `(<helper> xs <rest…>).length ≤ xs.length`.
#[derive(Clone)]
struct LengthLemma {
    /// Lean name of the filter helper.
    helper_lean: String,
    /// Lean param list of the helper (`(xs : List Int) (pivot : Int)`).
    params: String,
    /// Lean names of the helper's params, in order.
    param_names: Vec<String>,
}

impl LengthLemma {
    fn lemma_name(&self) -> String {
        format!("{}_len_le", self.helper_lean)
    }
}

/// Per-fn plan for the lex-list WF mutual block.
struct LexListMemberPlan<'a> {
    fd: &'a FnDef,
    /// Lean name of the measure list param.
    list_param_lean: String,
    /// `+offset` added to `list_param.length` in the lex first component.
    offset: usize,
    /// Lex second component (rank).
    rank: usize,
    /// Recognised recursive edges, in body source order.
    edges: Vec<LexListEdge>,
}

/// Pull the single measure list param (a `List<_>` param) of a fn, as
/// (param index, Lean name). Returns `None` when the fn has zero or more
/// than one `List<_>` param (the synthesiser only reasons about a single
/// list measure) or any non-`List` sizeOf-relevant param.
fn lex_list_measure_param(fd: &FnDef, ctx: &CodegenContext) -> Option<(usize, String)> {
    let resolved_fd = crate::codegen::common::fn_id_for_decl(ctx, fd)
        .and_then(|id| ctx.resolved_program.fn_by_id(id));
    let resolved_owned = match resolved_fd {
        Some(_) => None,
        None => Some(ctx.resolve_fn_def(fd, None)),
    };
    let rfd: &crate::ir::hir::ResolvedFnDef =
        resolved_fd.unwrap_or_else(|| resolved_owned.as_ref().unwrap().as_ref());
    let mut found: Option<(usize, String)> = None;
    for (idx, (name, ty)) in rfd.params.iter().enumerate() {
        match ty {
            crate::types::Type::List(_) => {
                if found.is_some() {
                    // More than one List param — outside the single-measure
                    // shape this synthesiser reasons about.
                    return None;
                }
                found = Some((idx, aver_name_to_lean(name)));
            }
            // Any other sizeOf-relevant param (Vector / String / recursive
            // ADT) means the measure isn't a single List length — back off.
            crate::types::Type::Vector(_) | crate::types::Type::Str => return None,
            _ => {}
        }
    }
    found
}

/// True iff `g` is a non-growing structural list filter: a recursive fn
/// `(xs : List _, …) -> List _` whose body matches on its first list param
/// with `[] -> []` and `[x, ..rest] -> <if-cond> { x :: g(rest, …) | g(rest, …) }`.
/// This is exactly the shape the validated
/// `induction … split … List.length_cons; omega` proof closes, giving
/// `(g xs …).length ≤ xs.length`. Anything else returns `None`.
fn lex_list_filter_helper<'a>(helper: &'a FnDef, ctx: &CodegenContext) -> Option<&'a FnDef> {
    // First param must be `List<_>`, and the fn must return `List<_>`.
    let resolved_fd = crate::codegen::common::fn_id_for_decl(ctx, helper)
        .and_then(|id| ctx.resolved_program.fn_by_id(id));
    let resolved_owned = match resolved_fd {
        Some(_) => None,
        None => Some(ctx.resolve_fn_def(helper, None)),
    };
    let rfd: &crate::ir::hir::ResolvedFnDef =
        resolved_fd.unwrap_or_else(|| resolved_owned.as_ref().unwrap().as_ref());
    let first_param = rfd.params.first()?;
    if !matches!(first_param.1, crate::types::Type::List(_)) {
        return None;
    }
    if !matches!(rfd.return_type, crate::types::Type::List(_)) {
        return None;
    }
    let list_param_name = first_param.0.clone();

    // Body must be a single tail expression: `match <list_param> { … }`.
    let tail = helper.body.tail_expr()?;
    if helper.body.stmts().len() != 1 {
        return None;
    }
    let Expr::Match { subject, arms } = &tail.node else {
        return None;
    };
    // Subject must be the (first) list param.
    let subject_name = crate::codegen::recursion::detect::local_name_of(subject)?;
    if subject_name != list_param_name {
        return None;
    }
    let mut saw_nil_empty = false;
    let mut saw_cons_filter = false;
    for arm in arms {
        match &arm.pattern {
            // `[] -> []`
            Pattern::EmptyList if matches!(&arm.body.node, Expr::List(items) if items.is_empty()) =>
            {
                saw_nil_empty = true;
            }
            Pattern::Cons(head, tail_bind)
                if lex_filter_cons_arm_ok(&arm.body, helper, head, tail_bind) =>
            {
                saw_cons_filter = true;
            }
            // Any other arm (non-`[]` nil body, non-filter cons arm,
            // literal / wildcard / ctor) breaks the recognised shape.
            _ => return None,
        }
    }
    (saw_nil_empty && saw_cons_filter).then_some(helper)
}

/// A `cons`-arm body of a filter helper: `<bool-match> { true -> head :: g(tail, …) ; false -> g(tail, …) }`
/// (either branch may be the keep / drop side). Both branches must be
/// either `g(tail, …)` or `head :: g(tail, …)` — i.e. each recursive call
/// passes the cons-tail and prepends at most one element. That guarantees
/// `(g xs …).length ≤ xs.length` under the validated proof.
fn lex_filter_cons_arm_ok(
    body: &Spanned<Expr>,
    helper: &FnDef,
    head: &str,
    tail_bind: &str,
) -> bool {
    match &body.node {
        // `match <cond> { true -> …; false -> … }` over a Bool subject.
        Expr::Match { arms, .. } => arms
            .iter()
            .all(|arm| lex_filter_branch_ok(&arm.body, helper, head, tail_bind)),
        // A direct branch (no inner conditional) — accept the same leaf
        // shapes so a `[x, ..rest] -> g(rest, …)` arm is recognised too.
        _ => lex_filter_branch_ok(body, helper, head, tail_bind),
    }
}

/// Leaf of a filter cons-arm: `g(tail, …)` or `head :: g(tail, …)`
/// (`List.prepend(head, g(tail, …))`). The recursive call must target the
/// helper itself and pass the cons-tail in the first (list) position.
fn lex_filter_branch_ok(body: &Spanned<Expr>, helper: &FnDef, head: &str, tail_bind: &str) -> bool {
    // A recursive call on the cons-tail — `g(tail, …)`. The TCO pass may
    // have rewritten a tail-position self-call into `Expr::TailCall`, so
    // accept both shapes. Self-identity uses the shared
    // `canonical_callee_name` helper (syntax-discovery-only) so the
    // suffix-match category rule lives in one place.
    let self_set: HashSet<String> = std::iter::once(helper.name.clone()).collect();
    let recur_on_tail = |target: &str, args: &[Spanned<Expr>]| -> bool {
        let is_self =
            crate::codegen::recursion::detect::canonical_callee_name(target, &self_set).is_some();
        is_self
            && args
                .first()
                .and_then(crate::codegen::recursion::detect::local_name_of)
                .filter(|n| *n == tail_bind)
                .is_some()
    };
    match &body.node {
        Expr::FnCall(callee, args) => {
            let name = crate::codegen::recursion::detect::expr_to_dotted_name(callee);
            match name.as_deref() {
                // `head :: g(tail, …)` → encoded as `List.prepend(head, recur)`.
                Some("List.prepend") if args.len() == 2 => {
                    crate::codegen::recursion::detect::local_name_of(&args[0])
                        .filter(|n| *n == head)
                        .is_some()
                        && lex_filter_branch_ok(&args[1], helper, head, tail_bind)
                }
                // `g(tail, …)` — drop-element branch.
                Some(n) => recur_on_tail(n, args),
                None => false,
            }
        }
        // TCO'd tail-position self-call: `g(tail, …)`.
        Expr::TailCall(boxed) => recur_on_tail(&boxed.target, &boxed.args),
        _ => false,
    }
}

/// Lean source of a recursive call's measure argument, classified.
/// `caller_list_param` is the caller's measure param name; `tail_binders`
/// is the set of cons-tail binders of a match on that param in the caller
/// body; `scc` is the SCC member names; `ctx` resolves filter helpers.
fn classify_lex_list_arg(
    arg: &Spanned<Expr>,
    caller_list_param: &str,
    tail_binders: &HashSet<String>,
    ctx: &CodegenContext,
) -> Option<LexListArg> {
    use crate::codegen::recursion::detect::{expr_to_dotted_name, local_name_of};
    // Subterm: the param itself or a cons-tail of it.
    if let Some(name) = local_name_of(arg) {
        if name == caller_list_param {
            return Some(LexListArg::Subterm { strict: false });
        }
        if tail_binders.contains(name) {
            return Some(LexListArg::Subterm { strict: true });
        }
        return None;
    }
    // Filtered: `g(s, …)` where `s` is the param or its cons-tail and `g`
    // is a non-growing list filter defined in the program.
    if let Expr::FnCall(callee, args) = &arg.node {
        let dotted = expr_to_dotted_name(callee)?;
        let bare = dotted.rsplit('.').next().unwrap_or(&dotted).to_string();
        let first = args.first()?;
        let s = local_name_of(first)?;
        let s_ok = s == caller_list_param || tail_binders.contains(s);
        if !s_ok {
            return None;
        }
        let helper_fd = find_user_fn_by_name(ctx, &bare)?;
        // Kernel-provable non-growing filter shape required.
        lex_list_filter_helper(helper_fd, ctx)?;
        // Render the call's actual args as Lean — these instantiate the
        // synthesised `<g>_len_le` lemma in `decreasing_by`.
        let lemma_args = args
            .iter()
            .map(|a| super::expr::emit_expr_legacy(a, ctx, None))
            .collect::<Vec<_>>()
            .join(" ");
        return Some(LexListArg::Filtered {
            helper: bare,
            lemma_args,
        });
    }
    None
}

/// Locate a user fn def by bare name across entry + dep-module scopes.
fn find_user_fn_by_name<'a>(ctx: &'a CodegenContext, name: &str) -> Option<&'a FnDef> {
    ctx.fn_defs
        .iter()
        .chain(ctx.modules.iter().flat_map(|m| m.fn_defs.iter()))
        .find(|fd| fd.name == name)
}

/// Recognise a mutual SCC whose every recursive call decreases either by a
/// structural subterm (cons-tail) or through a non-growing list filter,
/// and assign each member a lex measure `(list_param.length + offset, rank)`
/// that discharges every obligation. Returns the per-member plans + the
/// distinct length lemmas to synthesise, or `None` to back off to fuel.
fn recognize_lex_list_wf_scc<'a>(
    fns: &'a [&'a FnDef],
    ctx: &CodegenContext,
) -> Option<(Vec<LexListMemberPlan<'a>>, Vec<LengthLemma>)> {
    let names: HashSet<String> = fns.iter().map(|fd| fd.name.clone()).collect();
    if fns.len() < 2 {
        // Single-fn "mutual" SCCs are emitted by the self-recursive path,
        // not here; this synthesiser targets genuine mutual blocks.
        return None;
    }

    // Per-fn body-derived classified edges (list param + tail binders are
    // consumed inline to classify each edge; only the edges survive).
    struct Raw<'a> {
        fd: &'a FnDef,
        edges: Vec<LexListEdge>,
    }
    let mut raws: Vec<Raw<'a>> = Vec::new();
    for fd in fns {
        if !is_pure_fn(fd) {
            return None;
        }
        let (_idx, list_param) = lex_list_measure_param(fd, ctx)?;
        let tail_binders =
            crate::codegen::recursion::detect::collect_list_tail_binders(fd, &list_param);
        let mut edges: Vec<LexListEdge> = Vec::new();
        for (callee_raw, args) in
            crate::codegen::recursion::detect::collect_calls_from_body(fd.body.as_ref())
        {
            let Some(callee) =
                crate::codegen::recursion::detect::canonical_callee_name(&callee_raw, &names)
            else {
                continue;
            };
            // The callee's measure-list arg position (its single List param).
            let callee_fd = fns.iter().find(|f| f.name == callee)?;
            let (callee_list_idx, _) = lex_list_measure_param(callee_fd, ctx)?;
            let arg = args.get(callee_list_idx)?;
            let classified = classify_lex_list_arg(arg, &list_param, &tail_binders, ctx)?;
            edges.push(LexListEdge {
                callee,
                arg: classified,
            });
        }
        if edges.is_empty() {
            // A member with no intra-SCC recursive call isn't part of the
            // recursion this synthesiser models.
            return None;
        }
        raws.push(Raw { fd, edges });
    }

    // Offset propagation along subterm edges: a strict cons-tail edge f→c
    // makes `len_c = len_f - 1`, so `off_c = off_f + 1` keeps the lex first
    // component equal and the rank tie-break carries the decrease. A
    // non-strict subterm edge (param verbatim) keeps `off_c = off_f`.
    // Filter edges must end up STRICTLY lower in offset (their length only
    // weakly shrinks, so the first component must drop via offset).
    let mut offset: HashMap<String, usize> = HashMap::new();
    offset.insert(raws[0].fd.name.clone(), 0);
    // Fixed-point propagation (SCC has ≤ small node count).
    let mut changed = true;
    let mut iterations = 0;
    while changed {
        changed = false;
        iterations += 1;
        if iterations > raws.len() * raws.len() + 4 {
            // Non-convergent (cyclic strict subterm path) — back off.
            return None;
        }
        for raw in &raws {
            let Some(&off_f) = offset.get(&raw.fd.name) else {
                continue;
            };
            for edge in &raw.edges {
                if let LexListArg::Subterm { strict } = &edge.arg {
                    let want = if *strict { off_f + 1 } else { off_f };
                    match offset.get(&edge.callee) {
                        Some(&existing) if existing == want => {}
                        Some(_) => return None, // conflicting offset → back off
                        None => {
                            offset.insert(edge.callee.clone(), want);
                            changed = true;
                        }
                    }
                }
            }
        }
        // Seed any node not yet reached by a subterm edge with offset 0.
        for raw in &raws {
            if !offset.contains_key(&raw.fd.name) {
                offset.insert(raw.fd.name.clone(), 0);
                changed = true;
            }
        }
    }

    // Every filter edge f→c must satisfy `off_c < off_f` so the first
    // component strictly decreases (filter only proves `≤`, never `<`).
    for raw in &raws {
        let off_f = offset[&raw.fd.name];
        for edge in &raw.edges {
            if matches!(edge.arg, LexListArg::Filtered { .. }) {
                let off_c = offset[&edge.callee];
                if off_c >= off_f {
                    return None;
                }
            }
        }
    }

    // Ranks: subterm edges must tie-break, so a strict subterm edge f→c
    // needs `rank_c < rank_f`. Assign `rank = max_off - off` so a `+1`
    // offset step (strict subterm) lowers the rank by exactly 1.
    let max_off = offset.values().copied().max().unwrap_or(0);
    let mut plans: Vec<LexListMemberPlan<'a>> = Vec::new();
    for raw in raws {
        let off = offset[&raw.fd.name];
        let rank = max_off - off;
        let (_idx, list_param_lean) = lex_list_measure_param(raw.fd, ctx)?;
        plans.push(LexListMemberPlan {
            fd: raw.fd,
            list_param_lean,
            offset: off,
            rank,
            edges: raw.edges,
        });
    }

    // Validate the rank tie-break for every subterm edge.
    let rank_of: HashMap<String, usize> =
        plans.iter().map(|p| (p.fd.name.clone(), p.rank)).collect();
    for plan in &plans {
        for edge in &plan.edges {
            if let LexListArg::Subterm { strict } = &edge.arg {
                if *strict {
                    // strict subterm: equal first component, need rank to drop.
                    if rank_of[&edge.callee] >= plan.rank {
                        return None;
                    }
                } else {
                    // non-strict subterm (param verbatim, no shrink at all):
                    // first component equal AND rank equal would loop. We
                    // can't prove termination on a no-shrink self/peer call.
                    return None;
                }
            }
        }
    }

    // Distinct length lemmas referenced by any filter edge.
    let mut lemmas: Vec<LengthLemma> = Vec::new();
    let mut seen: HashSet<String> = HashSet::new();
    for plan in &plans {
        for edge in &plan.edges {
            if let LexListArg::Filtered { helper, .. } = &edge.arg {
                if !seen.insert(helper.clone()) {
                    continue;
                }
                let helper_fd = find_user_fn_by_name(ctx, helper)?;
                let helper_lean = aver_name_to_lean(helper);
                let params = emit_fn_params(&helper_fd.params);
                let param_names: Vec<String> = helper_fd
                    .params
                    .iter()
                    .map(|(n, _)| aver_name_to_lean(n))
                    .collect();
                lemmas.push(LengthLemma {
                    helper_lean,
                    params,
                    param_names,
                });
            }
        }
    }

    Some((plans, lemmas))
}

/// Emit the kernel-proved length-monotonicity lemma for one filter helper:
/// `theorem <h>_len_le : ∀ <params>, (<h> <args>).length ≤ <list_param>.length`.
/// The proof is the validated `induction … split … List.length_cons; omega`.
fn emit_length_lemma(lemma: &LengthLemma) -> String {
    let first = &lemma.param_names[0];
    let rest_args = lemma.param_names[1..].join(" ");
    let call = if rest_args.is_empty() {
        format!("{} {}", lemma.helper_lean, first)
    } else {
        format!("{} {} {}", lemma.helper_lean, first, rest_args)
    };
    // `∀`-quantify exactly the helper's params, in declared order.
    let forall_binders = lemma.params.clone();
    let intro_names = lemma.param_names.join(" ");
    let mut lines = Vec::new();
    lines.push(format!(
        "theorem {} : ∀ {}, ({}).length ≤ {}.length := by",
        lemma.lemma_name(),
        forall_binders,
        call,
        first
    ));
    lines.push(format!("  intro {}", intro_names));
    lines.push(format!("  induction {} with", first));
    lines.push(format!("  | nil => simp [{}]", lemma.helper_lean));
    lines.push(format!(
        "  | cons x rest ih => simp only [{}]; split",
        lemma.helper_lean
    ));
    lines.push("                      · simp only [List.length_cons]; omega".to_string());
    lines.push("                      · simp only [List.length_cons]; omega".to_string());
    lines.join("\n")
}

/// Emit a genuine well-founded mutual block for a length-monotonicity SCC:
/// the synthesised + kernel-proved length lemmas, then the `mutual … end`
/// block with per-def lex `termination_by (list.length + offset, rank)` and
/// `decreasing_by` clauses citing the lemmas. Returns `None` (back off to
/// fuel) when the SCC isn't recognised as length-monotone-WF.
pub(super) fn emit_native_mutual_lex_list_wf_group(
    fns: &[&FnDef],
    ctx: &CodegenContext,
) -> Option<String> {
    // NB: unlike `emit_native_mutual_sizeof_group`, this path does NOT gate
    // on `scc_has_growing_accumulator` — a computed filter arg
    // (`smallerOrEqual rest pivot`) trips that conservative check, and
    // proving it shrinks via a synthesised length lemma is exactly the job
    // here. The classifier below rejects anything it can't prove.
    let (plans, lemmas) = recognize_lex_list_wf_scc(fns, ctx)?;

    let mut lines: Vec<String> = Vec::new();
    // Kernel-proved length lemmas first — they reference the filter helpers
    // emitted earlier in topological order, and are cited by `decreasing_by`.
    for lemma in &lemmas {
        lines.push(emit_length_lemma(lemma));
        lines.push(String::new());
    }

    lines.push("mutual".to_string());
    for plan in &plans {
        let fd = plan.fd;
        let fn_name = aver_name_to_lean(&fd.name);
        let params = emit_fn_params(&fd.params);
        let ret_type = ret_type_or_unit(fd);
        let lowered = lower_pure_question_bang_for_emit(fd);
        let body_fn = lowered.as_ref().unwrap_or(fd);
        let body_ast = lowered
            .as_ref()
            .map(|l| l.body.as_ref())
            .unwrap_or(fd.body.as_ref());
        let body = emit_fn_body_for(body_fn, body_ast, ctx);

        lines.extend(
            emit_doc_comment(&fd.desc)
                .into_iter()
                .map(|line| format!("  {line}")),
        );
        lines.push(format!("  def {} {} : {} :=", fn_name, params, ret_type));
        for body_line in body.lines() {
            lines.push(format!("  {body_line}"));
        }
        let measure_first = if plan.offset == 0 {
            format!("{}.length", plan.list_param_lean)
        } else {
            format!("{}.length + {}", plan.list_param_lean, plan.offset)
        };
        lines.push(format!(
            "  termination_by ({}, {})",
            measure_first, plan.rank
        ));
        // One `decreasing_by` goal per recursive call, in body source order.
        lines.push("  decreasing_by".to_string());
        for edge in &plan.edges {
            match &edge.arg {
                LexListArg::Subterm { .. } => {
                    // Equal first component, decrease lives in the rank — a
                    // `Prod.Lex` goal omega can't discharge directly.
                    lines.push("    · simp_wf; exact Prod.Lex.right _ (by omega)".to_string());
                }
                LexListArg::Filtered { helper, lemma_args } => {
                    let lemma_name = format!("{}_len_le", aver_name_to_lean(helper));
                    // Cite the lemma at exactly the call's arguments so omega
                    // can relate `(g rest pivot).length` to `rest.length`.
                    lines.push(format!(
                        "    · simp_wf; have := {} {}; omega",
                        lemma_name, lemma_args
                    ));
                }
            }
        }
        lines.push(String::new());
    }
    lines.push("end".to_string());
    Some(lines.join("\n"))
}