Skip to main content

aver/codegen/lemma_discovery/
committed.rs

1//! The feedback half of the discovery loop (`ProofStrategy::SimpOverLemmas`):
2//! consume a previously-committed `DiscoveredLemmas.lean` so the kernel-proved
3//! lemmas JOIN the normal `aver proof` run instead of only being re-verified
4//! next to it.
5//!
6//! Flow (CLI-driven, Lean backend):
7//!
8//! ```text
9//!   <out>/DiscoveredLemmas.lean  ─►  parse_committed_lemmas  ─►  plan_simp_over_lemma_pins
10//!   (hash-gated: stale surface       (name + verbatim text        (per `verify … law`: every
11//!    means IGNORE — behave exactly    per `theorem` block)          committed lemma whose program-fn
12//!    like no discovery ran)                                         mentions ⊆ the law's cone)
13//!                                                  │
14//!                                                  ▼
15//!                       apply_simp_over_lemma_pins re-pins `Induction` → `SimpOverLemmas(names)`;
16//!                       the Lean backend then EMBEDS the lemma texts before the law theorem
17//!                       (re-verifying them in the same `lake build` — the soundness guard)
18//!                       and adds their names to the law's simp set.
19//! ```
20//!
21//! The cone-hash gate is a staleness key ONLY (skip-feedback, like
22//! skip-rediscovery on replay). Soundness never rests on it: an embedded lemma
23//! is re-proved by the kernel on every build, so a lemma staled by a
24//! same-signature body change fails the build loudly instead of being trusted.
25
26use std::collections::{BTreeMap, BTreeSet};
27
28use crate::ast::{TopLevel, VerifyKind};
29use crate::codegen::proof_lower::{LawProofCone, ProofLowerInputs};
30use crate::ir::proof_ir::ProofIR;
31
32/// A lemma available to a law's proof: its theorem name plus Lean text
33/// (statement, and for embedded ones the tactic too). Two provenances flow
34/// through the same orientation / loop-exclusion / simp-selection machinery:
35///
36/// - **embedded** (`embed = true`) — a kernel-proved lemma parsed back from a
37///   committed `DiscoveredLemmas.lean`; its full text is written into the
38///   generated proof project (re-proved in the same `lake build`).
39/// - **reference** (`embed = false`) — an already-proved EARLIER user
40///   `verify … law` in the same file (część A): its theorem is already
41///   emitted, so only the NAME joins later laws' simp sets; `text` carries
42///   just the synthesized `theorem <name> : <lhs> = <rhs>` statement, used
43///   for orientation + loop analysis, never written out.
44#[derive(Debug, Clone)]
45pub struct CommittedLemma {
46    pub name: String,
47    pub text: String,
48    /// Write `text` verbatim into the proof project (`true`), or only
49    /// reference `name` in simp sets because it is already emitted (`false`).
50    pub embed: bool,
51}
52
53impl CommittedLemma {
54    /// A reference to an already-emitted theorem (an earlier user law) — name
55    /// plus synthesized statement, never written out. `text` should be a
56    /// well-formed `theorem <name> : <stmt> := by` head so the shared
57    /// orientation / loop analysis reads it like any other lemma.
58    pub fn reference(name: String, text: String) -> Self {
59        Self {
60            name,
61            text,
62            embed: false,
63        }
64    }
65}
66
67/// Parse a committed `DiscoveredLemmas.lean` into its theorem blocks. A block
68/// starts at a column-0 `theorem ` line and runs until the next one (proof
69/// lines are indented, so this never splits a tactic). Header comments before
70/// the first theorem are dropped; comment/blank lines between theorems are
71/// absorbed into the preceding block's text (harmless Lean comments).
72pub fn parse_committed_lemmas(content: &str) -> Vec<CommittedLemma> {
73    let mut lemmas: Vec<CommittedLemma> = Vec::new();
74    let mut current: Option<CommittedLemma> = None;
75    for line in content.lines() {
76        if let Some(rest) = line.strip_prefix("theorem ") {
77            if let Some(mut done) = current.take() {
78                done.text.truncate(done.text.trim_end().len());
79                lemmas.push(done);
80            }
81            let name = rest
82                .split_whitespace()
83                .next()
84                .unwrap_or("")
85                .trim_end_matches(':')
86                .to_string();
87            current = Some(CommittedLemma {
88                name,
89                text: line.to_string(),
90                embed: true,
91            });
92        } else if let Some(block) = current.as_mut() {
93            block.text.push('\n');
94            block.text.push_str(line);
95        }
96    }
97    if let Some(mut done) = current.take() {
98        done.text.truncate(done.text.trim_end().len());
99        lemmas.push(done);
100    }
101    lemmas.retain(|l| !l.name.is_empty());
102    lemmas
103}
104
105/// Soundness validation for a parsed committed lemma: the embed path writes
106/// `text` VERBATIM into the generated entry root, where lake compiles it as
107/// top-level Lean — so a block absorbing anything beyond its own
108/// `theorem … := by` + tactic lines (the parser takes every non-`theorem `
109/// line as-is, and Lean accepts indented top-level commands) could smuggle a
110/// declaration like `axiom cheat : False` into the proof environment.
111/// Returns the first forbidden declaration keyword found outside `--` line
112/// comments (skipping the block's own leading `theorem`), or `None` when the
113/// block is clean. The CLI rejects the WHOLE artifact on any hit — a
114/// discovery-emitted file never contains these, so a hit means hand-edited
115/// or corrupted content that must not join a kernel-trust pipeline. (The
116/// axiom WHITELIST in the universal metric is the backstop; this check makes
117/// the failure loud and early instead.)
118pub fn forbidden_token_in_lemma(text: &str) -> Option<&'static str> {
119    const DENY: [&str; 30] = [
120        "axiom",
121        "opaque",
122        "unsafe",
123        "macro",
124        "macro_rules",
125        "notation",
126        "syntax",
127        "elab",
128        "attribute",
129        "set_option",
130        "instance",
131        "structure",
132        "inductive",
133        "class",
134        "def",
135        "abbrev",
136        "example",
137        "import",
138        "open",
139        "namespace",
140        "section",
141        "end",
142        "mutual",
143        "initialize",
144        "run_cmd",
145        "partial",
146        "noncomputable",
147        "deriving",
148        "theorem",
149        "sorry",
150    ];
151    for (line_idx, line) in text.lines().enumerate() {
152        let code = line.split("--").next().unwrap_or("");
153        for (tok_idx, tok) in code
154            .split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.' || c == '\''))
155            .filter(|t| !t.is_empty())
156            .enumerate()
157        {
158            // The block's own header keyword.
159            if line_idx == 0 && tok_idx == 0 && tok == "theorem" {
160                continue;
161            }
162            if let Some(hit) = DENY.iter().find(|d| **d == tok) {
163                return Some(hit);
164            }
165        }
166    }
167    None
168}
169
170/// Program fns a lemma's Lean text mentions, projected through `lean_index`
171/// (Lean name → caller-chosen value, e.g. the source name). Token scan over
172/// identifier-shaped chunks; builtin lemma names (`List.append_assoc`, …) and
173/// binder names simply miss the index.
174pub fn mentioned_fns(text: &str, lean_index: &BTreeMap<String, String>) -> BTreeSet<String> {
175    let mut out = BTreeSet::new();
176    for token in text.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.' || c == '\'')) {
177        if let Some(v) = lean_index.get(token) {
178            out.insert(v.clone());
179        }
180    }
181    out
182}
183
184/// How a committed lemma may join a `simp` set. Discovery commits equations
185/// in enumeration orientation, so usability as a rewrite rule is a property
186/// to RECOVER, not assume.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum SimpDirection {
189    /// LHS head is a program fn (`count x2 (x0 ++ x1) = plus …`,
190    /// `decode (encode xs) = xs`): use as-is — rewrites toward
191    /// decomposed/builtin normal form.
192    Forward,
193    /// LHS is builtin-headed but the RHS head is a program fn (the trivia
194    /// `(x0 ++ x1) = append x0 x1`): use as `← name` — rewrites the opaque
195    /// program fn INTO its builtin shape (an unfolding equation the fn's own
196    /// def can't provide when its recursion is stuck on a symbolic arg).
197    Reversed,
198}
199
200/// Classify a committed lemma as a usable `simp` rewrite rule, or `None`
201/// (e.g. a `0 <= …` invariant, or an equation connecting nothing to a
202/// program fn head). A `None` lemma stays EMBEDDED (other committed lemmas'
203/// proofs may depend on it) but joins no simp set — a builtin-headed
204/// equation used left-to-right re-folds the very structure the induction
205/// ladder needs peeled, and loops against the fn's own def unfold.
206pub fn simp_orientation(text: &str, program_fns: &BTreeSet<String>) -> Option<SimpDirection> {
207    let stmt = statement_body(text)?;
208    let rhs = split_after_top_eq(stmt);
209    // A Forward rule is usable only if it does not GROW the term — if the RHS
210    // textually contains the whole LHS (`dbl x = idNat (dbl x)`), rewriting
211    // LHS→RHS re-exposes the LHS and `simp` never terminates (a maxHeartbeats
212    // BUILD error `first` cannot catch). The `simp_entries` loop-exclusion
213    // only drops forward/reversed PAIRS, not a single self-growing forward
214    // rule — so reject it here. The shrinking REVERSED direction (RHS→LHS) is
215    // still safe and is tried next.
216    let lhs = rhs.map(|r| {
217        let end = stmt.len() - r.len() - 1; // strip the `=` between lhs and rhs
218        stmt[..end].trim()
219    });
220    let forward_grows = matches!((lhs, rhs), (Some(l), Some(r)) if !l.is_empty() && r.contains(l));
221    if program_fns.contains(&head_token(stmt)) && !forward_grows {
222        return Some(SimpDirection::Forward);
223    }
224    let rhs = rhs?;
225    // Symmetric guard for the reversed direction: a self-growing reversed rule
226    // (LHS contains the RHS) would loop the other way.
227    let reversed_grows = matches!(lhs, Some(l) if !rhs.trim().is_empty() && l.contains(rhs.trim()));
228    if program_fns.contains(&head_token(rhs)) && !reversed_grows {
229        return Some(SimpDirection::Reversed);
230    }
231    None
232}
233
234/// Ready-to-emit `simp` set entries for a pinned lemma selection: a Forward
235/// lemma joins as `name`, a Reversed one as `← name` — minus the loop-prone
236/// combinations. A Forward rule whose RHS mentions a program fn that some
237/// Reversed rule in the SAME set unfolds (its RHS head) would compose into a
238/// rewrite cycle — e.g. `length (x0 ++ x1) = length (append x0 x1)` (forward)
239/// against `← ((x0 ++ x1) = append x0 x1)` ping-pongs `++ ↔ append` under
240/// `length` forever. `simp` loops are NOT a caught failure: they abort the
241/// build with a deterministic maxHeartbeats ERROR that `first` cannot
242/// recover from, so the exclusion is a build-safety requirement, not a
243/// quality preference.
244pub fn simp_entries(lemmas: &[&CommittedLemma], program_fns: &BTreeSet<String>) -> Vec<String> {
245    let classified: Vec<(&CommittedLemma, SimpDirection)> = lemmas
246        .iter()
247        .filter_map(|l| simp_orientation(&l.text, program_fns).map(|d| (*l, d)))
248        .collect();
249    let reversed_heads: BTreeSet<String> = classified
250        .iter()
251        .filter(|(_, d)| *d == SimpDirection::Reversed)
252        .filter_map(|(l, _)| {
253            let rhs = split_after_top_eq(statement_body(&l.text)?)?;
254            Some(head_token(rhs))
255        })
256        .collect();
257    classified
258        .into_iter()
259        .filter_map(|(l, d)| match d {
260            SimpDirection::Forward => {
261                let rhs = split_after_top_eq(statement_body(&l.text)?)?;
262                let mentions_unfolded = rhs
263                    .split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.' || c == '\''))
264                    .any(|tok| reversed_heads.contains(tok));
265                if mentions_unfolded {
266                    None
267                } else {
268                    Some(l.name.clone())
269                }
270            }
271            SimpDirection::Reversed => Some(format!("← {}", l.name)),
272        })
273        .collect()
274}
275
276/// [`statement_of`] with the `∀ binders,` prefix stripped — the equation body
277/// the orientation/loop analyses operate on.
278fn statement_body(text: &str) -> Option<&str> {
279    let stmt = statement_of(text)?.trim_start();
280    if let Some(rest) = stmt.strip_prefix('∀') {
281        split_after_depth0(rest, ',')
282    } else {
283        Some(stmt)
284    }
285}
286
287/// First identifier-shaped token, skipping leading whitespace and `(`.
288fn head_token(text: &str) -> String {
289    text.chars()
290        .skip_while(|c| c.is_whitespace() || *c == '(')
291        .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '.' || *c == '\'')
292        .collect()
293}
294
295/// The slice after the top-level `=` of an equation — depth-0, not part of
296/// `<=` / `>=` / `!=` / `==` (the only `=`-bearing operators the lemma
297/// templates emit; `:=` was already cut off by [`statement_of`]).
298fn split_after_top_eq(text: &str) -> Option<&str> {
299    let mut depth = 0i32;
300    let mut prev = ' ';
301    let bytes = text.as_bytes();
302    for (i, c) in text.char_indices() {
303        match c {
304            '(' | '[' | '{' => depth += 1,
305            ')' | ']' | '}' => depth -= 1,
306            '=' if depth == 0 => {
307                let next_eq = bytes.get(i + 1) == Some(&b'=');
308                if !matches!(prev, '<' | '>' | '!' | '=') && !next_eq {
309                    return Some(&text[i + 1..]);
310                }
311            }
312            _ => {}
313        }
314        prev = c;
315    }
316    None
317}
318
319/// The statement region of a theorem text: after the first depth-0 `:`
320/// (binders keep their `:`s inside parens/brackets), up to the depth-0 `:=`.
321fn statement_of(text: &str) -> Option<&str> {
322    let mut depth = 0i32;
323    let mut start = None;
324    let mut prev_colon = false;
325    for (i, c) in text.char_indices() {
326        match c {
327            '(' | '[' | '{' => depth += 1,
328            ')' | ']' | '}' => depth -= 1,
329            ':' if depth == 0 && start.is_none() => {
330                start = Some(i + 1);
331            }
332            '=' if depth == 0 && prev_colon => {
333                // `:=` — if it directly follows the colon that opened the
334                // statement, the statement is empty (malformed); else end.
335                let s = start?;
336                if i > s {
337                    return Some(&text[s..i - 1]);
338                }
339                return None;
340            }
341            _ => {}
342        }
343        prev_colon = c == ':' && depth == 0;
344    }
345    None
346}
347
348/// Byte offset just past the first depth-0 occurrence of `sep`, as a slice.
349fn split_after_depth0(text: &str, sep: char) -> Option<&str> {
350    let mut depth = 0i32;
351    for (i, c) in text.char_indices() {
352        match c {
353            '(' | '[' | '{' => depth += 1,
354            ')' | ']' | '}' => depth -= 1,
355            c2 if c2 == sep && depth == 0 => return Some(&text[i + c.len_utf8()..]),
356            _ => {}
357        }
358    }
359    None
360}
361
362/// A planned re-pin: `(fn_id, law_name)` goes from `Induction` to
363/// `SimpOverLemmas(lemma_names)`.
364pub type SimpOverLemmaPin = (crate::ir::FnId, String, Vec<String>);
365
366/// Decide which laws get the committed lemmas. A lemma is in-scope for a law
367/// when every program fn its text mentions is inside the law's proof cone
368/// (plus the law's subject fn) — the same scope discovery enumerated over, so
369/// the embedded text can only reference fns already emitted before the law's
370/// theorem. Only laws the lowerer pinned `Induction` are re-pinned: that is
371/// the strategy the discovery cluster (list/Peano homomorphisms) lands on,
372/// and the Lean renderer for `SimpOverLemmas` reuses the same induction
373/// ladder, so the swap can only ADD proving power.
374pub fn plan_simp_over_lemma_pins(
375    inputs: &ProofLowerInputs,
376    ir: &ProofIR,
377    lemmas: &[CommittedLemma],
378) -> Vec<SimpOverLemmaPin> {
379    use crate::codegen::lean::aver_name_to_lean;
380    if lemmas.is_empty() {
381        return Vec::new();
382    }
383    // Lean name → Lean name over EVERY pure program fn: the universe the
384    // subset test runs in. A lemma mentioning no program fn at all carries no
385    // connection to the program and is never pinned.
386    let all_fns: BTreeMap<String, String> = inputs
387        .pure_fns()
388        .iter()
389        .map(|fd| {
390            let lean = aver_name_to_lean(&fd.name);
391            (lean.clone(), lean)
392        })
393        .collect();
394    let all_fn_names: BTreeSet<String> = all_fns.keys().cloned().collect();
395    let mentions: Vec<BTreeSet<String>> = lemmas
396        .iter()
397        .map(|l| mentioned_fns(&l.text, &all_fns))
398        .collect();
399    let oriented: Vec<bool> = lemmas
400        .iter()
401        .map(|l| simp_orientation(&l.text, &all_fn_names).is_some())
402        .collect();
403
404    let mut plan = Vec::new();
405    for item in inputs.entry_items {
406        let TopLevel::Verify(vb) = item else { continue };
407        let VerifyKind::Law(law) = &vb.kind else {
408            continue;
409        };
410        let Some(fn_id) = inputs
411            .symbol_table
412            .fn_id_of(&crate::ir::FnKey::entry(&vb.fn_name))
413        else {
414            continue;
415        };
416        let Some(thm) = ir
417            .law_theorems
418            .iter()
419            .find(|t| t.fn_id == fn_id && t.law_name == law.name)
420        else {
421            continue;
422        };
423        if !matches!(thm.strategy, crate::ir::ProofStrategy::Induction { .. }) {
424            continue;
425        }
426        let cone = LawProofCone::compute(law, &vb.fn_name, inputs);
427        let mut scope: BTreeSet<String> = cone
428            .pure_fns()
429            .iter()
430            .map(|fd| aver_name_to_lean(&fd.name))
431            .collect();
432        scope.insert(aver_name_to_lean(&vb.fn_name));
433        // The pin carries every in-scope lemma (the EMBED set — committed
434        // lemmas may depend on each other, so dropping one could break
435        // another's embedded proof), but a law is only worth pinning when at
436        // least one of them is a usable simp rewrite rule — the Lean emit
437        // re-derives that selection for its `simp` sets.
438        let mut any_oriented = false;
439        let mut selected: BTreeSet<usize> = BTreeSet::new();
440        for (i, (m, o)) in mentions.iter().zip(&oriented).enumerate() {
441            if !m.is_empty() && m.is_subset(&scope) {
442                selected.insert(i);
443                any_oriented |= *o;
444            }
445        }
446        if !any_oriented {
447            continue;
448        }
449        // Dependency closure: a committed lemma's PROOF may reference a
450        // sibling committed theorem by name (the structural chains do —
451        // e.g. a guarded `…_succ` step rewriting with its `…_natAbs_succ`
452        // helper, which itself mentions no program fn and so failed the
453        // in-scope gate above). Embedding one without the other is an
454        // unknown-identifier BUILD error, so pull referenced siblings in
455        // until fixpoint. Every program fn is emitted before the verify
456        // theorems regardless of cone, so an added dependency always
457        // type-checks; preserving committed-file order (the BTreeSet index
458        // walk below) keeps each dependency ahead of its dependent.
459        loop {
460            let added: Vec<usize> = lemmas
461                .iter()
462                .enumerate()
463                .filter(|(j, lj)| {
464                    !selected.contains(j)
465                        && selected.iter().any(|&i| lemmas[i].text.contains(&lj.name))
466                })
467                .map(|(j, _)| j)
468                .collect();
469            if added.is_empty() {
470                break;
471            }
472            selected.extend(added);
473        }
474        let names: Vec<String> = selected.iter().map(|&i| lemmas[i].name.clone()).collect();
475        plan.push((fn_id, law.name.clone(), names));
476    }
477    plan
478}
479
480/// Apply a [`plan_simp_over_lemma_pins`] plan to the lowered IR.
481pub fn apply_simp_over_lemma_pins(ir: &mut ProofIR, plan: &[SimpOverLemmaPin]) {
482    for (fn_id, law_name, names) in plan {
483        if let Some(t) = ir
484            .law_theorems
485            .iter_mut()
486            .find(|t| t.fn_id == *fn_id && t.law_name == *law_name)
487        {
488            t.strategy = crate::ir::ProofStrategy::SimpOverLemmas(names.clone());
489        }
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    /// The count-into-plus fold family (mirrors the conjecturer fixture in
498    /// the parent module), plus an `orphan` pure fn UNREACHABLE from the law —
499    /// the out-of-cone case the in-scope gate must reject.
500    const SRC: &str = r#"
501type Nat
502    Z
503    S(Nat)
504
505fn eqNat(x: Nat, y: Nat) -> Bool
506    match x
507        Nat.Z -> match y
508            Nat.Z -> true
509            Nat.S(z) -> false
510        Nat.S(x2) -> match y
511            Nat.Z -> false
512            Nat.S(y2) -> eqNat(x2, y2)
513
514fn count(x: Nat, y: List<Nat>) -> Nat
515    match y
516        [] -> Nat.Z
517        [z, ..ys] -> match eqNat(x, z)
518            true -> Nat.S(count(x, ys))
519            false -> count(x, ys)
520
521fn plus(x: Nat, y: Nat) -> Nat
522    match x
523        Nat.Z -> y
524        Nat.S(z) -> Nat.S(plus(z, y))
525
526fn appendNat(xs: List<Nat>, ys: List<Nat>) -> List<Nat>
527    List.concat(xs, ys)
528
529fn orphan(x: Nat) -> Nat
530    x
531
532verify count law countPlusConcat
533    given n: Nat = [Nat.Z, Nat.S(Nat.Z)]
534    given xs: List<Nat> = [[], [Nat.Z]]
535    given ys: List<Nat> = [[], [Nat.S(Nat.Z)]]
536    plus(count(n, xs), count(n, ys)) => count(n, appendNat(xs, ys))
537"#;
538
539    const COMMITTED: &str = "-- Discovered lemmas for prop_02.av — `aver proof --discover`\n\
540        -- cone-hash: 00deadbeef00\n\
541        -- Each theorem below was discovered and kernel-proved.\n\
542        \n\
543        theorem aver_helper_succ (n : Int) : Int.natAbs (n + 1) = Int.natAbs n + 1 := by\n\
544        \x20 omega\n\
545        \n\
546        theorem aver_discovered_lemma_0 (x0 : List Nat) (x1 : List Nat) (x2 : Nat) : count x2 (x0 ++ x1) = plus (count x2 x0) (count x2 x1) := by\n\
547        \x20 induction x0 with\n\
548        \x20 | nil => first | (simp [count]; done) | (simp [count, aver_helper_succ]; omega)\n\
549        \x20 | cons head tail ih => first | (simp_all [count]; done) | (simp_all [count]; omega)\n\
550        \n\
551        theorem aver_discovered_lemma_1 (x0 : Nat) : orphan (plus x0 x0) = plus x0 x0 := by\n\
552        \x20 simp [orphan]\n";
553
554    fn with_inputs<R>(src: &str, f: impl FnOnce(&ProofLowerInputs) -> R) -> R {
555        let mut lexer = crate::lexer::Lexer::new(src);
556        let tokens = lexer.tokenize().expect("lex");
557        let mut items = crate::parser::Parser::new(tokens).parse().expect("parse");
558        crate::ir::pipeline::tco(&mut items);
559        crate::ir::pipeline::resolve(&mut items);
560        let symbols = crate::ir::SymbolTable::build(&items, &[]);
561        let prefixes: std::collections::HashSet<String> = std::collections::HashSet::new();
562        let recursive: std::collections::HashSet<crate::ir::FnId> =
563            std::collections::HashSet::new();
564        let no_modules: &[crate::codegen::ModuleInfo] = &[];
565        let inputs = ProofLowerInputs {
566            entry_items: &items,
567            dep_modules: no_modules,
568            module_prefixes: &prefixes,
569            recursive_fns: &recursive,
570            symbol_table: &symbols,
571            program_shape: None,
572        };
573        f(&inputs)
574    }
575
576    #[test]
577    fn parses_committed_theorem_blocks() {
578        let lemmas = parse_committed_lemmas(COMMITTED);
579        assert_eq!(lemmas.len(), 3);
580        assert_eq!(lemmas[0].name, "aver_helper_succ");
581        assert_eq!(lemmas[1].name, "aver_discovered_lemma_0");
582        assert_eq!(lemmas[2].name, "aver_discovered_lemma_1");
583        // Block boundaries: each text starts at its own `theorem` line and
584        // carries its full (indented) tactic, nothing of its neighbour.
585        assert!(
586            lemmas[1]
587                .text
588                .starts_with("theorem aver_discovered_lemma_0 ")
589        );
590        assert!(lemmas[1].text.contains("induction x0 with"));
591        assert!(!lemmas[1].text.contains("aver_discovered_lemma_1"));
592        assert!(lemmas[2].text.ends_with("simp [orphan]"));
593        // Header comments are not a lemma.
594        assert!(lemmas.iter().all(|l| !l.text.contains("cone-hash")));
595    }
596
597    #[test]
598    fn plan_pins_in_scope_lemma_and_rejects_out_of_cone() {
599        with_inputs(SRC, |inputs| {
600            let mut ir = ProofIR::default();
601            crate::codegen::proof_lower::populate_law_theorems(inputs, &mut ir);
602            assert_eq!(ir.law_theorems.len(), 1);
603            assert!(matches!(
604                ir.law_theorems[0].strategy,
605                crate::ir::ProofStrategy::Induction { .. }
606            ));
607
608            let lemmas = parse_committed_lemmas(COMMITTED);
609            let plan = plan_simp_over_lemma_pins(inputs, &ir, &lemmas);
610            // Exactly one law pinned. lemma_0 mentions {count, plus} ⊆ cone ∪
611            // {subject} — in. Its tactic references `aver_helper_succ` by
612            // name, so the helper (no program-fn mentions — it would fail the
613            // in-scope gate alone) rides in via the dependency closure, AHEAD
614            // of its dependent (committed-file order). lemma_1 mentions
615            // `orphan`, which the law never reaches — out-of-cone, rejected.
616            assert_eq!(plan.len(), 1);
617            assert_eq!(plan[0].1, "countPlusConcat");
618            assert_eq!(
619                plan[0].2,
620                vec![
621                    "aver_helper_succ".to_string(),
622                    "aver_discovered_lemma_0".to_string()
623                ]
624            );
625
626            apply_simp_over_lemma_pins(&mut ir, &plan);
627            match &ir.law_theorems[0].strategy {
628                crate::ir::ProofStrategy::SimpOverLemmas(names) => {
629                    assert_eq!(names.len(), 2);
630                }
631                other => panic!("expected SimpOverLemmas pin, got {other:?}"),
632            }
633        });
634    }
635
636    #[test]
637    fn simp_orientation_classifies_rewrite_direction() {
638        let fns: BTreeSet<String> = ["count", "plus", "appendNat", "decode", "encode"]
639            .iter()
640            .map(|s| s.to_string())
641            .collect();
642        // Homomorphism: program-fn-headed LHS — a forward rewrite rule.
643        assert_eq!(
644            simp_orientation(
645                "theorem t0 (x0 : List Nat) (x2 : Nat) : (count x2 (x0 ++ x1)) = (plus (count x2 x0) (count x2 x1)) := by\n  simp",
646                &fns
647            ),
648            Some(SimpDirection::Forward)
649        );
650        // Roundtrip-shaped brick: also forward.
651        assert_eq!(
652            simp_orientation(
653                "theorem t1 (xs : List String) : decode (encode xs) = xs := by\n  simp",
654                &fns
655            ),
656            Some(SimpDirection::Forward)
657        );
658        // Builtin-headed LHS with a program-fn-headed RHS: usable REVERSED
659        // (`← name` unfolds the opaque wrapper into its builtin shape).
660        assert_eq!(
661            simp_orientation(
662                "theorem t2 (x0 : List Nat) : (x0 ++ x0) = (appendNat x0 x0) := by\n  simp",
663                &fns
664            ),
665            Some(SimpDirection::Reversed)
666        );
667        // ∀-quantified template: the binder list is skipped before the head.
668        assert_eq!(
669            simp_orientation(
670                "theorem t3 : ∀ (list : List Int) (acc : Int), plus list acc = acc := by\n  simp",
671                &fns
672            ),
673            Some(SimpDirection::Forward)
674        );
675        // Non-equation invariant (`0 <= …`) connecting no program-fn head on
676        // either side of an `=`: no usable direction (embed-only).
677        assert_eq!(
678            simp_orientation(
679                "theorem t4 (acc : Acc) (x : Int) : 0 <= (count acc x) := by\n  simp",
680                &fns
681            ),
682            None
683        );
684        // Builtin-to-builtin associativity trivia: no direction either.
685        assert_eq!(
686            simp_orientation(
687                "theorem t5 (x0 : List Nat) : ((x0 ++ x0) ++ x0) = (x0 ++ (x0 ++ x0)) := by\n  simp",
688                &fns
689            ),
690            None
691        );
692        // SELF-GROWING forward rule (`dbl x = idNat (dbl x)`, RHS contains the
693        // whole LHS): rewriting LHS→RHS never terminates, so Forward is
694        // forbidden — but the shrinking REVERSED direction (RHS head `idNat` is
695        // a program fn, LHS does not contain the RHS) is safe.
696        let dfns: BTreeSet<String> = ["dbl", "idNat"].iter().map(|s| s.to_string()).collect();
697        assert_eq!(
698            simp_orientation(
699                "theorem t6 (x : Nat) : dbl x = idNat (dbl x) := by\n  simp",
700                &dfns
701            ),
702            Some(SimpDirection::Reversed)
703        );
704        // A reflexive equation (`loopy x = loopy x`) grows in BOTH directions
705        // (each side contains the other), so neither direction is a usable
706        // rewrite — dropped.
707        let efns: BTreeSet<String> = ["loopy"].iter().map(|s| s.to_string()).collect();
708        assert_eq!(
709            simp_orientation(
710                "theorem t7 (x : Nat) : loopy x = loopy x := by\n  rfl",
711                &efns
712            ),
713            None
714        );
715    }
716
717    #[test]
718    fn forbidden_tokens_reject_smuggled_declarations() {
719        // A genuine discovery block: clean.
720        let lemmas = parse_committed_lemmas(COMMITTED);
721        assert!(
722            lemmas
723                .iter()
724                .all(|l| forbidden_token_in_lemma(&l.text).is_none()),
725            "discovery-emitted blocks must validate clean"
726        );
727        // The smuggle vector the adversarial review found: a column-0 (or
728        // indented — Lean accepts indented top-level commands) `axiom` line
729        // absorbed into a block's verbatim text would join the kernel
730        // environment and defeat the universal metric.
731        assert_eq!(
732            forbidden_token_in_lemma("theorem t : True := by\n  trivial\naxiom cheat : False"),
733            Some("axiom")
734        );
735        assert_eq!(
736            forbidden_token_in_lemma("theorem t : True := by\n  trivial\n  set_option foo true"),
737            Some("set_option")
738        );
739        // `sorry` never appears in a committed lemma (proved-or-dropped).
740        assert_eq!(
741            forbidden_token_in_lemma("theorem t : P := by\n  first | simp | sorry"),
742            Some("sorry")
743        );
744        // Words inside `--` comments don't trip the scan.
745        assert_eq!(
746            forbidden_token_in_lemma("theorem t : True := by\n  trivial -- no axiom here"),
747            None
748        );
749        // A second `theorem` cannot hide inside a block either.
750        assert_eq!(
751            forbidden_token_in_lemma("theorem t : True := by\n  trivial\n  theorem u : True"),
752            Some("theorem")
753        );
754    }
755
756    #[test]
757    fn plan_ignores_lemmas_with_no_program_connection() {
758        with_inputs(SRC, |inputs| {
759            let mut ir = ProofIR::default();
760            crate::codegen::proof_lower::populate_law_theorems(inputs, &mut ir);
761            // A lemma mentioning NO program fn (pure builtin algebra) carries
762            // no connection to the program — never pinned.
763            let lemmas = vec![CommittedLemma {
764                name: "free_floating".to_string(),
765                text: "theorem free_floating (a : Nat) : a + 0 = a := by simp".to_string(),
766                embed: true,
767            }];
768            assert!(plan_simp_over_lemma_pins(inputs, &ir, &lemmas).is_empty());
769        });
770    }
771}