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