1use std::collections::{BTreeMap, HashSet};
32
33use crate::ast::{BinOp, Expr, Spanned, TopLevel};
34
35use super::untranslate::UntranslatedGoal;
36
37#[derive(Debug, Clone)]
40pub enum CalcVerdict {
41 Lemma(Box<UntranslatedGoal>),
42 Decline(String),
43}
44
45pub struct CalcEnv {
50 ctors: HashSet<String>,
51 fn_ret: BTreeMap<String, String>,
52}
53
54impl CalcEnv {
55 pub fn from_items(items: &[TopLevel]) -> Self {
56 let mut ctors = HashSet::new();
57 let mut fn_ret = BTreeMap::new();
58 for it in items {
59 match it {
60 TopLevel::TypeDef(crate::ast::TypeDef::Sum { name, variants, .. }) => {
61 for v in variants {
62 ctors.insert(v.name.clone());
63 ctors.insert(format!("{name}.{}", v.name));
64 }
65 }
66 TopLevel::FnDef(f) => {
67 fn_ret.insert(f.name.clone(), f.return_type.clone());
68 }
69 _ => {}
70 }
71 }
72 CalcEnv { ctors, fn_ret }
73 }
74
75 fn is_ctor(&self, name: &str) -> bool {
76 self.ctors.contains(name)
77 || name
78 .rsplit_once('.')
79 .is_some_and(|(_, s)| self.ctors.contains(s))
80 }
81
82 fn fn_return(&self, name: &str) -> Option<&str> {
84 self.fn_ret.get(name).map(String::as_str)
85 }
86}
87
88pub fn calculate(
96 goal: &UntranslatedGoal,
97 env: &CalcEnv,
98 reserved: &HashSet<String>,
99) -> CalcVerdict {
100 if goal.premises.len() > 1 {
104 return CalcVerdict::Decline(format!(
105 "{} surviving premises (a `when` is one Bool expression)",
106 goal.premises.len()
107 ));
108 }
109 let mut g = goal.clone();
110
111 let substituted = subst_ih_once(&mut g);
113
114 if let Some(prem) = g.premises.first().cloned() {
116 match lift_antiunify(&mut g, &prem, env, reserved) {
117 Ok(()) => {}
118 Err(reason) => return CalcVerdict::Decline(reason),
119 }
120 }
121
122 let lifted_b = lift_opaque_subterms(&mut g, env, reserved);
124
125 rebuild_givens(&mut g, goal);
126
127 if !substituted && !lifted_b && g.claim == goal.claim && g.premises == goal.premises {
130 return CalcVerdict::Decline(
131 "residual is the parent claim itself (no decomposition — an executor gap)".to_string(),
132 );
133 }
134 CalcVerdict::Lemma(Box::new(g))
135}
136
137fn subst_ih_once(g: &mut UntranslatedGoal) -> bool {
143 let Some(prem) = g.premises.first() else {
144 return false;
145 };
146 let Expr::BinOp(BinOp::Eq, l, r) = &prem.node else {
147 return false;
148 };
149 let (l, r) = (l.node.clone(), r.node.clone());
150 let in_claim = contains_subtree(&g.claim.0.node, &l) || contains_subtree(&g.claim.1.node, &l);
151 if !in_claim || contains_subtree(&r, &l) {
152 return false;
153 }
154 substitute(&mut g.claim.0.node, &l, &r);
155 substitute(&mut g.claim.1.node, &l, &r);
156 g.premises.clear();
157 true
158}
159
160fn lift_antiunify(
168 g: &mut UntranslatedGoal,
169 prem: &Spanned<Expr>,
170 env: &CalcEnv,
171 reserved: &HashSet<String>,
172) -> Result<(), String> {
173 let Expr::BinOp(BinOp::Eq, pl, pr) = &prem.node else {
175 return Ok(()); };
177 let mut existing: HashSet<String> = g.givens.iter().map(|(n, _)| n.clone()).collect();
178 existing.extend(reserved.iter().cloned());
179 let mut fresh = FreshVars::new(existing);
180 let mut subst: Vec<(Expr, String)> = Vec::new();
181
182 let new_lhs = antiunify(&g.claim.0.node, &pl.node, env, &mut subst, &mut fresh)?;
186 let new_rhs = antiunify(&g.claim.1.node, &pr.node, env, &mut subst, &mut fresh)?;
187 if subst.is_empty() {
188 return Ok(()); }
190 g.claim.0.node = new_lhs;
191 g.claim.1.node = new_rhs;
192 let mut new_prem = prem.node.clone();
194 for (concrete, var) in &subst {
195 substitute(&mut new_prem, concrete, &Expr::Ident(var.clone()));
196 }
197 g.premises = vec![Spanned::new(new_prem, 0)];
198 for (concrete, var) in &subst {
200 let ty = lifted_type(concrete, env)
201 .ok_or_else(|| format!("cannot type the lifted variable `{var}`"))?;
202 g.givens.push((var.clone(), ty));
203 }
204 Ok(())
205}
206
207fn antiunify(
212 claim: &Expr,
213 prem: &Expr,
214 env: &CalcEnv,
215 subst: &mut Vec<(Expr, String)>,
216 fresh: &mut FreshVars,
217) -> Result<Expr, String> {
218 if claim == prem {
219 if is_liftable_atom_or_call(claim, env) {
223 return Ok(Expr::Ident(var_for(subst, claim, fresh)));
224 }
225 return Ok(claim.clone());
226 }
227 if let Some((ctor, inner)) = unary_ctor_app(claim, env) {
230 let g_inner = antiunify(inner, prem, env, subst, fresh)?;
231 return Ok(rewrap_ctor(ctor, g_inner));
232 }
233 if let (Some((h1, a1)), Some((h2, a2))) = (as_call(claim), as_call(prem))
235 && h1 == h2
236 && a1.len() == a2.len()
237 {
238 let mut args = Vec::with_capacity(a1.len());
239 for (c, p) in a1.iter().zip(a2.iter()) {
240 args.push(Spanned::new(
241 antiunify(&c.node, &p.node, env, subst, fresh)?,
242 0,
243 ));
244 }
245 return Ok(Expr::FnCall(
246 Box::new(Spanned::new(Expr::Ident(h1.to_string()), 0)),
247 args,
248 ));
249 }
250 if let (Expr::BinOp(o1, l1, r1), Expr::BinOp(o2, l2, r2)) = (claim, prem)
252 && o1 == o2
253 {
254 let l = antiunify(&l1.node, &l2.node, env, subst, fresh)?;
255 let r = antiunify(&r1.node, &r2.node, env, subst, fresh)?;
256 return Ok(Expr::BinOp(
257 *o1,
258 Box::new(Spanned::new(l, 0)),
259 Box::new(Spanned::new(r, 0)),
260 ));
261 }
262 Err("anti-unification diff needs a term outside the goal and IH".to_string())
263}
264
265fn lift_opaque_subterms(
271 g: &mut UntranslatedGoal,
272 env: &CalcEnv,
273 reserved: &HashSet<String>,
274) -> bool {
275 let mut lhs: Vec<Expr> = Vec::new();
278 collect_fn_apps(&g.claim.0.node, env, true, &mut lhs);
279 let mut rhs: Vec<Expr> = Vec::new();
280 collect_fn_apps(&g.claim.1.node, env, true, &mut rhs);
281
282 let mut both: Vec<Expr> = Vec::new();
285 for e in &lhs {
286 if rhs.iter().any(|r| r == e) && !both.iter().any(|b| b == e) {
287 both.push(e.clone());
288 }
289 }
290 let maximal: Vec<Expr> = both
291 .iter()
292 .filter(|e| {
293 !both
294 .iter()
295 .any(|other| other != *e && contains_subtree(other, e))
296 })
297 .cloned()
298 .collect();
299
300 let mut existing: HashSet<String> = g.givens.iter().map(|(n, _)| n.clone()).collect();
301 existing.extend(reserved.iter().cloned());
302 let mut fresh = FreshVars::new(existing);
303 let mut lifted = false;
304 for sub in maximal {
305 let Some(ty) = lifted_type(&sub, env) else {
306 continue; };
308 let var = fresh.next();
309 substitute(&mut g.claim.0.node, &sub, &Expr::Ident(var.clone()));
310 substitute(&mut g.claim.1.node, &sub, &Expr::Ident(var.clone()));
311 for p in &mut g.premises {
312 substitute(&mut p.node, &sub, &Expr::Ident(var.clone()));
313 }
314 g.givens.push((var, ty));
315 lifted = true;
316 }
317 lifted
318}
319
320fn collect_fn_apps(e: &Expr, env: &CalcEnv, top: bool, out: &mut Vec<Expr>) {
326 if !top && is_user_fn_call(e, env) {
327 out.push(e.clone());
328 }
329 for c in children(e) {
330 collect_fn_apps(&c.node, env, false, out);
331 }
332}
333
334struct FreshVars {
339 used: HashSet<String>,
340 idx: usize,
341}
342
343impl FreshVars {
344 fn new(used: HashSet<String>) -> Self {
345 FreshVars { used, idx: 0 }
346 }
347
348 fn next(&mut self) -> String {
349 loop {
350 let letter = (b'a' + (self.idx % 26) as u8) as char;
351 let round = self.idx / 26;
352 let name = if round == 0 {
353 letter.to_string()
354 } else {
355 format!("{letter}{round}")
356 };
357 self.idx += 1;
358 if !self.used.contains(&name) {
359 self.used.insert(name.clone());
360 return name;
361 }
362 }
363 }
364}
365
366fn var_for(subst: &mut Vec<(Expr, String)>, subterm: &Expr, fresh: &mut FreshVars) -> String {
368 if let Some((_, v)) = subst.iter().find(|(e, _)| e == subterm) {
369 return v.clone();
370 }
371 let v = fresh.next();
372 subst.push((subterm.clone(), v.clone()));
373 v
374}
375
376fn unary_ctor_app<'a>(e: &'a Expr, env: &CalcEnv) -> Option<(&'a str, &'a Expr)> {
378 if let Expr::FnCall(head, args) = e
379 && args.len() == 1
380 && let Expr::Ident(name) = &head.node
381 && env.is_ctor(name)
382 {
383 return Some((name.as_str(), &args[0].node));
384 }
385 None
386}
387
388fn rewrap_ctor(ctor: &str, inner: Expr) -> Expr {
389 Expr::FnCall(
390 Box::new(Spanned::new(Expr::Ident(ctor.to_string()), 0)),
391 vec![Spanned::new(inner, 0)],
392 )
393}
394
395fn as_call(e: &Expr) -> Option<(&str, &[Spanned<Expr>])> {
397 if let Expr::FnCall(head, args) = e
398 && let Expr::Ident(name) = &head.node
399 {
400 return Some((name.as_str(), args.as_slice()));
401 }
402 None
403}
404
405fn is_user_fn_call(e: &Expr, env: &CalcEnv) -> bool {
409 matches!(as_call(e), Some((name, _)) if !env.is_ctor(name) && lifted_head(name, env).is_some())
410}
411
412fn is_liftable_atom_or_call(e: &Expr, env: &CalcEnv) -> bool {
416 match e {
417 Expr::FnCall(..) => lifted_type(e, env).is_some(),
418 _ => false,
419 }
420}
421
422fn lifted_type(e: &Expr, env: &CalcEnv) -> Option<String> {
424 let (name, _) = as_call(e)?;
425 lifted_head(name, env).map(str::to_string)
426}
427
428fn lifted_head<'a>(name: &str, env: &'a CalcEnv) -> Option<&'a str> {
429 env.fn_return(name)
431 .or_else(|| name.rsplit_once('.').and_then(|(_, s)| env.fn_return(s)))
432}
433
434fn children(e: &Expr) -> Vec<&Spanned<Expr>> {
437 match e {
438 Expr::FnCall(head, args) => {
439 let mut v = vec![head.as_ref()];
440 v.extend(args.iter());
441 v
442 }
443 Expr::BinOp(_, a, b) => vec![a.as_ref(), b.as_ref()],
444 Expr::Neg(a) => vec![a.as_ref()],
445 Expr::List(xs) | Expr::Tuple(xs) => xs.iter().collect(),
446 Expr::Attr(b, _) => vec![b.as_ref()],
447 _ => vec![],
448 }
449}
450
451fn contains_subtree(hay: &Expr, needle: &Expr) -> bool {
454 hay == needle
455 || children(hay)
456 .iter()
457 .any(|c| contains_subtree(&c.node, needle))
458}
459
460fn substitute(e: &mut Expr, from: &Expr, to: &Expr) {
462 if e == from {
463 *e = to.clone();
464 return;
465 }
466 match e {
467 Expr::FnCall(head, args) => {
468 substitute(&mut head.node, from, to);
469 for a in args {
470 substitute(&mut a.node, from, to);
471 }
472 }
473 Expr::BinOp(_, a, b) => {
474 substitute(&mut a.node, from, to);
475 substitute(&mut b.node, from, to);
476 }
477 Expr::Neg(a) => substitute(&mut a.node, from, to),
478 Expr::List(xs) | Expr::Tuple(xs) => {
479 for x in xs {
480 substitute(&mut x.node, from, to);
481 }
482 }
483 Expr::Attr(b, _) => substitute(&mut b.node, from, to),
484 _ => {}
485 }
486}
487
488fn rebuild_givens(g: &mut UntranslatedGoal, original: &UntranslatedGoal) {
491 let mut used = HashSet::new();
492 collect_idents(&g.claim.0.node, &mut used);
493 collect_idents(&g.claim.1.node, &mut used);
494 for p in &g.premises {
495 collect_idents(&p.node, &mut used);
496 }
497 let orig_names: HashSet<&String> = original.givens.iter().map(|(n, _)| n).collect();
498 g.givens.retain(|(n, _)| used.contains(n));
499 g.givens.sort_by_key(|(n, _)| !orig_names.contains(n));
501}
502
503fn collect_idents(e: &Expr, out: &mut HashSet<String>) {
504 if let Expr::Ident(n) = e {
505 out.insert(n.clone());
506 }
507 for c in children(e) {
508 collect_idents(&c.node, out);
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::ast::unparse;
516 use crate::codegen::lean::untranslate::{PeanoCtx, UntranslateCtx, untranslate_goal_ctx};
517
518 fn render(g: &UntranslatedGoal) -> String {
519 let mut out = String::new();
520 for (n, t) in &g.givens {
521 out.push_str(&format!("given {n}: {t}; "));
522 }
523 for p in &g.premises {
524 let mut buf = String::new();
525 unparse::write_expr_public(&mut buf, p, 0).unwrap();
526 out.push_str(&format!("when {buf}; "));
527 }
528 let mut l = String::new();
529 let mut r = String::new();
530 unparse::write_expr_public(&mut l, &g.claim.0, 0).unwrap();
531 unparse::write_expr_public(&mut r, &g.claim.1, 0).unwrap();
532 out.push_str(&format!("{l} => {r}"));
533 out
534 }
535
536 fn nat_env() -> CalcEnv {
537 let src = "type Nat\n Z\n S(Nat)\n\n\
540 fn len(xs: List<Nat>) -> Nat\n Nat.Z\n\n\
541 fn le(x: Nat, y: Nat) -> Bool\n true\n\n\
542 fn filterZ(xs: List<Nat>) -> List<Nat>\n xs\n\n\
543 fn rev(xs: List<Nat>) -> List<Nat>\n xs\n";
544 CalcEnv::from_items(&crate::source::parse_source(src).expect("parses"))
545 }
546
547 fn peano_nat() -> UntranslateCtx {
548 UntranslateCtx {
549 peano: Some(PeanoCtx {
550 type_name: "Nat".to_string(),
551 zero_ctor: "Z".to_string(),
552 succ_ctor: "S".to_string(),
553 }),
554 }
555 }
556
557 const DUMP_P66_1: &str = include_str!("testdata/lemma_calc_krok0/p66_1.json");
558
559 #[test]
560 fn antiunify_forces_the_prop_66_successor_lemma() {
561 let goal = untranslate_goal_ctx(DUMP_P66_1, &peano_nat()).expect("in grammar");
567 let CalcVerdict::Lemma(l) = calculate(&goal, &nat_env(), &HashSet::new()) else {
568 panic!("expected a forced lemma");
569 };
570 assert_eq!(
571 render(&l),
572 "given a: Nat; given b: Nat; when (le(a, b) == true); le(a, Nat.S(b)) => true"
573 );
574 }
575
576 #[test]
577 fn fresh_vars_skip_parent_law_given_names() {
578 let goal = untranslate_goal_ctx(DUMP_P66_1, &peano_nat()).expect("in grammar");
584 let reserved: HashSet<String> = ["a".to_string()].into_iter().collect();
585 let CalcVerdict::Lemma(l) = calculate(&goal, &nat_env(), &reserved) else {
586 panic!("expected a forced lemma");
587 };
588 assert_eq!(
589 render(&l),
590 "given b: Nat; given c: Nat; when (le(b, c) == true); le(b, Nat.S(c)) => true"
591 );
592 }
593
594 #[test]
595 fn subst_consumes_ih_and_lifts_snoc_general_lemma() {
596 let concat = |a: &str, b: &str| {
601 format!(r#"{{"app":{{"fn":{{"const":"List.append"}},"args":[{a},{b}]}}}}"#)
602 };
603 let rev = |a: &str| format!(r#"{{"app":{{"fn":{{"const":"rev"}},"args":[{a}]}}}}"#);
604 let filterz = |a: &str| format!(r#"{{"app":{{"fn":{{"const":"filterZ"}},"args":[{a}]}}}}"#);
605 let tail = r#"{"var":"tail"}"#;
606 let zlist = r#"{"app":{"fn":{"const":"List.cons"},"args":[{"const":"Nat"},{"app":{"fn":{"const":"OfNat.ofNat"},"args":[{"const":"Nat"},{"nat":"0"},{"opaque":"i"}]}},{"app":{"fn":{"const":"List.nil"},"args":[{"const":"Nat"}]}}]}}"#;
607 let list_nat = r#"{"app":{"fn":{"const":"List"},"args":[{"const":"Nat"}]}}"#;
608 let ih = format!(
609 r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{list_nat},{},{}]}}}}"#,
610 rev(&filterz(tail)),
611 filterz(&rev(tail))
612 );
613 let claim = format!(
614 r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{list_nat},{},{}]}}}}"#,
615 concat(&rev(&filterz(tail)), zlist),
616 filterz(&concat(&rev(tail), zlist))
617 );
618 let json = format!(
619 r#"{{"forall":{{"name":"tail","ty":{list_nat},"body":{{"forall":{{"name":"ih","ty":{ih},"body":{claim}}}}}}}}}"#
620 );
621 let goal = untranslate_goal_ctx(&json, &peano_nat()).expect("in grammar");
622 let CalcVerdict::Lemma(l) = calculate(&goal, &nat_env(), &HashSet::new()) else {
623 panic!("expected a forced lemma");
624 };
625 let r = render(&l);
627 assert!(!r.contains("when"), "IH should be substituted away: {r}");
628 assert!(r.contains("given a: List<Nat>"), "{r}");
629 assert_eq!(
630 r,
631 "given a: List<Nat>; List.concat(filterZ(a), List.concat([Nat.Z], [])) \
632 => filterZ(List.concat(a, List.concat([Nat.Z], [])))"
633 );
634 }
635
636 #[test]
637 fn declines_when_diff_is_not_common_or_ctor_wrap() {
638 let f = |a: &str| format!(r#"{{"app":{{"fn":{{"const":"le"}},"args":[{a},{a}]}}}}"#);
641 let ih = format!(
642 r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Bool"}},{},{{"const":"Bool.true"}}]}}}}"#,
643 f(r#"{"var":"x"}"#)
644 );
645 let claim = format!(
646 r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Bool"}},{},{{"const":"Bool.true"}}]}}}}"#,
647 f(r#"{"var":"y"}"#)
648 );
649 let list_nat = r#"{"app":{"fn":{"const":"List"},"args":[{"const":"Nat"}]}}"#;
650 let json = format!(
651 r#"{{"forall":{{"name":"x","ty":{{"const":"Nat"}},"body":{{"forall":{{"name":"ih","ty":{ih},"body":{claim}}}}}}}}}"#
652 );
653 let _ = list_nat;
654 let goal = untranslate_goal_ctx(&json, &peano_nat()).expect("in grammar");
655 match calculate(&goal, &nat_env(), &HashSet::new()) {
656 CalcVerdict::Decline(reason) => {
657 assert!(
658 reason.contains("outside") || reason.contains("term"),
659 "{reason}"
660 );
661 }
662 CalcVerdict::Lemma(l) => panic!("should decline, got {}", render(&l)),
663 }
664 }
665}