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}