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}