Skip to main content

aver/codegen/lean/
untranslate.rs

1//! `aver proof --explain`: Lean goal JSON → `ast::Expr` (the bilingual-out
2//! layer). This is the INVERSE of the Aver → Lean expression translator
3//! (`super::expr`), defined ONLY on the closed grammar of Expr shapes our own
4//! emitter produces: literals, `BinOp` arithmetic/comparisons, `Eq` claims,
5//! module-qualified function calls, and `∀`-binders. Anything outside that
6//! grammar — a `match`, a lambda, a bare projection, a constructor, an
7//! unrecognised head constant — is an HONEST DECLINE ([`EngineGap`]), never a
8//! guess: the caller renders it as an "engine-form gap" verdict rather than a
9//! misleading candidate law.
10//!
11//! The JSON is produced by the [`AVER_DUMP_GOAL_ELAB`] meta-tactic, appended to
12//! the residual probe under `--explain` (a second, fail-soft stage of the probe
13//! in `src/main/commands.rs`). The driver therefore reads a STRUCTURED goal, not
14//! Lean's human-facing pretty-printer.
15//!
16//! Identity note (identity_guardrails): this module is a pure JSON → syntax
17//! renderer. It maps Lean *surface names* back to Aver *surface names* for
18//! display / re-parse; it makes no identity-routing decision and holds no
19//! `FnId`/`TypeId`-keyed table, so the typed-identity invariant does not apply.
20
21use serde_json::Value;
22
23use crate::ast::{BinOp, Expr, Literal, Spanned, TopLevel, VerifyKind};
24
25/// The Lean 4 meta-tactic emitted into the `--explain` residual probe file. It
26/// serialises the current goal (abstracted over its local context into a closed
27/// `∀`-Prop) to OUR JSON via the info log, tagged `AVER_GOAL_JSON:<fn.law>:…`.
28/// Lives in the generated probe file (not `AverCommon`) so the `import Lean`
29/// meta dependency is confined to the opt-in `--explain` path and never taxes a
30/// normal proof build.
31pub const AVER_DUMP_GOAL_ELAB: &str = r#"import Lean
32open Lean Elab Tactic Meta in
33private def averJsonEsc (s : String) : String := Id.run do
34  let mut out := ""
35  for c in s.toList do
36    out := out ++ (match c with
37      | '"' => "\\\""
38      | '\\' => "\\\\"
39      | '\n' => "\\n"
40      | '\r' => "\\r"
41      | '\t' => "\\t"
42      | c => toString c)
43  return out
44open Lean in
45private partial def averExprJson (names : List String) (e : Expr) : String :=
46  match e with
47  | .forallE n t b _ =>
48      let nm := toString n
49      "{\"forall\":{\"name\":\"" ++ averJsonEsc nm ++ "\",\"ty\":" ++ averExprJson names t
50        ++ ",\"body\":" ++ averExprJson (nm :: names) b ++ "}}"
51  | .const n _ => "{\"const\":\"" ++ averJsonEsc (toString n) ++ "\"}"
52  | .bvar i =>
53      match names[i]? with
54      | some nm => "{\"var\":\"" ++ averJsonEsc nm ++ "\"}"
55      | none => "{\"opaque\":\"bvar\"}"
56  | .lit (.natVal v) => "{\"nat\":\"" ++ toString v ++ "\"}"
57  | .lit (.strVal s) => "{\"str\":\"" ++ averJsonEsc s ++ "\"}"
58  | .mdata _ e => averExprJson names e
59  | .app .. =>
60      let fn := e.getAppFn
61      let args := e.getAppArgs
62      let argsJson := String.intercalate "," (args.toList.map (averExprJson names))
63      "{\"app\":{\"fn\":" ++ averExprJson names fn ++ ",\"args\":[" ++ argsJson ++ "]}}"
64  | .proj s i e =>
65      "{\"proj\":{\"struct\":\"" ++ averJsonEsc (toString s) ++ "\",\"idx\":" ++ toString i
66        ++ ",\"e\":" ++ averExprJson names e ++ "}}"
67  | _ => "{\"opaque\":\"other\"}"
68open Lean Elab Tactic Meta in
69elab "aver_dump_goal " s:str : tactic => do
70  match (← getUnsolvedGoals) with
71  | [] => pure ()
72  | g :: _ =>
73    g.withContext do
74      let ty ← instantiateMVars (← g.getType)
75      let fvars ← (← getLCtx).foldlM (init := (#[] : Array Expr)) fun acc d =>
76        pure (if d.isImplementationDetail then acc else acc.push d.toExpr)
77      let closed ← instantiateMVars (← mkForallFVars fvars ty)
78      logInfo m!"AVER_GOAL_JSON:{s.getString}:{averExprJson [] closed}"
79"#;
80
81/// The info-log marker the elaborator prefixes each dumped goal with. The driver
82/// greps `lake env lean` output for lines carrying it and slices the JSON tail.
83pub const GOAL_JSON_MARKER: &str = "AVER_GOAL_JSON:";
84
85/// An honest decline: the goal expr contains a shape outside the closed grammar
86/// our translator emits, so no faithful Aver law can be reconstructed. Rendered
87/// as an "engine-form gap" verdict, NOT a guessed candidate.
88#[derive(Debug, Clone, PartialEq)]
89pub struct EngineGap {
90    pub reason: String,
91}
92
93impl EngineGap {
94    fn new(reason: impl Into<String>) -> Self {
95        EngineGap {
96            reason: reason.into(),
97        }
98    }
99}
100
101/// A goal un-translated back to Aver-space: the `∀`-binders split into data
102/// GIVENS (name + Aver type name) and PROP premises (surviving hypotheses, e.g.
103/// an induction hypothesis, rendered as Aver `Bool` expressions for `when`),
104/// plus the equality CLAIM `(lhs, rhs)`.
105#[derive(Debug, Clone)]
106pub struct UntranslatedGoal {
107    /// `(binder name, Aver type name)` for each data `∀`-binder — the law's
108    /// givens.
109    pub givens: Vec<(String, String)>,
110    /// Surviving hypothesis binders as Aver `Bool` expressions — the law's
111    /// `when` premises.
112    pub premises: Vec<Spanned<Expr>>,
113    /// The equality claim `(lhs, rhs)`.
114    pub claim: (Spanned<Expr>, Spanned<Expr>),
115}
116
117fn sp(e: Expr) -> Spanned<Expr> {
118    Spanned::new(e, 0)
119}
120
121/// Context threaded from the `--explain` render site. `peano` is set when the
122/// law being un-translated has a given whose type is a canonical-Peano ADT
123/// (`T { Zero; Succ(T) }`, shape-detected — any name, not just literal `Nat`).
124/// The transpiler lifts such a type's VALUES to Lean `Nat` (`Zero→0`,
125/// `Succ x→x + 1`), so the dumped goal speaks `Nat`. Inverting that
126/// deterministic lift here — `0→T.Zero`, `x + 1 / Nat.succ x→T.Succ x`, literal
127/// `n≤8→Succ^n(Zero)` — stays inside the translator image (it undoes OUR OWN
128/// lift, not a guess). Nat truncated subtraction / mul / div and any non-`+1`
129/// Nat arithmetic stay OUT of grammar, so the #630 boundary against foreign Nat
130/// semantics is untouched. `None` (no Peano given) = exactly the pre-V2 behavior.
131#[derive(Debug, Clone, Default)]
132pub struct UntranslateCtx {
133    pub peano: Option<PeanoCtx>,
134}
135
136/// The canonical-Peano ADT (surface type + constructor names) whose Lean-`Nat`
137/// lift the un-translator inverts. Constructor names are DATA carried from the
138/// law's declaration; the machinery keys on the Peano SHAPE, never on the names.
139#[derive(Debug, Clone)]
140pub struct PeanoCtx {
141    pub type_name: String,
142    pub zero_ctor: String,
143    pub succ_ctor: String,
144}
145
146/// Build the [`UntranslateCtx`] for a `<fn>.<law>` under `--explain`: if the
147/// law's givens include a canonical-Peano ADT (shape-detected via
148/// [`detect_canonical_peano`](crate::codegen::proof_recognize::detect_canonical_peano)),
149/// thread its constructor names so the un-translator can invert the `Nat` lift.
150/// Name-blind: keys on the Peano SHAPE, never on the type being called `Nat`.
151pub fn peano_ctx_for_law(items: &[TopLevel], fn_name: &str, law_name: &str) -> UntranslateCtx {
152    let peanos: Vec<_> = items
153        .iter()
154        .filter_map(|it| match it {
155            TopLevel::TypeDef(td) => crate::codegen::proof_recognize::detect_canonical_peano(td),
156            _ => None,
157        })
158        .collect();
159    if peanos.is_empty() {
160        return UntranslateCtx::default();
161    }
162    for it in items {
163        if let TopLevel::Verify(vb) = it
164            && vb.fn_name == fn_name
165            && let VerifyKind::Law(law) = &vb.kind
166            && law.name == law_name
167        {
168            for g in &law.givens {
169                for tok in type_name_tokens(&g.type_name) {
170                    if let Some(p) = peanos.iter().find(|p| p.type_name == tok) {
171                        return UntranslateCtx {
172                            peano: Some(PeanoCtx {
173                                type_name: p.type_name.clone(),
174                                zero_ctor: p.base_ctor.clone(),
175                                succ_ctor: p.succ_ctor.clone(),
176                            }),
177                        };
178                    }
179                }
180            }
181        }
182    }
183    UntranslateCtx::default()
184}
185
186/// The identifier tokens of a surface type string (`List<Nat>` → [`List`,
187/// `Nat`]), so a Peano element type is found regardless of container nesting.
188fn type_name_tokens(ty: &str) -> Vec<String> {
189    ty.split(|c: char| !c.is_alphanumeric() && c != '_' && c != '.')
190        .filter(|s| !s.is_empty())
191        .map(str::to_string)
192        .collect()
193}
194
195/// Parse the goal JSON emitted by [`AVER_DUMP_GOAL_ELAB`] into an
196/// [`UntranslatedGoal`], or decline with an [`EngineGap`] if any node is outside
197/// the closed grammar.
198pub fn untranslate_goal(json: &str) -> Result<UntranslatedGoal, EngineGap> {
199    untranslate_goal_ctx(json, &UntranslateCtx::default())
200}
201
202/// As [`untranslate_goal`], threading an [`UntranslateCtx`] so a canonical-Peano
203/// law's `Nat`-lift is inverted (see [`UntranslateCtx`]).
204pub fn untranslate_goal_ctx(
205    json: &str,
206    ctx: &UntranslateCtx,
207) -> Result<UntranslatedGoal, EngineGap> {
208    let v: Value = serde_json::from_str(json)
209        .map_err(|e| EngineGap::new(format!("malformed goal JSON: {e}")))?;
210    let mut givens: Vec<(String, String)> = Vec::new();
211    let mut premises: Vec<Spanned<Expr>> = Vec::new();
212    let mut cur = &v;
213    // Peel the `∀`-prefix: each binder is a vacuous split hypothesis (dropped), a
214    // Prop premise (Eq / comparison → `when`), or a data given (nameable type).
215    while let Some(fa) = cur.get("forall") {
216        let name = fa
217            .get("name")
218            .and_then(Value::as_str)
219            .ok_or_else(|| EngineGap::new("forall binder without a name"))?;
220        let ty = fa
221            .get("ty")
222            .ok_or_else(|| EngineGap::new("forall binder without a type"))?;
223        let body = fa
224            .get("body")
225            .ok_or_else(|| EngineGap::new("forall binder without a body"))?;
226        if is_vacuous_prop(ty) {
227            // A trivially true (`True`, `x = x`) or contradictory (`¬(true =
228            // true)`) split-branch hypothesis — drop it. A tautology premise is
229            // redundant; a contradictory branch is vacuous, so dropping only
230            // strengthens the residual, which the VM sample-check then judges.
231        } else if is_prop_type(ty) {
232            // A surviving hypothesis — render it as an Aver Bool expr for `when`.
233            premises.push(untranslate_bool(ty, ctx)?);
234        } else {
235            let tn = aver_type_name(ty, ctx).ok_or_else(|| {
236                EngineGap::new(format!(
237                    "binder `{name}` has a type outside the grammar (cannot name it in Aver)"
238                ))
239            })?;
240            givens.push((name.to_string(), tn));
241        }
242        cur = body;
243    }
244    // The residual claim must be an equality (the law's `lhs => rhs`).
245    let (lhs, rhs) = untranslate_eq(cur, ctx)
246        .ok_or_else(|| EngineGap::new("goal claim is not an equality our grammar renders"))?;
247    Ok(UntranslatedGoal {
248        givens,
249        premises,
250        claim: (lhs?, rhs?),
251    })
252}
253
254/// The Aver `Bool` expression for a Prop-typed binder (`when` premise): an
255/// equality becomes `a == b`, a comparison becomes `a < b` etc.
256fn untranslate_bool(v: &Value, ctx: &UntranslateCtx) -> Result<Spanned<Expr>, EngineGap> {
257    if let Some((l, r)) = untranslate_eq(v, ctx) {
258        return Ok(sp(Expr::BinOp(BinOp::Eq, Box::new(l?), Box::new(r?))));
259    }
260    // A split else-branch hypothesis `¬(<boolExpr> = b)` (non-constant, so not
261    // dropped as vacuous) → the `when`-conjunct `<boolExpr> != b`. The Bool↔Prop
262    // bridge for the false side of an `if <boolFn> …` split.
263    if let Some(inner) = negated_eq_inner(v)
264        && let Some((l, r)) = untranslate_eq(inner, ctx)
265    {
266        return Ok(sp(Expr::BinOp(BinOp::Neq, Box::new(l?), Box::new(r?))));
267    }
268    if let Some(app) = v.get("app")
269        && let Some(op) = comparison_binop(head_const(app))
270    {
271        require_int_carrier(app)?;
272        let args = app_operands(app, 2)?;
273        return Ok(sp(Expr::BinOp(
274            op,
275            Box::new(untranslate_expr(&args[0], ctx)?),
276            Box::new(untranslate_expr(&args[1], ctx)?),
277        )));
278    }
279    Err(EngineGap::new(
280        "premise hypothesis is not an equality/comparison our grammar renders",
281    ))
282}
283
284/// If `v` is `@Eq _ a b`, return the two operand JSON nodes untranslated.
285#[allow(clippy::type_complexity)]
286fn untranslate_eq(
287    v: &Value,
288    ctx: &UntranslateCtx,
289) -> Option<(
290    Result<Spanned<Expr>, EngineGap>,
291    Result<Spanned<Expr>, EngineGap>,
292)> {
293    let app = v.get("app")?;
294    if head_const(app) != Some("Eq") {
295        return None;
296    }
297    let args = app.get("args")?.as_array()?;
298    let n = args.len();
299    if n < 2 {
300        return None;
301    }
302    Some((
303        untranslate_expr(&args[n - 2], ctx),
304        untranslate_expr(&args[n - 1], ctx),
305    ))
306}
307
308/// Recursive descent JSON node → `ast::Expr`, declining on any out-of-grammar
309/// shape.
310fn untranslate_expr(v: &Value, ctx: &UntranslateCtx) -> Result<Spanned<Expr>, EngineGap> {
311    if let Some(nat) = v.get("nat").and_then(Value::as_str) {
312        if let Some(p) = &ctx.peano {
313            return peano_numeral_from_str(p, nat).map(sp);
314        }
315        return Ok(sp(int_literal(nat)));
316    }
317    if let Some(s) = v.get("str").and_then(Value::as_str) {
318        return Ok(sp(Expr::Literal(Literal::Str(s.to_string()))));
319    }
320    if let Some(name) = v.get("var").and_then(Value::as_str) {
321        return Ok(sp(Expr::Ident(name.to_string())));
322    }
323    if let Some(name) = v.get("const").and_then(Value::as_str) {
324        return Ok(sp(const_to_expr(name)));
325    }
326    if let Some(app) = v.get("app") {
327        return untranslate_app(app, ctx);
328    }
329    if let Some(o) = v.get("opaque").and_then(Value::as_str) {
330        return Err(EngineGap::new(format!("goal contains a `{o}` node")));
331    }
332    if v.get("forall").is_some() {
333        return Err(EngineGap::new("nested quantifier in the goal claim"));
334    }
335    if v.get("proj").is_some() {
336        return Err(EngineGap::new("field projection outside the grammar"));
337    }
338    Err(EngineGap::new("unrecognised goal node"))
339}
340
341fn untranslate_app(app: &Value, ctx: &UntranslateCtx) -> Result<Spanned<Expr>, EngineGap> {
342    let head = head_const(app);
343    // Numeric literal: `@OfNat.ofNat _ n _` — the middle arg carries the nat.
344    if head == Some("OfNat.ofNat") {
345        let nat = app
346            .get("args")
347            .and_then(Value::as_array)
348            .and_then(|args| {
349                args.iter()
350                    .find_map(|a| a.get("nat").and_then(Value::as_str))
351            })
352            .ok_or_else(|| EngineGap::new("OfNat literal without a nat operand"))?;
353        // Under a canonical-Peano law, a `Nat`-carried literal is a lifted Peano
354        // numeral — invert to `Succ^n(Zero)`; an `Int` literal stays as-is.
355        if let Some(p) = &ctx.peano
356            && carrier_const(app) == Some("Nat")
357        {
358            return peano_numeral_from_str(p, nat).map(sp);
359        }
360        return Ok(sp(int_literal(nat)));
361    }
362    // Canonical-Peano successor constructor `Nat.succ x` → `T.Succ(x)`.
363    if head == Some("Nat.succ")
364        && let Some(p) = &ctx.peano
365    {
366        let ops = app_operands(app, 1)?;
367        return Ok(sp(peano_succ(p, untranslate_expr(&ops[0], ctx)?)));
368    }
369    // Negation: `@Neg.neg _ _ a` (Int only — Nat has no negation).
370    if head == Some("Neg.neg") {
371        require_int_carrier(app)?;
372        let args = app_operands(app, 1)?;
373        return Ok(sp(Expr::Neg(Box::new(untranslate_expr(&args[0], ctx)?))));
374    }
375    // Numeric coercions have no Aver surface: Aver `Int` is ℤ and there is no
376    // `Nat`, so a `Nat`-carried value must never be laundered into an Aver
377    // `Int` — that is exactly what would produce a lying sample verdict.
378    if is_numeric_coercion(head) {
379        return Err(EngineGap::new("numeric coercion outside the grammar"));
380    }
381    // Heterogeneous / homogeneous arithmetic — operands are the last two args.
382    // Type-aware: only the `Int` carrier maps to an Aver operator (Nat
383    // truncated subtraction differs from Aver's Int=ℤ).
384    if let Some(op) = arith_binop(head) {
385        // Canonical-Peano successor: `Nat`-carried `x + 1` inverts our
386        // `Succ x → x + 1` value lift. Other `Nat` arithmetic (non-`+1`,
387        // sub/mul/div) still declines via `require_int_carrier` below — the #630
388        // boundary against foreign Nat semantics is preserved.
389        if let Some(p) = &ctx.peano
390            && matches!(head, Some("HAdd.hAdd") | Some("Add.add"))
391            && carrier_const(app) == Some("Nat")
392        {
393            let ops = app_operands(app, 2)?;
394            if nat_lit_value(&ops[1]) == Some(1) {
395                return Ok(sp(peano_succ(p, untranslate_expr(&ops[0], ctx)?)));
396            }
397        }
398        require_int_carrier(app)?;
399        let args = app_operands(app, 2)?;
400        return Ok(sp(Expr::BinOp(
401            op,
402            Box::new(untranslate_expr(&args[0], ctx)?),
403            Box::new(untranslate_expr(&args[1], ctx)?),
404        )));
405    }
406    if let Some(op) = comparison_binop(head) {
407        require_int_carrier(app)?;
408        let args = app_operands(app, 2)?;
409        return Ok(sp(Expr::BinOp(
410            op,
411            Box::new(untranslate_expr(&args[0], ctx)?),
412            Box::new(untranslate_expr(&args[1], ctx)?),
413        )));
414    }
415    if head == Some("Eq") {
416        return Err(EngineGap::new("equality nested inside the claim"));
417    }
418    // List builtins in our translator's image: `[x]` / `x :: xs` / `a ++ b`
419    // spell back as Aver list literals and `List.concat`.
420    if head == Some("List.nil") {
421        return Ok(sp(Expr::List(vec![])));
422    }
423    if head == Some("List.cons") {
424        // `@List.cons _ a b` → `List.concat([a], b)`.
425        let ops = app_operands(app, 2)?;
426        let a = untranslate_expr(&ops[0], ctx)?;
427        let b = untranslate_expr(&ops[1], ctx)?;
428        return Ok(sp(Expr::FnCall(
429            Box::new(sp(Expr::Ident("List.concat".to_string()))),
430            vec![sp(Expr::List(vec![a])), b],
431        )));
432    }
433    if matches!(
434        head,
435        Some("HAppend.hAppend") | Some("Append.append") | Some("List.append")
436    ) {
437        // `a ++ b` → `List.concat(a, b)`.
438        let ops = app_operands(app, 2)?;
439        return Ok(sp(Expr::FnCall(
440            Box::new(sp(Expr::Ident("List.concat".to_string()))),
441            vec![
442                untranslate_expr(&ops[0], ctx)?,
443                untranslate_expr(&ops[1], ctx)?,
444            ],
445        )));
446    }
447    // Otherwise: a user / module function applied to arguments. The head must be
448    // a plain const (a higher-order applied variable is out of grammar).
449    let Some(name) = head else {
450        return Err(EngineGap::new(
451            "application head is not a named function (higher-order)",
452        ));
453    };
454    let all = app
455        .get("args")
456        .and_then(Value::as_array)
457        .ok_or_else(|| EngineGap::new("application without args"))?;
458    let mut args = Vec::with_capacity(all.len());
459    for a in all {
460        args.push(untranslate_expr(a, ctx)?);
461    }
462    Ok(sp(Expr::FnCall(Box::new(sp(const_to_expr(name))), args)))
463}
464
465/// The head constant name of an `app` JSON node, if the head is a `const`.
466fn head_const(app: &Value) -> Option<&str> {
467    app.get("fn")?.get("const")?.as_str()
468}
469
470/// The last `n` args of an application (operands), declining if there are fewer.
471fn app_operands(app: &Value, n: usize) -> Result<Vec<Value>, EngineGap> {
472    let args = app
473        .get("args")
474        .and_then(Value::as_array)
475        .ok_or_else(|| EngineGap::new("application without args"))?;
476    if args.len() < n {
477        return Err(EngineGap::new(
478            "application has fewer operands than expected",
479        ));
480    }
481    Ok(args[args.len() - n..].to_vec())
482}
483
484fn arith_binop(head: Option<&str>) -> Option<BinOp> {
485    match head? {
486        "HAdd.hAdd" | "Add.add" => Some(BinOp::Add),
487        "HSub.hSub" | "Sub.sub" => Some(BinOp::Sub),
488        "HMul.hMul" | "Mul.mul" => Some(BinOp::Mul),
489        "HDiv.hDiv" | "Div.div" => Some(BinOp::Div),
490        _ => None,
491    }
492}
493
494fn comparison_binop(head: Option<&str>) -> Option<BinOp> {
495    match head? {
496        "LT.lt" => Some(BinOp::Lt),
497        "LE.le" => Some(BinOp::Lte),
498        "GT.gt" => Some(BinOp::Gt),
499        "GE.ge" => Some(BinOp::Gte),
500        "Ne" => Some(BinOp::Neq),
501        _ => None,
502    }
503}
504
505/// Numeric coercion heads (`↑`, `Int.ofNat`, …). These bridge Lean's `Nat`/`Int`
506/// hierarchy, which Aver does not have — there is no faithful Aver surface, so
507/// the un-translator declines rather than dropping the coercion silently.
508fn is_numeric_coercion(head: Option<&str>) -> bool {
509    matches!(
510        head,
511        Some(
512            "Nat.cast"
513                | "NatCast.natCast"
514                | "IntCast.intCast"
515                | "Int.ofNat"
516                | "Int.cast"
517                | "Int.toNat"
518        )
519    )
520}
521
522/// Gate an arithmetic / comparison / negation application to the `Int` carrier
523/// our operators are sound for. Lean elaborates these with the carrier type as
524/// the FIRST argument (`@HAdd.hAdd α β γ …`, `@LT.lt α …`, `@Neg.neg α …`).
525/// Aver has no `Nat` and Aver `Int` is ℤ, so mapping Nat arithmetic to an Aver
526/// operator would misread Nat truncated subtraction as real subtraction and
527/// invent a counterexample for a goal that is TRUE in Lean — decline instead.
528fn require_int_carrier(app: &Value) -> Result<(), EngineGap> {
529    match app
530        .get("args")
531        .and_then(Value::as_array)
532        .and_then(|a| a.first())
533        .and_then(|t| t.get("const"))
534        .and_then(Value::as_str)
535    {
536        Some("Int") => Ok(()),
537        Some("Nat") => Err(EngineGap::new(
538            "natural-number arithmetic (truncated subtraction differs from Aver's Int)",
539        )),
540        _ => Err(EngineGap::new(
541            "arithmetic over a non-Int carrier type outside the grammar",
542        )),
543    }
544}
545
546/// Map a Lean surface const to its Aver surface form. Built-in `Bool`/`Int`
547/// constructors we recognise map to Aver literals; everything else keeps its
548/// dotted module path, un-escaping the trailing-`'` Lean reserved-word guard on
549/// the final segment.
550fn const_to_expr(name: &str) -> Expr {
551    match name {
552        "Bool.true" | "True" => return Expr::Literal(Literal::Bool(true)),
553        "Bool.false" | "False" => return Expr::Literal(Literal::Bool(false)),
554        "List.nil" => return Expr::List(vec![]),
555        _ => {}
556    }
557    Expr::Ident(lean_dotted_to_aver(name))
558}
559
560/// Un-escape a (possibly dotted) Lean name back to its Aver spelling: strip the
561/// trailing-`'` reserved-word guard from the final segment.
562fn lean_dotted_to_aver(name: &str) -> String {
563    match name.rsplit_once('.') {
564        Some((prefix, last)) => format!("{prefix}.{}", super::expr::lean_name_to_aver(last)),
565        None => super::expr::lean_name_to_aver(name),
566    }
567}
568
569/// The Aver type name for a data `∀`-binder type node (`const Int`,
570/// `List X`, a user record const, …), or `None` if it cannot be named. Under a
571/// canonical-Peano law the lifted `Nat` maps back to the ADT's surface name `T`
572/// (identity when the ADT is literally called `Nat`, as in the witnesses).
573fn aver_type_name(v: &Value, ctx: &UntranslateCtx) -> Option<String> {
574    if let Some(name) = v.get("const").and_then(Value::as_str) {
575        return Some(match name {
576            "Int" => "Int".to_string(),
577            "Bool" => "Bool".to_string(),
578            "String" => "Str".to_string(),
579            "Nat" => match &ctx.peano {
580                Some(p) => p.type_name.clone(),
581                None => lean_dotted_to_aver("Nat"),
582            },
583            other => lean_dotted_to_aver(other),
584        });
585    }
586    if let Some(app) = v.get("app") {
587        // `List X` → `List<X>`.
588        if head_const(app) == Some("List") {
589            let args = app.get("args")?.as_array()?;
590            let inner = aver_type_name(args.last()?, ctx)?;
591            return Some(format!("List<{inner}>"));
592        }
593    }
594    None
595}
596
597/// True iff the binder type is a Prop we treat as a `when` premise: an equality,
598/// a comparison, or a negated equality (`¬(a = b)`, a split else-branch).
599fn is_prop_type(v: &Value) -> bool {
600    let Some(app) = v.get("app") else {
601        return false;
602    };
603    let head = head_const(app);
604    head == Some("Eq") || comparison_binop(head).is_some() || negated_eq_inner(v).is_some()
605}
606
607/// If `v` is `Not <inner>` with `<inner>` an `@Eq …`, return the inner Eq node.
608fn negated_eq_inner(v: &Value) -> Option<&Value> {
609    let app = v.get("app")?;
610    if head_const(app) != Some("Not") {
611        return None;
612    }
613    let inner = app.get("args")?.as_array()?.last()?;
614    if inner.get("app").map(head_const)? == Some("Eq") {
615        Some(inner)
616    } else {
617        None
618    }
619}
620
621fn int_literal(nat: &str) -> Expr {
622    match nat.parse::<i64>() {
623        Ok(n) => Expr::Literal(Literal::Int(n)),
624        // Past-i64 magnitude — keep the decimal digits (Aver `Int` is ℤ).
625        Err(_) => Expr::Literal(Literal::BigInt(nat.to_string())),
626    }
627}
628
629/// A `∀`-binder whose Prop is trivially true (`True`, `x = x`) or the negation
630/// of one (`¬(x = x)` — a contradictory split branch). Dropped rather than kept
631/// as a `when`: a tautology premise is redundant, a contradictory branch is
632/// vacuous, so either way the residual is at worst strengthened and the VM
633/// sample-check judges it. Not gated on the Peano ctx — it is a general cleanup.
634fn is_vacuous_prop(v: &Value) -> bool {
635    if v.get("const").and_then(Value::as_str) == Some("True") {
636        return true;
637    }
638    let Some(app) = v.get("app") else {
639        return false;
640    };
641    match head_const(app) {
642        Some("Eq") => eq_operands_identical(app),
643        Some("Not") => app
644            .get("args")
645            .and_then(Value::as_array)
646            .and_then(|a| a.last())
647            .is_some_and(is_vacuous_prop),
648        _ => false,
649    }
650}
651
652/// `@Eq _ a b` whose two operands are structurally identical JSON (`a = a`).
653fn eq_operands_identical(app: &Value) -> bool {
654    match app.get("args").and_then(Value::as_array) {
655        Some(args) if args.len() >= 2 => args[args.len() - 2] == args[args.len() - 1],
656        _ => false,
657    }
658}
659
660/// The literal `Nat` value of a node that is a bare `{"nat":n}` or an
661/// `@OfNat.ofNat _ n _` (used to recognise the `+1` successor), else `None`.
662fn nat_lit_value(v: &Value) -> Option<u64> {
663    if let Some(n) = v.get("nat").and_then(Value::as_str) {
664        return n.parse().ok();
665    }
666    let app = v.get("app")?;
667    if head_const(app) != Some("OfNat.ofNat") {
668        return None;
669    }
670    app.get("args")?
671        .as_array()?
672        .iter()
673        .find_map(|a| a.get("nat").and_then(Value::as_str))
674        .and_then(|n| n.parse().ok())
675}
676
677/// The carrier type const of an `@Op α …` application (its first argument).
678fn carrier_const(app: &Value) -> Option<&str> {
679    app.get("args")?.as_array()?.first()?.get("const")?.as_str()
680}
681
682/// The Peano base value `T.Zero`.
683fn peano_zero(p: &PeanoCtx) -> Expr {
684    Expr::Ident(format!("{}.{}", p.type_name, p.zero_ctor))
685}
686
687/// The Peano successor `T.Succ(x)`.
688fn peano_succ(p: &PeanoCtx, x: Spanned<Expr>) -> Expr {
689    Expr::FnCall(
690        Box::new(sp(Expr::Ident(format!("{}.{}", p.type_name, p.succ_ctor)))),
691        vec![x],
692    )
693}
694
695/// The Peano numeral `Succ^n(Zero)`; declines past 8 (nobody wants `Succ^300`).
696fn peano_numeral(p: &PeanoCtx, n: u64) -> Result<Expr, EngineGap> {
697    if n > 8 {
698        return Err(EngineGap::new(
699            "Peano numeral larger than 8 — declines rather than nesting Succ",
700        ));
701    }
702    let mut e = peano_zero(p);
703    for _ in 0..n {
704        e = peano_succ(p, sp(e));
705    }
706    Ok(e)
707}
708
709/// Parse a decimal string as a Peano numeral (see [`peano_numeral`]).
710fn peano_numeral_from_str(p: &PeanoCtx, nat: &str) -> Result<Expr, EngineGap> {
711    let n: u64 = nat
712        .parse()
713        .map_err(|_| EngineGap::new("Peano numeral is not a natural number"))?;
714    peano_numeral(p, n)
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720    use crate::ast::unparse;
721
722    /// Render an untranslated goal to a one-line Aver law body for assertions.
723    fn render(g: &UntranslatedGoal) -> String {
724        let mut out = String::new();
725        for (n, t) in &g.givens {
726            out.push_str(&format!("given {n}: {t}; "));
727        }
728        for p in &g.premises {
729            let mut buf = String::new();
730            unparse::write_expr_public(&mut buf, p, 0).unwrap();
731            out.push_str(&format!("when {buf}; "));
732        }
733        let mut l = String::new();
734        let mut r = String::new();
735        unparse::write_expr_public(&mut l, &g.claim.0, 0).unwrap();
736        unparse::write_expr_public(&mut r, &g.claim.1, 0).unwrap();
737        out.push_str(&format!("{l} => {r}"));
738        out
739    }
740
741    // `@Eq Int (@HAdd.hAdd Int Int Int i a b) (@HAdd.hAdd Int Int Int i b a)`
742    // — the elaborated form of `a + b = b + a` over Int.
743    fn eq_json(lhs: &str, rhs: &str) -> String {
744        format!(r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Int"}},{lhs},{rhs}]}}}}"#)
745    }
746    fn hadd(a: &str, b: &str) -> String {
747        format!(
748            r#"{{"app":{{"fn":{{"const":"HAdd.hAdd"}},"args":[{{"const":"Int"}},{{"const":"Int"}},{{"const":"Int"}},{{"opaque":"other"}},{a},{b}]}}}}"#
749        )
750    }
751    fn var(n: &str) -> String {
752        format!(r#"{{"var":"{n}"}}"#)
753    }
754    fn forall(name: &str, ty: &str, body: &str) -> String {
755        format!(r#"{{"forall":{{"name":"{name}","ty":{ty},"body":{body}}}}}"#)
756    }
757
758    #[test]
759    fn round_trips_int_commutativity() {
760        // ∀ (a : Int) (b : Int), a + b = b + a
761        let claim = eq_json(&hadd(&var("a"), &var("b")), &hadd(&var("b"), &var("a")));
762        let json = forall(
763            "a",
764            r#"{"const":"Int"}"#,
765            &forall("b", r#"{"const":"Int"}"#, &claim),
766        );
767        let g = untranslate_goal(&json).expect("in grammar");
768        assert_eq!(
769            g.givens,
770            vec![
771                ("a".to_string(), "Int".to_string()),
772                ("b".to_string(), "Int".to_string()),
773            ]
774        );
775        assert!(g.premises.is_empty());
776        assert_eq!(render(&g), "given a: Int; given b: Int; (a + b) => (b + a)");
777    }
778
779    #[test]
780    fn premise_becomes_when() {
781        // ∀ (a : Int), (0 <= a) → a + 0 = a  (the `<=` binder is a `when`)
782        let le = format!(
783            r#"{{"app":{{"fn":{{"const":"LE.le"}},"args":[{{"const":"Int"}},{{"opaque":"other"}},{},{}]}}}}"#,
784            r#"{"nat":"0"}"#,
785            var("a")
786        );
787        let claim = eq_json(&hadd(&var("a"), r#"{"nat":"0"}"#), &var("a"));
788        let json = forall("a", r#"{"const":"Int"}"#, &forall("h", &le, &claim));
789        let g = untranslate_goal(&json).expect("in grammar");
790        assert_eq!(g.givens, vec![("a".to_string(), "Int".to_string())]);
791        assert_eq!(render(&g), "given a: Int; when (0 <= a); (a + 0) => a");
792    }
793
794    #[test]
795    fn user_fn_call_round_trips() {
796        // ∀ (x : List<Int>), length(x) = length(x)  (const `length` applied)
797        let call = |arg: &str| format!(r#"{{"app":{{"fn":{{"const":"length"}},"args":[{arg}]}}}}"#);
798        let list_ty = r#"{"app":{"fn":{"const":"List"},"args":[{"const":"Int"}]}}"#;
799        let claim = format!(
800            r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Nat"}},{},{}]}}}}"#,
801            call(&var("x")),
802            call(&var("x"))
803        );
804        let json = forall("x", list_ty, &claim);
805        let g = untranslate_goal(&json).expect("in grammar");
806        assert_eq!(g.givens, vec![("x".to_string(), "List<Int>".to_string())]);
807        assert_eq!(render(&g), "given x: List<Int>; length(x) => length(x)");
808    }
809
810    #[test]
811    fn residual_maps_list_builtins_and_ih_premise() {
812        // The prop_07 (`length (qrev …)`) cons-arm residual shape, with the
813        // trailing arithmetic Int-carried so the grammar renders end-to-end
814        // (the real Nat-carried prop_07 declines — see
815        // `declines_nat_subtraction`): List.cons / List.nil / ++ must spell
816        // back through Aver `List.concat` + literals, and the induction
817        // hypothesis binder becomes a `when`.
818        let list_int = r#"{"app":{"fn":{"const":"List"},"args":[{"const":"Int"}]}}"#;
819        let inst = r#"{"opaque":"inst"}"#;
820        let length = |a: &str| format!(r#"{{"app":{{"fn":{{"const":"length"}},"args":[{a}]}}}}"#);
821        let qrev =
822            |a: &str, b: &str| format!(r#"{{"app":{{"fn":{{"const":"qrev"}},"args":[{a},{b}]}}}}"#);
823        let plus =
824            |a: &str, b: &str| format!(r#"{{"app":{{"fn":{{"const":"plus"}},"args":[{a},{b}]}}}}"#);
825        let nil =
826            format!(r#"{{"app":{{"fn":{{"const":"List.nil"}},"args":[{{"const":"Int"}}]}}}}"#);
827        // `[] ++ y`
828        let append = format!(
829            r#"{{"app":{{"fn":{{"const":"HAppend.hAppend"}},"args":[{list_int},{list_int},{list_int},{inst},{nil},{}]}}}}"#,
830            var("y")
831        );
832        // `head :: ([] ++ y)`
833        let cons = format!(
834            r#"{{"app":{{"fn":{{"const":"List.cons"}},"args":[{{"const":"Int"}},{},{append}]}}}}"#,
835            var("head")
836        );
837        // `plus (length tail) (length y) + 1`
838        let one = format!(
839            r#"{{"app":{{"fn":{{"const":"OfNat.ofNat"}},"args":[{{"const":"Nat"}},{{"nat":"1"}},{inst}]}}}}"#
840        );
841        let rhs = format!(
842            r#"{{"app":{{"fn":{{"const":"HAdd.hAdd"}},"args":[{{"const":"Int"}},{{"const":"Int"}},{{"const":"Int"}},{inst},{},{one}]}}}}"#,
843            plus(&length(&var("tail")), &length(&var("y")))
844        );
845        let claim = format!(
846            r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Int"}},{},{rhs}]}}}}"#,
847            length(&qrev(&var("tail"), &cons))
848        );
849        let ih_ty = format!(
850            r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Int"}},{},{}]}}}}"#,
851            length(&qrev(&var("tail"), &var("y"))),
852            plus(&length(&var("tail")), &length(&var("y")))
853        );
854        let json = forall(
855            "y",
856            list_int,
857            &forall(
858                "head",
859                r#"{"const":"Int"}"#,
860                &forall("tail", list_int, &forall("ih", &ih_ty, &claim)),
861            ),
862        );
863        let g = untranslate_goal(&json).expect("in grammar");
864        assert_eq!(
865            g.givens,
866            vec![
867                ("y".to_string(), "List<Int>".to_string()),
868                ("head".to_string(), "Int".to_string()),
869                ("tail".to_string(), "List<Int>".to_string()),
870            ]
871        );
872        assert_eq!(g.premises.len(), 1);
873        let r = render(&g);
874        assert!(
875            r.contains("when (length(qrev(tail, y)) == plus(length(tail), length(y)))"),
876            "{r}"
877        );
878        assert!(r.contains("List.concat([head], List.concat([], y))"), "{r}");
879        assert!(r.ends_with("=> (plus(length(tail), length(y)) + 1)"), "{r}");
880        assert!(!r.contains("List.cons") && !r.contains("hAppend"), "{r}");
881    }
882
883    #[test]
884    fn declines_out_of_grammar_lambda() {
885        // A claim whose operand is an opaque (e.g. lambda) node → engine gap.
886        let claim = eq_json(r#"{"opaque":"other"}"#, &var("a"));
887        let json = forall("a", r#"{"const":"Int"}"#, &claim);
888        let err = untranslate_goal(&json).expect_err("out of grammar");
889        assert!(err.reason.contains("other"), "{}", err.reason);
890    }
891
892    #[test]
893    fn declines_non_equality_claim() {
894        // Claim is a bare comparison, not an equality.
895        let claim = format!(
896            r#"{{"app":{{"fn":{{"const":"LE.le"}},"args":[{{"const":"Int"}},{{"opaque":"other"}},{},{}]}}}}"#,
897            var("a"),
898            var("a")
899        );
900        let json = forall("a", r#"{"const":"Int"}"#, &claim);
901        let err = untranslate_goal(&json).expect_err("not an equality");
902        assert!(err.reason.contains("equality"), "{}", err.reason);
903    }
904
905    #[test]
906    fn declines_nat_subtraction() {
907        // ∀ (a b : Nat), (a - b) + b = a. In Nat this is FALSE→saturating-true
908        // territory (2 - 5 = 0), but mapping `HSub.hSub Nat` to Aver's `-`
909        // (Int=ℤ) would invent a counterexample for a goal true in Lean, so the
910        // Nat carrier must decline rather than untranslate the subtraction.
911        let hsub = format!(
912            r#"{{"app":{{"fn":{{"const":"HSub.hSub"}},"args":[{{"const":"Nat"}},{{"const":"Nat"}},{{"const":"Nat"}},{{"opaque":"inst"}},{},{}]}}}}"#,
913            var("a"),
914            var("b")
915        );
916        let lhs = format!(
917            r#"{{"app":{{"fn":{{"const":"HAdd.hAdd"}},"args":[{{"const":"Nat"}},{{"const":"Nat"}},{{"const":"Nat"}},{{"opaque":"inst"}},{hsub},{}]}}}}"#,
918            var("b")
919        );
920        let claim = format!(
921            r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Nat"}},{lhs},{}]}}}}"#,
922            var("a")
923        );
924        let json = forall(
925            "a",
926            r#"{"const":"Nat"}"#,
927            &forall("b", r#"{"const":"Nat"}"#, &claim),
928        );
929        let err = untranslate_goal(&json).expect_err("Nat arithmetic must decline");
930        assert!(err.reason.contains("natural-number"), "{}", err.reason);
931    }
932
933    // ---- V2: Peano-inverse grammar (Wall A) + vacuous split-binder drop ----
934    //
935    // These feed the SALVAGED krok-0 dumps (`testdata/lemma_calc_krok0/*.json`,
936    // copied verbatim from the probe run) through the extended grammar. The hard
937    // gate is `peano_66_1_*`: p66_1 must reconstruct the FORCED lemma
938    // `when le(a,b); le(a, Nat.S(b)) => true` (modulo naming). Under the
939    // witnesses the Peano ADT is `type Nat { Z; S(Nat) }`.
940
941    const DUMP_P66_0: &str = include_str!("testdata/lemma_calc_krok0/p66_0.json");
942    const DUMP_P66_1: &str = include_str!("testdata/lemma_calc_krok0/p66_1.json");
943    const DUMP_P66_2: &str = include_str!("testdata/lemma_calc_krok0/p66_2.json");
944    const DUMP_P73_0: &str = include_str!("testdata/lemma_calc_krok0/p73_0.json");
945    const DUMP_P73_1: &str = include_str!("testdata/lemma_calc_krok0/p73_1.json");
946
947    /// The `type Nat { Z; S(Nat) }` Peano context the p66/p73 witnesses declare.
948    fn peano_nat() -> UntranslateCtx {
949        UntranslateCtx {
950            peano: Some(PeanoCtx {
951                type_name: "Nat".to_string(),
952                zero_ctor: "Z".to_string(),
953                succ_ctor: "S".to_string(),
954            }),
955        }
956    }
957
958    #[test]
959    fn peano_66_1_forces_successor_lemma() {
960        // THE STEP-0 GATE. p66_1 (the `isZ`-false branch, `len + 1`) must come
961        // back as the forced conditional lemma with `len xs + 1` inverted to
962        // `Nat.S(len xs)` and the contradictory `¬(true = true)` split binder
963        // dropped — leaving exactly the IH as the single `when`.
964        let g = untranslate_goal_ctx(DUMP_P66_1, &peano_nat()).expect("in grammar under Peano");
965        assert_eq!(
966            g.givens,
967            vec![("tail".to_string(), "List<Nat>".to_string())]
968        );
969        assert_eq!(g.premises.len(), 1, "only the IH survives as `when`");
970        assert_eq!(
971            render(&g),
972            "given tail: List<Nat>; \
973             when (le(len(filterZ(tail)), len(tail)) == true); \
974             le(len(filterZ(tail)), Nat.S(len(tail))) => true"
975        );
976    }
977
978    #[test]
979    fn peano_66_1_without_ctx_still_declines_nat_arithmetic() {
980        // Wall A is real: WITHOUT the Peano ctx, the same dump declines on the
981        // `Nat`-carried `+ 1` (the #630 boundary). The ctx is what unlocks it.
982        let err = untranslate_goal(DUMP_P66_1).expect_err("Nat `+1` declines without ctx");
983        assert!(err.reason.contains("natural-number"), "{}", err.reason);
984    }
985
986    #[test]
987    fn peano_66_0_drops_vacuous_binder_and_stays_in_grammar() {
988        // p66_0 (the `isZ`-true branch) carries a trivially-true `true = true`
989        // split binder — dropped — and no `+1`; it stays fully in grammar.
990        let g = untranslate_goal_ctx(DUMP_P66_0, &peano_nat()).expect("in grammar under Peano");
991        assert_eq!(
992            g.givens,
993            vec![("tail".to_string(), "List<Nat>".to_string())]
994        );
995        assert_eq!(g.premises.len(), 1, "IH survives, vacuous binder dropped");
996        let r = render(&g);
997        assert!(
998            r.ends_with("le(len(List.concat([], filterZ(tail))), len(tail)) => true"),
999            "{r}"
1000        );
1001        assert!(!r.contains("hAppend") && !r.contains("List.cons"), "{r}");
1002    }
1003
1004    #[test]
1005    fn peano_66_2_declines_on_blocked_match() {
1006        // p66_2 still has the unreduced blocked `if isZ … then … else …`: its
1007        // `ite` decidability condition (`false = true`) is a nested equality and
1008        // its `isZ.match_1` args are opaque (Wall B, cleaned in step 1's Lean-side
1009        // re-strip). Descent hits the nested-`Eq` condition first — honest decline.
1010        let err = untranslate_goal_ctx(DUMP_P66_2, &peano_nat()).expect_err("blocked match");
1011        assert!(err.reason.contains("equality nested"), "{}", err.reason);
1012    }
1013
1014    #[test]
1015    fn peano_73_1_declines_on_blocked_match() {
1016        // p73_1 likewise carries the unreduced `ite`/`isZ.match_1` residual.
1017        let err = untranslate_goal_ctx(DUMP_P73_1, &peano_nat()).expect_err("blocked match");
1018        assert!(err.reason.contains("equality nested"), "{}", err.reason);
1019    }
1020
1021    #[test]
1022    fn peano_73_0_in_grammar_but_still_carries_uncleaned_ite() {
1023        // p73_0's split condition already collapsed to `True`, so there is no
1024        // opaque node — but the `ite` shell survives (Wall B), so the residual
1025        // is a still-uncleaned candidate. Step 1's reduceIte re-strip removes it;
1026        // documented here as the honest step-0 measurement, not a gate.
1027        let g = untranslate_goal_ctx(DUMP_P73_0, &peano_nat()).expect("in grammar (messy)");
1028        let r = render(&g);
1029        assert!(r.contains("ite("), "expected uncleaned ite shell: {r}");
1030        // The `[Z]` singleton (`OfNat 0`) inverted to the Peano base `Nat.Z`.
1031        assert!(r.contains("Nat.Z"), "{r}");
1032    }
1033
1034    #[test]
1035    fn peano_numeral_nests_succ() {
1036        // A bare `Nat`-carried literal `2` inverts to `Nat.S(Nat.S(Nat.Z))`.
1037        let claim = format!(
1038            r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Nat"}},{},{}]}}}}"#,
1039            var("x"),
1040            ofnat_nat("2"),
1041        );
1042        let json = forall("x", r#"{"const":"Nat"}"#, &claim);
1043        let g = untranslate_goal_ctx(&json, &peano_nat()).expect("in grammar");
1044        assert_eq!(render(&g), "given x: Nat; x => Nat.S(Nat.S(Nat.Z))");
1045    }
1046
1047    #[test]
1048    fn peano_numeral_over_eight_declines() {
1049        let claim = format!(
1050            r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Nat"}},{},{}]}}}}"#,
1051            var("x"),
1052            ofnat_nat("9"),
1053        );
1054        let json = forall("x", r#"{"const":"Nat"}"#, &claim);
1055        let err = untranslate_goal_ctx(&json, &peano_nat()).expect_err("too large");
1056        assert!(err.reason.contains("larger than 8"), "{}", err.reason);
1057    }
1058
1059    fn ofnat_nat(n: &str) -> String {
1060        format!(
1061            r#"{{"app":{{"fn":{{"const":"OfNat.ofNat"}},"args":[{{"const":"Nat"}},{{"nat":"{n}"}},{{"opaque":"inst"}}]}}}}"#
1062        )
1063    }
1064
1065    #[test]
1066    fn negated_bool_equality_becomes_neq_when() {
1067        // A non-constant split else-branch `¬(f x = true)` → `when (f(x) != true)`
1068        // (the false side of an `if f x …` split); vacuous `¬(true = true)` still
1069        // drops via `is_vacuous_prop`, so only genuine hypotheses reach here.
1070        let f = |a: &str| format!(r#"{{"app":{{"fn":{{"const":"f"}},"args":[{a}]}}}}"#);
1071        let not_eq = format!(
1072            r#"{{"app":{{"fn":{{"const":"Not"}},"args":[{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Bool"}},{},{{"const":"Bool.true"}}]}}}}]}}}}"#,
1073            f(&var("x"))
1074        );
1075        let claim = format!(
1076            r#"{{"app":{{"fn":{{"const":"Eq"}},"args":[{{"const":"Bool"}},{},{{"const":"Bool.true"}}]}}}}"#,
1077            f(&var("x"))
1078        );
1079        let json = forall("x", r#"{"const":"Int"}"#, &forall("h", &not_eq, &claim));
1080        let g = untranslate_goal(&json).expect("in grammar");
1081        assert_eq!(g.givens, vec![("x".to_string(), "Int".to_string())]);
1082        assert_eq!(g.premises.len(), 1);
1083        assert_eq!(
1084            render(&g),
1085            "given x: Int; when (f(x) != true); f(x) => true"
1086        );
1087    }
1088
1089    #[test]
1090    fn type_name_tokens_splits_containers() {
1091        assert_eq!(type_name_tokens("List<Nat>"), vec!["List", "Nat"]);
1092        assert_eq!(type_name_tokens("Nat"), vec!["Nat"]);
1093        assert_eq!(type_name_tokens("Map<Str, Int>"), vec!["Map", "Str", "Int"]);
1094    }
1095
1096    #[test]
1097    fn peano_ctx_detected_from_real_prop_66_source() {
1098        // End-to-end for the ctx constructor: parse the real witness and confirm
1099        // the `filterLenLe` law's `List<Nat>` given resolves to the shape-detected
1100        // Peano ADT with its actual `Z`/`S` constructor names (name-blind: the
1101        // detector keyed on shape, the names are carried as data).
1102        let src = include_str!("../../../proof-corpus/tip/isaplanner-mono/prop_66.av");
1103        let items = crate::source::parse_source(src).expect("prop_66 parses");
1104        let ctx = peano_ctx_for_law(&items, "filterZ", "filterLenLe");
1105        let p = ctx.peano.expect("filterLenLe has a List<Nat> given");
1106        assert_eq!(p.type_name, "Nat");
1107        assert_eq!(p.zero_ctor, "Z");
1108        assert_eq!(p.succ_ctor, "S");
1109    }
1110
1111    #[test]
1112    fn peano_ctx_absent_when_no_peano_given() {
1113        // A law over Int only → no Peano ctx (pre-V2 behavior preserved).
1114        let src = "fn f(x: Int) -> Int\n    x\n\n\
1115                   verify f law idem\n        given x: Int = 1..3\n        f(x) => f(x)\n";
1116        let items = crate::source::parse_source(src).expect("parses");
1117        let ctx = peano_ctx_for_law(&items, "f", "idem");
1118        assert!(ctx.peano.is_none());
1119    }
1120}