Skip to main content

aver/codegen/lemma_discovery/
mod.rs

1//! Lemma discovery — the "locksmith" (Phase 2 of the charter,
2//! `prompts/lemma-discovery.md`).
3//!
4//! Where the legacy `AccumulatorRoundtrip` recognizer was a *key* cut for one
5//! lock (it fires on exactly `rle.av`), this is the *locksmith*: a pass that
6//! discovers the auxiliary lemmas an inductive proof needs, proves them, and
7//! emits them as explicit checkable artifacts. The full pipeline is:
8//!
9//! ```text
10//!   LawProofCone  ─►  typed-term enumerator  ─►  VM-filter  ─►  backend-prove  ─►  commit
11//!   (scope: pure       (small equations over     (Aver VM as    (Lean = truth,     (named .lean/
12//!    fns + ADTs)         the cone, bounded by      test oracle,   Dafny = regression) .dfy + manifest)
13//!                        term SIZE) + LLM          conservative
14//!                        conjecturer (guarded)     on overflow)
15//! ```
16//!
17//! The cone (built by `LawProofCone::compute`) is the differentiator: the
18//! compiler already knows a law's scope, so the enumerator gets
19//! goal-direction *for free* — external tools (HipSpec/CCLemma/…) must
20//! reconstruct scope at cost.
21//!
22//! # What's implemented here (Phase 2a → 2e)
23//!
24//! The type-directed term enumerator, candidate generator, VM-filter, the Lean
25//! theorem rendering the CLI kernel-checks, and the discovery-surface hash that
26//! keys committed-lemma replay:
27//!
28//! 1. A **typed variable context** — a small fixed pool of variables (up to
29//!    [`MAX_VARS_PER_TYPE`] per distinct parameter type the cone fns range
30//!    over). Sharing one context across both sides of an equation is what
31//!    lets `decode(a ++ b)` and `decode(a) ++ decode(b)` mention the *same*
32//!    `a`, `b`.
33//! 2. **Bottom-up term enumeration** over {cone pure fns, the `List.concat`
34//!    builtin, the variables}, bounded by term **size** (node count, not
35//!    arity × depth) up to [`MAX_TERM_SIZE`], deduplicated by rendering.
36//! 3. **Candidate equations** — every pair of distinct, same-type terms that
37//!    share the same free-variable set (see [`conjectures_from_terms`] for
38//!    why that pruning, and what it deliberately does not yet reach).
39//! 4. **VM-filter** ([`vm_filter`]) — runs both sides of each candidate on the
40//!    Aver VM over sample variable assignments and drops counterexamples.
41//!    Conservative (an eval error / out-of-guard `Int` never refutes), so a
42//!    backend-true lemma is never wrongly dropped.
43//!
44//! 5. **Lean theorem rendering** ([`lean_lemma_theorem`], [`rank_candidate_indices`])
45//!    — a survivor with a list free variable becomes a theorem with the
46//!    list-induction template; the CLI (`aver proof --discover`) appends it to
47//!    the generated Lean project and `lake build`s it (proved ⟺ exit 0). This
48//!    is the proved-or-dropped gate (2d).
49//!
50//! 6. **Discover-once / replay** ([`discovery_surface_hash`]) — proved lemmas
51//!    are committed by the CLI as a reviewable `DiscoveredLemmas.lean` tagged
52//!    with the surface hash; on a re-run with an unchanged surface the CLI
53//!    REPLAYS (re-verifies the committed lemmas via `lake build`) instead of
54//!    re-enumerating. Re-verification — not the hash — is the soundness guard.
55//!
56//! 7. **Feedback into the law's own proof** ([`committed`] — the
57//!    `ProofStrategy::SimpOverLemmas` hook): on a NORMAL `aver proof` run the
58//!    CLI parses a committed `DiscoveredLemmas.lean` (hash-gated for
59//!    staleness only), re-pins each in-scope `Induction` law to
60//!    `SimpOverLemmas(names)`, and the Lean backend embeds the lemma texts
61//!    before the law theorem (re-proving them in the same `lake build`) and
62//!    simps over their names — so a law that NEEDS a discovered auxiliary
63//!    lemma closes under normal `aver proof` after one `--discover` run.
64//!
65//! Entry [`run_discovery`] (+ [`vm_filter`], + the CLI prove/replay step) is
66//! invoked by `aver proof --discover`; normal `aver proof` never enumerates
67//! (discovery is the explicit, cached step — it only CONSUMES the committed
68//! artifact via step 7).
69
70use std::collections::{BTreeMap, BTreeSet, HashSet};
71
72use crate::ast::{TopLevel, VerifyKind};
73use crate::codegen::proof_lower::{LawProofCone, ProofLowerInputs};
74use crate::nan_value::{NanValue, NanValueConvert};
75use crate::types::Type;
76use crate::value::Value;
77
78/// Variables minted per distinct cone parameter type. Two is the smallest
79/// count that makes distributivity-shaped lemmas (`f(a ++ b) = f a ++ f b`,
80/// two vars of one type) reachable.
81const MAX_VARS_PER_TYPE: usize = 2;
82/// Largest term (node count) the enumerator builds. The `decode_append`
83/// right-hand side `List.concat(decode(a), decode(b))` is size 5, so this is
84/// the minimum that surfaces the Phase-2 acceptance lemma.
85const MAX_TERM_SIZE: usize = 5;
86/// Safety cap on total enumerated terms — discovery is the expensive step but
87/// must still terminate predictably on a large cone. Hitting it is recorded
88/// in [`DiscoveryStats::terms_truncated`] (charter: no silent caps).
89const MAX_TERMS: usize = 20_000;
90/// Safety cap on generated candidate equations. Recorded in
91/// [`DiscoveryStats::conjectures_truncated`] when hit.
92const MAX_CONJECTURES: usize = 20_000;
93/// Work cap on the O(bucket²) candidate-pairing scan. Bounds `--discover`
94/// time on large cones (e.g. json's ~68-fn cones) independently of how many
95/// candidates are actually emitted — filtered (different-free-var) pairs
96/// still cost a comparison, so the output cap alone doesn't bound the work.
97/// Recorded in [`DiscoveryStats::conjectures_truncated`] when hit.
98const MAX_PAIRS_EXAMINED: usize = 2_000_000;
99/// Cap on a single n-ary application's argument cartesian product, so one
100/// wide op over large term pools can't blow up before [`MAX_TERMS`] bites.
101/// Hitting it is folded into [`DiscoveryStats::terms_truncated`].
102const CARTESIAN_CAP: usize = 4_000;
103/// Cones with more pure fns than this skip enumeration entirely. Naive
104/// size-[`MAX_TERM_SIZE`] discovery over a very large cone (e.g. json's
105/// 60+-fn parser cones) is both slow and low-signal — the space is
106/// astronomically undersampled, and such subsystems are a separate problem
107/// (charter Phase 4). Recorded in [`DiscoveryStats::skipped_large_cone`];
108/// healthy cones (rle 6, quicksort ≤5, red-black-tree ≤10) stay well under it.
109const MAX_CONE_FNS: usize = 24;
110
111/// A free variable in a typed term — a source-renderable name plus its Aver
112/// type. `TermNode::Var(i)` refers to the binder at index `i` in the owning
113/// [`LawDiscovery`]'s shared `binders`.
114#[derive(Debug, Clone)]
115pub struct Binder {
116    pub name: String,
117    pub ty: Type,
118}
119
120/// A node in a typed term tree. `App.callee` is a cone pure-fn name or a
121/// builtin (`List.concat`); rendering is uniform `callee(arg, …)`.
122#[derive(Debug, Clone, PartialEq)]
123pub enum TermNode {
124    /// A bound variable, by index into the shared `binders`.
125    Var(usize),
126    /// Application of a cone fn or builtin op to typed args.
127    App { callee: String, args: Vec<TermNode> },
128}
129
130impl TermNode {
131    fn size(&self) -> usize {
132        match self {
133            TermNode::Var(_) => 1,
134            TermNode::App { args, .. } => 1 + args.iter().map(TermNode::size).sum::<usize>(),
135        }
136    }
137
138    fn render(&self, binders: &[Binder]) -> String {
139        match self {
140            TermNode::Var(i) => binders
141                .get(*i)
142                .map(|b| b.name.clone())
143                .unwrap_or_else(|| format!("?{i}")),
144            TermNode::App { callee, args } => {
145                let rendered: Vec<String> = args.iter().map(|a| a.render(binders)).collect();
146                format!("{callee}({})", rendered.join(", "))
147            }
148        }
149    }
150
151    fn free_vars(&self, out: &mut BTreeSet<usize>) {
152        match self {
153            TermNode::Var(i) => {
154                out.insert(*i);
155            }
156            TermNode::App { args, .. } => {
157                for a in args {
158                    a.free_vars(out);
159                }
160            }
161        }
162    }
163}
164
165/// An applicable operation in the enumeration vocabulary: a cone pure fn or a
166/// builtin, with its (monomorphic, instantiated) parameter and result types.
167#[derive(Debug, Clone)]
168struct Op {
169    callee: String,
170    params: Vec<Type>,
171    ret: Type,
172}
173
174/// A well-typed term built during enumeration, over the law's shared binders.
175#[derive(Debug, Clone)]
176struct EnumTerm {
177    node: TermNode,
178    ty: Type,
179}
180
181/// A candidate equation `lhs == rhs` (both `ty`-typed) over the shared
182/// binders. A *conjecture*, not a theorem — the charter's proved-or-dropped
183/// gate (VM-filter then kernel proof, 2c–2d) is what turns survivors into
184/// usable lemmas.
185#[derive(Debug, Clone)]
186pub struct Conjecture {
187    pub lhs: TermNode,
188    pub rhs: TermNode,
189    pub ty: Type,
190}
191
192impl Conjecture {
193    /// Source-shaped rendering, e.g.
194    /// `decode(List.concat(x2, x3)) == List.concat(decode(x2), decode(x3))`.
195    pub fn render(&self, binders: &[Binder]) -> String {
196        format!(
197            "{} == {}",
198            self.lhs.render(binders),
199            self.rhs.render(binders)
200        )
201    }
202}
203
204/// Coverage / truncation accounting for one law's discovery run.
205#[derive(Debug, Clone)]
206pub struct DiscoveryStats {
207    pub cone_fn_count: usize,
208    pub term_count: usize,
209    /// Candidate equations enumerated (2b), before the VM-filter.
210    pub conjecture_count: usize,
211    pub terms_truncated: bool,
212    pub conjectures_truncated: bool,
213    /// The cone exceeded [`MAX_CONE_FNS`]; enumeration was skipped entirely.
214    pub skipped_large_cone: bool,
215    /// The VM-filter (2c) ran for this law. When `true`, `conjectures` holds
216    /// only the survivors and `candidates_refuted` counts the rest.
217    pub vm_filtered: bool,
218    /// Candidates the VM-filter refuted (counterexample found on sample data).
219    pub candidates_refuted: usize,
220    pub max_term_size: usize,
221}
222
223/// A discovery report for one `verify ... law`: the cone summary, the shared
224/// variable context, and the enumerated candidate equations. Later phases
225/// extend this with VM-filter verdicts and proved lemmas.
226#[derive(Debug, Clone)]
227pub struct LawDiscovery {
228    /// The law's subject fn (`verify <fn> law <name>`); excluded from the cone.
229    pub subject_fn: String,
230    /// The law's name.
231    pub law_name: String,
232    /// The cone vocabulary — pure fns the enumerator may apply (sorted).
233    pub cone_fns: Vec<String>,
234    /// The cone type alphabet — user ADTs reachable from those fns (sorted).
235    pub cone_types: Vec<String>,
236    /// The shared typed variable context the conjectures range over.
237    pub binders: Vec<Binder>,
238    /// Candidate equations (size-ascending); after the VM-filter, survivors.
239    pub conjectures: Vec<Conjecture>,
240    /// Rendered equations kernel-proved by a backend (2d). Filled by the prove
241    /// step (which needs a `CodegenContext` + prover, so it lives in the CLI);
242    /// `run_discovery` leaves it empty.
243    pub proved: Vec<String>,
244    /// Coverage / truncation accounting.
245    pub stats: DiscoveryStats,
246}
247
248mod bricks;
249mod committed;
250mod enumerate;
251mod render;
252mod vm_filter;
253
254pub use bricks::structural_lemma_groups;
255pub use committed::{
256    CommittedLemma, SimpDirection, apply_simp_over_lemma_pins, forbidden_token_in_lemma,
257    mentioned_fns, parse_committed_lemmas, plan_simp_over_lemma_pins, simp_entries,
258    simp_orientation,
259};
260pub use enumerate::run_discovery;
261pub use render::{
262    discovery_surface_hash, lean_lemma_theorem, rank_candidate_indices, render_report,
263};
264pub use vm_filter::vm_filter;
265
266// Shared internal helper used across submodules (the report renderer in
267// `render` formats binder types via the enumerator's `render_type`).
268use enumerate::render_type;
269// Surfaced for the encoder-role detector test in `mod tests`.
270#[cfg(test)]
271use bricks::detect_encoders;
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::codegen::ModuleInfo;
277    use std::collections::HashSet;
278
279    /// Minimal RLE-shaped fixture: a `decode` recursor over `List<Run>` with
280    /// a transitive helper chain (`decode → expandRun → repeat`) and a
281    /// roundtrip law whose subject is `encode`. Exercises the cone's
282    /// fn-closure, type alphabet, and the enumerator + candidate generator.
283    const SRC: &str = r#"
284record Run
285    char: String
286    count: Int
287
288fn repeat(c: String, n: Int) -> List<String>
289    [c]
290
291fn expandRun(r: Run) -> List<String>
292    repeat(r.char, r.count)
293
294fn decode(runs: List<Run>) -> List<String>
295    match runs
296        [] -> []
297        [run, ..rest] -> List.concat(expandRun(run), decode(rest))
298
299fn encode(xs: List<String>) -> List<Run>
300    []
301
302verify encode law roundtrip
303    given xs: List<String> = [[], ["a"]]
304    decode(encode(xs)) => xs
305"#;
306
307    /// Build a `ProofLowerInputs` from source and run `f` on it. The full
308    /// lex→parse→tco→resolve pipeline runs so the VM-filter / oracle can compile
309    /// the cone fns; `LawProofCone::compute` works on the resolved AST (it
310    /// handles both `Ident` and `Resolved`).
311    fn with_inputs<R>(src: &str, f: impl FnOnce(&ProofLowerInputs) -> R) -> R {
312        let mut lexer = crate::lexer::Lexer::new(src);
313        let tokens = lexer.tokenize().expect("lex");
314        let mut items = crate::parser::Parser::new(tokens).parse().expect("parse");
315        crate::ir::pipeline::tco(&mut items);
316        crate::ir::pipeline::resolve(&mut items);
317        let symbols = crate::ir::SymbolTable::build(&items, &[]);
318        // Build the program shape (WrapperOverRecursion, …) so shape-anchored
319        // detection exercises the same path in tests as in the CLI (where the
320        // CodegenContext populates `program_shape`).
321        let resolved = crate::ir::hir::resolve_program(&symbols, &items);
322        let resolved_fns: Vec<&crate::ir::hir::ResolvedFnDef> = resolved
323            .iter()
324            .filter_map(|t| match t {
325                crate::ir::hir::ResolvedTopLevel::FnDef(fd) => Some(fd),
326                _ => None,
327            })
328            .collect();
329        // `analyze_program_with_modules` (not `analyze_program`) is what
330        // populates `patterns` (WrapperOverRecursion, …) — the entry-only
331        // variant leaves them empty.
332        let shape =
333            crate::analysis::shape::analyze_program_with_modules(&resolved_fns, &items, &[]);
334        let prefixes: HashSet<String> = HashSet::new();
335        let recursive: HashSet<crate::ir::FnId> = HashSet::new();
336        let no_modules: &[ModuleInfo] = &[];
337        let inputs = ProofLowerInputs {
338            entry_items: &items,
339            dep_modules: no_modules,
340            module_prefixes: &prefixes,
341            recursive_fns: &recursive,
342            symbol_table: &symbols,
343            program_shape: Some(&shape),
344        };
345        f(&inputs)
346    }
347
348    /// Enumerate (2b) AND VM-filter (2c).
349    fn discover(src: &str) -> Vec<LawDiscovery> {
350        with_inputs(src, |inputs| {
351            let mut reports = run_discovery(inputs);
352            vm_filter(&mut reports, inputs);
353            reports
354        })
355    }
356
357    fn rle_source() -> String {
358        std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/examples/data/rle.av"))
359            .expect("read rle.av")
360    }
361
362    fn tally_source() -> String {
363        std::fs::read_to_string(concat!(
364            env!("CARGO_MANIFEST_DIR"),
365            "/examples/data/tally.av"
366        ))
367        .expect("read tally.av")
368    }
369
370    fn drain_source() -> String {
371        std::fs::read_to_string(concat!(
372            env!("CARGO_MANIFEST_DIR"),
373            "/examples/data/drain.av"
374        ))
375        .expect("read drain.av")
376    }
377
378    fn scale_source() -> String {
379        std::fs::read_to_string(concat!(
380            env!("CARGO_MANIFEST_DIR"),
381            "/examples/data/scale.av"
382        ))
383        .expect("read scale.av")
384    }
385
386    fn twofield_source() -> String {
387        std::fs::read_to_string(concat!(
388            env!("CARGO_MANIFEST_DIR"),
389            "/examples/data/twofield.av"
390        ))
391        .expect("read twofield.av")
392    }
393
394    fn sparse_source() -> String {
395        std::fs::read_to_string(concat!(
396            env!("CARGO_MANIFEST_DIR"),
397            "/examples/data/sparse.av"
398        ))
399        .expect("read sparse.av")
400    }
401
402    fn sum_acc_source() -> String {
403        std::fs::read_to_string(concat!(
404            env!("CARGO_MANIFEST_DIR"),
405            "/examples/data/sum_acc.av"
406        ))
407        .expect("read sum_acc.av")
408    }
409
410    fn encoder_roles(src: &str) -> Vec<String> {
411        with_inputs(src, |inputs| {
412            detect_encoders(inputs)
413                .into_iter()
414                .map(|e| {
415                    format!(
416                        "{}/{}/{}/{}/{}/{}/{}",
417                        e.wrapper, e.inverse, e.loop_fn, e.finish, e.step, e.expand, e.var
418                    )
419                })
420                .collect()
421        })
422    }
423
424    /// Matches the clearly-FALSE `x == List.concat(x, x)` (either orientation):
425    /// a candidate the VM-filter must refute (a non-empty list ≠ itself
426    /// appended to itself).
427    fn is_self_concat_identity(c: &Conjecture) -> bool {
428        fn oriented(l: &TermNode, r: &TermNode) -> bool {
429            let TermNode::Var(x) = l else { return false };
430            let TermNode::App { callee, args } = r else {
431                return false;
432            };
433            callee == "List.concat"
434                && args.len() == 2
435                && matches!((&args[0], &args[1]), (TermNode::Var(a), TermNode::Var(b)) if a == x && b == x)
436        }
437        oriented(&c.lhs, &c.rhs) || oriented(&c.rhs, &c.lhs)
438    }
439
440    /// Structural matcher for the `decode_append` shape, in either orientation:
441    /// `decode(List.concat(a, b)) == List.concat(decode(a), decode(b))` with
442    /// `a`, `b` distinct variables.
443    fn is_decode_append(c: &Conjecture) -> bool {
444        fn oriented(l: &TermNode, r: &TermNode) -> bool {
445            // l = decode(List.concat(Var(a), Var(b)))
446            let TermNode::App {
447                callee: lc,
448                args: la,
449            } = l
450            else {
451                return false;
452            };
453            if lc != "decode" || la.len() != 1 {
454                return false;
455            }
456            let TermNode::App {
457                callee: cc,
458                args: ca,
459            } = &la[0]
460            else {
461                return false;
462            };
463            if cc != "List.concat" || ca.len() != 2 {
464                return false;
465            }
466            let (TermNode::Var(a), TermNode::Var(b)) = (&ca[0], &ca[1]) else {
467                return false;
468            };
469            if a == b {
470                return false;
471            }
472            // r = List.concat(decode(Var(a)), decode(Var(b)))
473            let TermNode::App {
474                callee: rc,
475                args: ra,
476            } = r
477            else {
478                return false;
479            };
480            if rc != "List.concat" || ra.len() != 2 {
481                return false;
482            }
483            let (
484                TermNode::App {
485                    callee: d1,
486                    args: r1,
487                },
488                TermNode::App {
489                    callee: d2,
490                    args: r2,
491                },
492            ) = (&ra[0], &ra[1])
493            else {
494                return false;
495            };
496            if d1 != "decode" || d2 != "decode" || r1.len() != 1 || r2.len() != 1 {
497                return false;
498            }
499            matches!((&r1[0], &r2[0]), (TermNode::Var(a2), TermNode::Var(b2)) if a2 == a && b2 == b)
500        }
501        oriented(&c.lhs, &c.rhs) || oriented(&c.rhs, &c.lhs)
502    }
503
504    #[test]
505    fn cone_excludes_subject_and_closes_over_pure_helpers() {
506        let reports = discover(SRC);
507        assert_eq!(reports.len(), 1);
508        let r = &reports[0];
509        assert_eq!(r.subject_fn, "encode");
510        assert_eq!(r.law_name, "roundtrip");
511        // `encode` (subject) is dropped; `decode` + its transitive pure
512        // helpers stay, sorted by name.
513        assert_eq!(r.cone_fns, vec!["decode", "expandRun", "repeat"]);
514    }
515
516    #[test]
517    fn cone_types_resolve_adts_from_signatures() {
518        let r = &discover(SRC)[0];
519        // `Run` is reachable from `decode`/`expandRun` signatures; builtin
520        // scalars (`String`/`Int`) and collection ctors drop out.
521        assert_eq!(r.cone_types, vec!["Run"]);
522    }
523
524    #[test]
525    fn enumerator_rediscovers_decode_append() {
526        let r = &discover(SRC)[0];
527        // The Phase-2 acceptance lemma falls out of the size-bounded
528        // enumeration as a candidate equation — unguarded, purely from the
529        // cone vocabulary, with no RLE-specific recognizer.
530        assert!(
531            r.conjectures.iter().any(is_decode_append),
532            "decode_append candidate not found among {} conjectures",
533            r.conjectures.len()
534        );
535        // Sanity: enumeration stayed within the safety caps.
536        assert!(!r.stats.terms_truncated, "term enumeration truncated");
537        assert!(
538            !r.stats.conjectures_truncated,
539            "conjecture generation truncated"
540        );
541    }
542
543    /// The acceptance, on the real ground-truth fixture (not just the minimal
544    /// inline one): `examples/data/rle.av`'s `encode law roundtrip` cone is
545    /// the full `[decode, encodeFold, encodeLoop, expandRun, flushAcc,
546    /// repeat]`, yet `decode_append` still falls out of the enumeration.
547    #[test]
548    fn enumerator_rediscovers_decode_append_on_real_rle() {
549        let src =
550            std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/examples/data/rle.av"))
551                .expect("read rle.av");
552        let reports = discover(&src);
553        let roundtrip = reports
554            .iter()
555            .find(|r| r.law_name == "roundtrip")
556            .expect("roundtrip law");
557        assert_eq!(
558            roundtrip.cone_fns,
559            vec![
560                "decode",
561                "encodeFold",
562                "encodeLoop",
563                "expandRun",
564                "flushAcc",
565                "repeat"
566            ]
567        );
568        assert!(
569            roundtrip.conjectures.iter().any(is_decode_append),
570            "decode_append candidate not found among {} conjectures on real rle.av",
571            roundtrip.conjectures.len()
572        );
573        assert!(!roundtrip.stats.terms_truncated && !roundtrip.stats.conjectures_truncated);
574    }
575
576    #[test]
577    fn vm_filter_refutes_false_keeps_decode_append() {
578        let r = &discover(SRC)[0];
579        // The VM-filter actually ran (oracle compiled) and dropped candidates.
580        assert!(r.stats.vm_filtered, "VM-filter did not run");
581        assert!(
582            r.stats.candidates_refuted > 0,
583            "VM-filter refuted nothing — oracle likely failed to compile"
584        );
585        // decode_append is TRUE → survives the filter.
586        assert!(
587            r.conjectures.iter().any(is_decode_append),
588            "decode_append did not survive the VM-filter"
589        );
590        // `x == List.concat(x, x)` is FALSE → refuted, not among survivors.
591        assert!(
592            !r.conjectures.iter().any(is_self_concat_identity),
593            "false self-concat identity survived the VM-filter"
594        );
595    }
596
597    #[test]
598    fn lean_theorem_renders_decode_append() {
599        let r = &discover(SRC)[0];
600        let c = r
601            .conjectures
602            .iter()
603            .find(|c| is_decode_append(c))
604            .expect("decode_append survives");
605        let thm = lean_lemma_theorem(c, &r.binders, "L").expect("template applies");
606        // Statement: `theorem L (x.. : List Run) (..) : decode (.. ++ ..) = .. := by`
607        assert!(thm.contains("theorem L "), "{thm}");
608        assert!(thm.contains(": List Run)"), "{thm}");
609        assert!(thm.contains("decode (") && thm.contains("++"), "{thm}");
610        // Tactic: session-grade list-induction ladder (mirrors the user-stated-law
611        // prover) — the decode unfold + append_assoc, plus the `omega` and
612        // `split` branches that give discovered candidates the same reach.
613        assert!(thm.contains("induction "), "{thm}");
614        assert!(thm.contains("| nil => first | (simp [decode]"), "{thm}");
615        assert!(thm.contains("List.append_assoc"), "{thm}");
616        assert!(thm.contains("ih"), "{thm}");
617        assert!(
618            thm.contains("omega"),
619            "ladder must include the omega branch: {thm}"
620        );
621        assert!(
622            thm.contains("split <;>"),
623            "ladder must include the inner-match split branch: {thm}"
624        );
625        // Discovery is proved-or-dropped: NO `sorry` fallback (a sorry builds
626        // clean and would falsely mark a refuted candidate "proved").
627        assert!(
628            !thm.contains("sorry"),
629            "discovered-lemma tactic must never carry a sorry fallback: {thm}"
630        );
631    }
632
633    #[test]
634    fn ranking_puts_homomorphism_first() {
635        let r = &discover(SRC)[0];
636        let ranked = rank_candidate_indices(r);
637        // The first ranked candidate is the list-homomorphism (decode_append).
638        let first = &r.conjectures[ranked[0]];
639        assert!(
640            is_decode_append(first),
641            "expected decode_append ranked first, got {}",
642            first.render(&r.binders)
643        );
644    }
645
646    /// A `count`-into-`plus` fold whose `count(n, a ++ b) = plus(count n a,
647    /// count n b)` monoid homomorphism is size ~7 — past the enumerator's
648    /// `MAX_TERM_SIZE = 5` — so only the structure-directed conjecturer can mint it.
649    const COUNT_HOMO_SRC: &str = r#"
650type Nat
651    Z
652    S(Nat)
653
654fn eqNat(x: Nat, y: Nat) -> Bool
655    match x
656        Nat.Z -> match y
657            Nat.Z -> true
658            Nat.S(z) -> false
659        Nat.S(x2) -> match y
660            Nat.Z -> false
661            Nat.S(y2) -> eqNat(x2, y2)
662
663fn count(x: Nat, y: List<Nat>) -> Nat
664    match y
665        [] -> Nat.Z
666        [z, ..ys] -> match eqNat(x, z)
667            true -> Nat.S(count(x, ys))
668            false -> count(x, ys)
669
670fn plus(x: Nat, y: Nat) -> Nat
671    match x
672        Nat.Z -> y
673        Nat.S(z) -> Nat.S(plus(z, y))
674
675fn appendNat(xs: List<Nat>, ys: List<Nat>) -> List<Nat>
676    List.concat(xs, ys)
677
678verify count law countPlusConcat
679    given n: Nat = [Nat.Z, Nat.S(Nat.Z)]
680    given xs: List<Nat> = [[], [Nat.Z]]
681    given ys: List<Nat> = [[], [Nat.S(Nat.Z)]]
682    plus(count(n, xs), count(n, ys)) => count(n, appendNat(xs, ys))
683"#;
684
685    #[test]
686    fn structural_homomorphism_conjectured_for_count_fold() {
687        // The conjecturer mints the count homomorphism (the subject fn `count`
688        // is excluded from the cone, so the conjecturer adds it back), and the
689        // VM-filter KEEPS it (it is a true homomorphism). A render like
690        // `count(x2, List.concat(x0, x1)) == plus(count(x2, x0), count(x2, x1))`.
691        let r = &discover(COUNT_HOMO_SRC)[0];
692        let found = r.conjectures.iter().any(|c| {
693            let s = c.render(&r.binders);
694            s.contains("List.concat(") && s.contains("plus(count(")
695        });
696        assert!(
697            found,
698            "count→plus homomorphism not conjectured/surviving; survivors:\n{}",
699            r.conjectures
700                .iter()
701                .map(|c| c.render(&r.binders))
702                .collect::<Vec<_>>()
703                .join("\n")
704        );
705        // It must also rank first — it is the highest-value target, so the
706        // bounded prove budget reaches it.
707        let ranked = rank_candidate_indices(r);
708        let top = r.conjectures[ranked[0]].render(&r.binders);
709        assert!(
710            top.contains("List.concat(") && top.contains("plus(count("),
711            "count homomorphism must rank first, got {top}"
712        );
713    }
714
715    #[test]
716    fn structural_conjecturer_on_real_rle() {
717        // rle is a full encoder, so its counted-repeat (`repeat`) and
718        // monotone-nonneg (`encodeFold.count`) bricks are SUBSUMED by the
719        // relational roundtrip chain (which re-proves them internally) and are
720        // NOT emitted a second time as standalone groups — dedup. So exactly one
721        // group fires: the chain.
722        let groups = with_inputs(&rle_source(), structural_lemma_groups);
723        assert_eq!(
724            groups.len(),
725            1,
726            "expected only the relational chain (standalone bricks deduped)"
727        );
728        let all: String = groups.iter().flatten().map(|(_, t)| t.as_str()).collect();
729        // brick 1 (now inside the chain): guarded counted-repeat advance
730        // (`repeat` escapes to `repeat'`; its param is named `char` in rle).
731        assert!(
732            all.contains("repeat' char (n + 1) = repeat' char n ++ [char]"),
733            "{all}"
734        );
735        assert!(
736            all.contains("(hn : 0 <= n)") && all.contains("natAbs"),
737            "{all}"
738        );
739        // generalized brick 2 (now inside the chain): monotone-nonneg field
740        // invariant, with the shape-agnostic split/omega template.
741        assert!(all.contains("_count_nonneg"), "{all}");
742        assert!(all.contains("0 <= (encodeFold acc char).count"), "{all}");
743        assert!(
744            all.contains("unfold encodeFold") && all.contains("split <;>"),
745            "{all}"
746        );
747        // DEDUP regression guard: the count-nonneg invariant is proved EXACTLY
748        // once (only the chain's copy), not also as a standalone structural group.
749        assert_eq!(
750            all.matches("0 <= (encodeFold acc char).count").count(),
751            1,
752            "count_nonneg duplicated — dedup regressed"
753        );
754    }
755
756    #[test]
757    fn monotone_field_generalizes_beyond_rle_shape() {
758        // tally.av: a NON-rle accumulator that branches on `x > acc.last`
759        // (not `count == 0`). The generalized detector must still find the
760        // monotone-nonneg `seen` field and emit its invariant — proof that
761        // brick 2 keys on the field arithmetic, not the RLE step shape.
762        let groups = with_inputs(&tally_source(), structural_lemma_groups);
763        let all: String = groups.iter().flatten().map(|(_, t)| t.as_str()).collect();
764        assert!(all.contains("0 <= (tallyStep acc x).seen"), "{all}");
765        assert!(all.contains("unfold tallyStep"), "{all}");
766        // It must NOT key on the RLE discriminants.
767        assert!(!all.contains("acc.current"), "{all}");
768    }
769
770    #[test]
771    fn bounded_step_handles_decreasing_accumulator() {
772        // drain.av: `tick` does `acc.n + 1` / `acc.n - 1`, so `0 <= acc.n` is
773        // FALSE — monotone-nonneg must decline. The bounded-step fallback fires
774        // instead, emitting the TRUE two-sided bound (delta in [-1, +1]), and
775        // never the false nonneg invariant.
776        let groups = with_inputs(&drain_source(), structural_lemma_groups);
777        let all: String = groups.iter().flatten().map(|(_, t)| t.as_str()).collect();
778        // Soundness: the false nonneg invariant is not even conjectured.
779        assert!(!all.contains("0 <= (tick acc x).n"), "{all}");
780        // Generalization: both sides of the bounded step, via the same template.
781        assert!(all.contains("acc.n - 1 <= (tick acc x).n"), "{all}");
782        assert!(all.contains("(tick acc x).n <= acc.n + 1"), "{all}");
783        assert!(
784            all.contains("unfold tick") && all.contains("split <;>"),
785            "{all}"
786        );
787    }
788
789    #[test]
790    fn nonneg_covers_multiplicative_scaling() {
791        // scale.av: `grow` does `acc.level * 2` / `acc.level`. The `* 2` is not a
792        // `+ k` shift, but `level * 2` keeps `0 <= level` and stays linear in the
793        // field, so the nonneg invariant still fires (closes the nonlinear-nonneg
794        // gap) — not the bounded step.
795        let groups = with_inputs(&scale_source(), structural_lemma_groups);
796        let all: String = groups.iter().flatten().map(|(_, t)| t.as_str()).collect();
797        assert!(all.contains("0 <= (grow acc x).level"), "{all}");
798        assert!(all.contains("unfold grow"), "{all}");
799    }
800
801    #[test]
802    fn detect_encoder_recognizes_rle_and_sparse() {
803        // The relational-brick role detector must recover the SAME encoder
804        // skeleton from two structurally different encoders — proof it keys on
805        // the fold-with-inverse shape, not on rle. (Roles arrive via TailCall
806        // nodes after TCO; `as_call` handles that.)
807        assert!(
808            encoder_roles(&rle_source())
809                .contains(&"encode/decode/encodeLoop/flushAcc/encodeFold/expandRun/xs".to_string()),
810            "rle roles: {:?}",
811            encoder_roles(&rle_source())
812        );
813        assert!(
814            encoder_roles(&sparse_source()).contains(
815                &"encodeSparse/decodeSparse/sparseLoop/flushSparse/sparseStep/expandTok/xs"
816                    .to_string()
817            ),
818            "sparse roles: {:?}",
819            encoder_roles(&sparse_source())
820        );
821    }
822
823    #[test]
824    fn shape_classifies_fold_wrappers() {
825        // Shape-anchoring precondition: `analysis::shape` must classify the
826        // encoder/monoidal wrappers as `WrapperOverRecursion` — the principled
827        // "is this a wrapper-over-recursion fold" decision the detectors gate on,
828        // sourced from the shared shape vocabulary, not a bespoke AST walk.
829        use crate::analysis::shape::ModulePattern;
830        let wrappers = |src: &str| -> Vec<(String, String)> {
831            with_inputs(src, |inputs| {
832                inputs
833                    .program_shape
834                    .map(|s| {
835                        s.patterns
836                            .iter()
837                            .filter_map(|p| match p {
838                                ModulePattern::WrapperOverRecursion {
839                                    wrapper_fn,
840                                    inner_fn,
841                                    ..
842                                } => Some((wrapper_fn.clone(), inner_fn.clone())),
843                                _ => None,
844                            })
845                            .collect()
846                    })
847                    .unwrap_or_default()
848            })
849        };
850        assert!(
851            wrappers(&rle_source()).contains(&("encode".to_string(), "encodeLoop".to_string())),
852            "rle: {:?}",
853            wrappers(&rle_source())
854        );
855        assert!(
856            wrappers(&sparse_source())
857                .contains(&("encodeSparse".to_string(), "sparseLoop".to_string())),
858            "sparse: {:?}",
859            wrappers(&sparse_source())
860        );
861        assert!(
862            wrappers(&sum_acc_source()).contains(&("sum".to_string(), "sumTR".to_string())),
863            "sum_acc: {:?}",
864            wrappers(&sum_acc_source())
865        );
866    }
867
868    #[test]
869    fn monoidal_spec_equivalence_emitted_for_sum_acc() {
870        // The MONOIDAL flavor of the same accumulator-generalization schema: the
871        // detector recognizes `sum(xs) = sumDirect(xs)` (sum = sumTR(·, 0), an
872        // additive fold) and emits the shared `loop_gen` skeleton closed by
873        // `omega` — NOT the codec roundtrip chain. Evidence the schema is one
874        // thing across codec and monoidal, not two bespoke recognizers.
875        let all: String = with_inputs(&sum_acc_source(), structural_lemma_groups)
876            .iter()
877            .flatten()
878            .map(|(_, t)| t.as_str())
879            .collect();
880        // The law itself + the strengthened loop invariant.
881        assert!(all.contains("sum xs = sumDirect xs"), "{all}");
882        assert!(
883            all.contains("sumTR list acc = acc + sumDirect list"),
884            "{all}"
885        );
886        // The shared induct-and-instantiate skeleton, additive closer.
887        assert!(all.contains("induction list with"), "{all}");
888        assert!(
889            all.contains("rw [ih (acc + h)]") && all.contains("omega"),
890            "{all}"
891        );
892        // It must NOT drag in the codec bricks (no inverse / counted-repeat here).
893        assert!(!all.contains("flush_fold_step"), "{all}");
894        assert!(!all.contains("inv_append"), "{all}");
895    }
896
897    #[test]
898    fn relational_chain_emitted_for_rle_and_sparse() {
899        // The relational-brick emitter must produce the FULL roundtrip chain for
900        // BOTH encoders, with the IDENTICAL generic `flush_fold_step` tactic —
901        // the locksmith, not a per-program key. (Text-level pin; the lake-gated
902        // proof_spec tests confirm it kernel-proves.)
903        let rle: String = with_inputs(&rle_source(), structural_lemma_groups)
904            .iter()
905            .flatten()
906            .map(|(_, t)| t.as_str())
907            .collect();
908        // The law itself + the strengthened invariant + the crux, all present.
909        assert!(rle.contains("decode (encode xs) = xs"), "{rle}");
910        assert!(
911            rle.contains("decode (flushAcc (encodeFold acc x)) = decode (flushAcc acc) ++ [x]"),
912            "{rle}"
913        );
914        assert!(rle.contains("unfold encodeFold flushAcc"), "{rle}");
915        // The neutral accumulator is rendered from the wrapper body.
916        assert!(
917            rle.contains("{ runs := [], current := \"\", count := 0 }"),
918            "{rle}"
919        );
920
921        let sparse: String = with_inputs(&sparse_source(), structural_lemma_groups)
922            .iter()
923            .flatten()
924            .map(|(_, t)| t.as_str())
925            .collect();
926        assert!(
927            sparse.contains("decodeSparse (encodeSparse xs) = xs"),
928            "{sparse}"
929        );
930        assert!(sparse.contains("repeat0 1 = [0]"), "{sparse}");
931        assert!(sparse.contains("unfold sparseStep flushSparse"), "{sparse}");
932        assert!(sparse.contains("{ out := [], pending := 0 }"), "{sparse}");
933        // The crux tactic is byte-for-byte the SAME combinator on both (only the
934        // role names differ) — the evidence it generalizes.
935        let crux = "split <;> (try split) <;> (try split) <;>";
936        assert!(rle.contains(crux) && sparse.contains(crux));
937    }
938
939    #[test]
940    fn multi_int_fields_each_get_a_lemma() {
941        // twofield.av: `meterStep` has a non-negative `seen` AND a strictly
942        // decreasing `budget`. Discovery must emit a lemma for EACH field, not
943        // just the first — `seen`'s nonneg invariant and `budget`'s bounded step.
944        let groups = with_inputs(&twofield_source(), structural_lemma_groups);
945        let all: String = groups.iter().flatten().map(|(_, t)| t.as_str()).collect();
946        assert!(all.contains("0 <= (meterStep acc x).seen"), "{all}");
947        assert!(
948            all.contains("acc.budget - 1 <= (meterStep acc x).budget"),
949            "{all}"
950        );
951        assert!(
952            all.contains("(meterStep acc x).budget <= acc.budget - 1"),
953            "{all}"
954        );
955    }
956}