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