Zulip Chat Archive

Stream: mathlib4

Topic: Mathlib causes failure in mutual recursive definition


Alex Keizer (Aug 04 2023 at 16:32):

I've run into an issue were a mutually recursive definition, without termination_by hint, suddenly fails to compile after importing mathlib.
Here is a MWE

import Mathlib.Algebra.Order.Ring.Canonical
import Mathlib.Data.Nat.Basic

instance : OrderedCancelAddCommMonoid  :=
  { Nat.commSemiring, Nat.linearOrder with
    lt := Nat.lt, add_le_add_left := @Nat.add_le_add_left,
    le_of_add_le_add_left := @Nat.le_of_add_le_add_left,
  }

-- The failure seems specific to that instance, if we have a different instance with `sorry`,
-- then the mutual def below will compile without failure
-- instance : OrderedCancelAddCommMonoid ℕ := sorry

mutual
  inductive Expr : Type
    | add : Nat  Com  Expr
    | zero : Expr

  inductive Com : Type
    | ret : Expr  Com
end


mutual
  -- `fail to show termination`, but only if the `OrderedCancelAddCommMonoid` instance is defined
  def Expr.eval : Expr  Nat
    | .add m c => m + c.eval
    | .zero => 0

  def Com.eval : Com  Nat
    | .ret e => e.eval
end

Oddly enough, the failure is tied to an instance of OrderedCancelAddCommMonoid ℕ. Without this instance, the mutual def compiles, but with the instance, Lean fails to show termination.

For reference, the misbehaving instance is implied by the LinearOrderedCommSemiring ℕ instance in Mathlib.Data.Nat.Order.Basic (so importing that file also causes the error by itself).

The workaround is easy, just adding an explicit termination hint, but I am very curious what is happening here. How does the existence of this typeclass instance cause a failure in the termination checker?

Scott Morrison (Aug 05 2023 at 03:40):

Briefly: the termination checker is running tactics, and my guess is that the import is changing the simp set, and thus the behaviour of a simp call. Not at a computer now, but have a look at WFTactics.lean in the Lean 4 repository to see the default behaviour.

Tobias Grosser (Aug 05 2023 at 08:38):

Thank you, @Scott Morrison. That's super helpful.

Tobias Grosser (Aug 05 2023 at 08:40):

I am currently trying to debug this. I will see if I can install a custom lean build and see if I can dbg_trace this. What I find interesting is that the error message is different from what I expect. I would have expected:

/-- Constructs a proof of decreasing along a well founded relation, by applying
lexicographic order lemmas and using `ts` to solve the base case. If it fails,
it prints a message to help the user diagnose an ill-founded recursive definition. -/
macro "decreasing_with " ts:tacticSeq : tactic =>
 `(tactic|
   (simp_wf
    repeat (first | apply Prod.Lex.right | apply Prod.Lex.left)
    repeat (first | apply PSigma.Lex.right | apply PSigma.Lex.left)
    first
    | done
    | $ts
    | fail "failed to prove termination, possible solutions:
  - Use `have`-expressions to prove the remaining goals
  - Use `termination_by` to specify a different well-founded relation
  - Use `decreasing_by` to specify your own tactic for discharging this kind of goal"))

Yet, we get the slightly different one about mutual inductives being the problem.

Tobias Grosser (Aug 05 2023 at 08:43):

I wonder if there is an easier way to debug this than installing my custom lean4 build and updating the prelude.

Tobias Grosser (Aug 05 2023 at 08:43):

Could I maybe copy some of the lean4 code in my own file, run some #eval line, and check there if the decreasing_with thing works?

Sebastian Ullrich (Aug 05 2023 at 08:49):

The elaborator will only show the error message from trying structural recursion (which does not exist for mutuals), you can force it to show the wf error using

termination_by _ x => sizeOf x

Sebastian Ullrich (Aug 05 2023 at 08:52):

No need to rebuild Lean, you can locally override decreasing_tactic: docs#tacticDecreasing_tactic

Tobias Grosser (Aug 05 2023 at 08:54):

These are the magic hints. Thank you.

Sebastian Ullrich (Aug 05 2023 at 08:55):

Combining the termination_by with trace.Meta.Tactic.simp.rewrite suggests that the instance enables an additional rewrite, which might screw up the goal for the remaining tactics

Tobias Grosser (Aug 05 2023 at 11:43):

OK. I made some mini-progress debugging this. This very example goes through if I add the following theorem:

@[simp]
theorem Nat.zero_lt_one_plus_n {n : Nat}:  0 < 1 + n := by sorry

I am not yet sure where things diverge, but I find it interesting that this trivial identity is not in the mathlib simp set.

Tobias Grosser (Aug 05 2023 at 11:50):

When trying to proof this identity, simp using:

theorem Nat.zero_lt_succ (n : Nat) : LT.lt 0 (succ n) := succ_le_succ (zero_le n)

does not work.

This is 0 < succ n, which seems to be pretty much 0 < 1 + n. I feel there is only some notation in the way.

Kevin Buzzard (Aug 05 2023 at 11:51):

succ n is n + 1

Tobias Grosser (Aug 05 2023 at 11:52):

I see: by rw [Nat.add_comm]; simp works.

Tobias Grosser (Aug 05 2023 at 11:53):

I am curious what's the mathlib solution to these trivial identities. Should we just rely on linarith or would it make sense to add a simpl lemma that turns 1 + n into n + 1?

Mario Carneiro (Aug 05 2023 at 11:56):

I think it is not clear when to use non-equalities in the simp set, because almost any theorem could be a simp set candidate in that case

Mario Carneiro (Aug 05 2023 at 11:56):

it does seem a bit like overfitting to have precisely 0 < 1 + n as a simp lemma

Tobias Grosser (Aug 05 2023 at 11:56):

In LLVM we an informal canonical form, where we always move constants to the right.

Mario Carneiro (Aug 05 2023 at 11:57):

what happens if you simp [add_comm]?

Mario Carneiro (Aug 05 2023 at 11:57):

simp also has a canonical form for AC rewriting, which it uses in permutation rules like this

Tobias Grosser (Aug 05 2023 at 11:57):

This means, we need to not have rewrites for '0 + m' and 'm + 0', but always first move the 0 to the right.

Tobias Grosser (Aug 05 2023 at 11:58):

Mario Carneiro said:

what happens if you simp [add_comm]?

This works.

Mario Carneiro (Aug 05 2023 at 11:58):

but it is not enabled by default, you have to pass add_comm, add_left_comm, add_assoc explicitly

Tobias Grosser (Aug 05 2023 at 11:58):

Interesting.

Tobias Grosser (Aug 05 2023 at 11:58):

I guess I was mostly curious why the termination proof broke for such a simple example.

Tobias Grosser (Aug 05 2023 at 11:59):

I am not sure if there is anything actionable to do in this case.

Mario Carneiro (Aug 05 2023 at 11:59):

The simp-ish thing used in termination proofs is a bit more powerful and should handle proofs of 0 < 1 + n

Tobias Grosser (Aug 05 2023 at 12:00):

It seemingly does not, no?

Mario Carneiro (Aug 05 2023 at 12:00):

because that's exactly the kind of goal that comes up in proofs about sizeOf

Mario Carneiro (Aug 05 2023 at 12:00):

I think something in the mathlib simp set messed it up

Tobias Grosser (Aug 05 2023 at 12:00):

OK.

Tobias Grosser (Aug 05 2023 at 12:00):

If I add @[simp] theorem x {m : Nat}: 0 < 1 + m := by sorry then it works again.

Mario Carneiro (Aug 05 2023 at 12:01):

without a simp marker?

Tobias Grosser (Aug 05 2023 at 12:01):

With.

Tobias Grosser (Aug 05 2023 at 12:03):

I am not sure how to debug this further. I can see that wrong simp lemmas may make simp run into a direction that avoids pattern to be found. But can adding simp lemmas in mathlib make simp less powerful for such a trivial identity?

Tobias Grosser (Aug 05 2023 at 12:04):

That is interesting.

Sebastian Ullrich (Aug 05 2023 at 12:06):

My best guess without digging further is that the additional simp lemma uses a representation of 0 that the final simp_arith doesn't recognize

Mario Carneiro (Aug 05 2023 at 12:08):

The subgoal generated by

set_option trace.Meta.Tactic.simp.rewrite true
mutual
  -- `fail to show termination`, but only if the `OrderedCancelAddCommMonoid` instance is defined
  def Expr.eval : Expr  Nat
    | .add m c => m + c.eval
    | .zero => 0

  def Com.eval : Com  Nat
    | .ret e => e.eval
end
decreasing_by
  simp_wf
  repeat (first | apply Prod.Lex.right | apply Prod.Lex.left)
  repeat (first | apply PSigma.Lex.right | apply PSigma.Lex.left)
  simp (config := { arith := true })
  done

is Zero.zero ≤ m, indicating that the Zero typeclass is causing problems

Tobias Grosser (Aug 05 2023 at 12:12):

Wow, that really shows me how to debug this.

Tobias Grosser (Aug 05 2023 at 12:17):

Interestingly, example {m : Nat }: Zero.zero ≤ m := by simp_arith fails with and without the OrderedCalcelAddCommMonoid. Given that this is as canonical as it gets, I guess this should be simplified.

Tobias Grosser (Aug 05 2023 at 14:21):

Maybe this is relevant: https://leanprover.zulipchat.com/#narrow/stream/287929-mathlib4/topic/0.F0.9F.94.B6.20.28.22zero.20diamond.22.29/near/257669888

Tobias Grosser (Aug 05 2023 at 14:21):

A diamond between ofNat and Zero

Tobias Grosser (Aug 05 2023 at 14:54):

OK. I have made some progress. It seems the following can be proven with:

example {m : Nat }: Zero.zero < 1 + m := by
  simp (config := {decide:= false, arith := true}) only [Zero.zero, Nat.zero_eq]

similarly I need

example {m : Nat }: Zero.zero < m + One.one := by
   simp only [Nat.zero_eq, Nat.succ_pos', Zero.zero, One.one]

to prove the following one.

It seems to me I need to include Zero.zero and One.one in my simp set explicitly to make theorems that contain these work. Is this typical. I find that somehow surprising.

Tobias Grosser (Aug 05 2023 at 15:05):

And indeed, adding attribute [simp] Zero.zero One.one makes the proof reliably go through.

Scott Morrison (Aug 05 2023 at 23:00):

I think instead you should be writing Zero.zero : ℕ back into 0 : ℕ, etc?

Tobias Grosser (Aug 06 2023 at 01:56):

What do you mean by

writing Zero.zero : ℕ back into 0 : ℕ,

I interpret that as you asking for a theorem that maps Zero.zero: ℕ back to 0: ℕ. I tested it and the following works as well:

@[simp]
theorem Nat_Zero_eq_zero : (Zero.zero : ) = (0 : ) :=
  by simp [Zero.zero]

@[simp]
theorem Nat_One_eq_one : (One.one: ) = (1 : ) :=
  by simp [One.one]

I am not surprised by it and assumed that @[simp] Zero.zero pretty much would do the same thing. So I suspect I misunderstood and wonder if you maybe meant sth different? In general, I am a little surprised that apparently typeclasses allow us to write statements generic over types that rely on Zero.zero and that smoothly generalize to Nat, and at the same time the typeclass gets in the way of such a trivial identity. I read over https://leanprover.github.io/theorem_proving_in_lean/type_classes.html, but did not find such cases mentioned. Is there maybe a better source to learn about type classes.

Chris Hughes (Aug 07 2023 at 11:52):

I've done some more investigation. It seems that it is simp { arith := true } that introduces the Zero.zero. This simp { arith := true } also has different behaviour depending on which 0 is used. If the instance OfNat Nat 0 comes via Zero.toOfNat0, then simp { arith := true } fails to prove 0 < 1 + m but it succeeds when the instance is instOfNatNat.

import Mathlib.Algebra.Order.Ring.Canonical
import Mathlib.Data.Nat.Basic

instance : OrderedCancelAddCommMonoid  :=
  { Nat.commSemiring, Nat.linearOrder with
    lt := Nat.lt, add_le_add_left := @Nat.add_le_add_left,
    le_of_add_le_add_left := @Nat.le_of_add_le_add_left,
  }

example (m c : ) : sizeOf c < 1 + m + sizeOf c := by
  simp only [lt_add_iff_pos_left]
  simp (config := { arith := true }) only -- goal is `Zero.zero ≤ m`

example (m : ) : 0 < 1 + m := by
  simp (config := { arith := true }) only --No goals

example (m : ) : (@OfNat.ofNat  0 (Zero.toOfNat0)) < 1 + m := by
  simp (config := { arith := true }) only -- goal is `Zero.zero ≤ m`

example (m : ) : (@OfNat.ofNat  0 (instOfNatNat 0)) < 1 + m := by
  simp (config := { arith := true }) only -- No goals

Chris Hughes (Aug 07 2023 at 11:55):

Where can I see the code for what simp (config := { arith := true }) only does?

Chris Hughes (Aug 07 2023 at 12:20):

My guess is that the problem is in toLinearExpr in Lean.Meta.Tactic.LinearArith.Nat.Basic

Tobias Grosser (Aug 07 2023 at 12:32):

It seems @Gabriel Ebner looked into this recently (a year ago)?

Chris Hughes (Aug 07 2023 at 12:34):

Maybe it's just a case of adding Zero.zero (and One.one) to isNatProjInstin Lean.Meta.Offset

Chris Hughes (Aug 07 2023 at 12:57):

I think what's going on is the following. Lean sees @OfNat.ofNat ℕ 0 (Zero.toOfNat0). It runs Lean.Meta.Linear.Nat.ToLinear.toLinearExpr. This function then calls isNatProjInst on the head symbol, and it is true because the head symbol is OfNat.ofNat which is in the list given by isNatProjInst. It then runs unfoldProjInst? on this, which I guess is where Zero.zero comes from. It doesn't unfold any further however, because Zero.zero is not in the list given in isNatProjInst.

Adding Zero.zero to the list in unfoldProjInst might fix this, but it seems a little hacky and unstable, because I still don't think it would work if the Zero instance itself came from AddMonoid or something.

Chris Hughes (Aug 07 2023 at 13:04):

Or maybe unfoldProjInst? is smart enough to keep unfolding in these cases. This seems likely, but I don't fully understand this function.

Mario Carneiro (Aug 07 2023 at 13:18):

one issue with this is that unfoldProjInst? is defined in lean core and Zero is defined in mathlib

Chris Hughes (Aug 07 2023 at 13:20):

But it doesn't matter because it's just checking the name. But yes it's ugly

Chris Hughes (Aug 07 2023 at 13:20):

Maybe Zero has to go back to core

Chris Hughes (Aug 07 2023 at 13:21):

Or we need an attribute or something that we can mark Zero with this attribute.

Mario Carneiro (Aug 07 2023 at 13:22):

I don't see why we want to unfold numbers in the first place, that sounds bad?

Chris Hughes (Aug 07 2023 at 13:31):

It struck me as a little strange to unfold addition to Nat.add and so on.

Alex Keizer (Aug 07 2023 at 13:37):

Maybe this is a different discussion, but do we even need Zero to be a separate typeclass from OfNat? Could we somehow make it a surface-level alias?

Mario Carneiro (Aug 07 2023 at 13:49):

yes, we get into diamond issues otherwise

Tobias Grosser (Aug 08 2023 at 04:57):

Mario Carneiro said:

I don't see why we want to unfold numbers in the first place, that sounds bad?

AFAIU this is all about the following two files. where we aim to turn an Expr into a Nat.Linear.Expr. It seems the algorithm is rather simple. It matches for known constructors, calls unfoldProjInst? if possible and otherwise assumes anything that cannot be unfolded is a parameter. This roughly makes sense to me. @Mario Carneiro I don't fully understand what you said above. Are you saying if let some e ← unfoldProjInst? e then should be completely dropped? I am not yet seeing why this is particularly bad and would avoid introducing Zero.zero.

In particular, I am not yet sure where the Zero.zero actually gets introduced. @Chris Hughes, did you find the line where this happens? unfolding sizeOf certainly does not yield a zero. Does some of the lean code itself introduce the zero?

partial def toLinearExpr (e : Expr) : M LinearExpr := do
  match e with
  | Expr.lit (Literal.natVal n) => return num n
  | Expr.mdata _ e              => toLinearExpr e
  | Expr.const ``Nat.zero ..    => return num 0
  | Expr.app ..                 => visit e
  | Expr.mvar ..                => visit e
  | _                           => addAsVar e
where
  visit (e : Expr) : M LinearExpr := do
    let f := e.getAppFn
    match f with
    | Expr.mvar .. =>
      let eNew  instantiateMVars e
      if eNew != e then
        toLinearExpr eNew
      else
        addAsVar e
    | Expr.const declName .. =>
      let numArgs := e.getAppNumArgs
      if declName == ``Nat.succ && numArgs == 1 then
        return inc ( toLinearExpr e.appArg!)
      else if declName == ``Nat.add && numArgs == 2 then
        return add ( toLinearExpr (e.getArg! 0)) ( toLinearExpr (e.getArg! 1))
      else if declName == ``Nat.mul && numArgs == 2 then
        match ( evalNat (e.getArg! 0) |>.run) with
        | some k => return mulL k ( toLinearExpr (e.getArg! 1))
        | none => match ( evalNat (e.getArg! 1) |>.run) with
          | some k => return mulR ( toLinearExpr (e.getArg! 0)) k
          | none => addAsVar e
      else if isNatProjInst declName numArgs then
        if let some e  unfoldProjInst? e then
          toLinearExpr e
        else
          addAsVar e
      else
        addAsVar e
    | _ => addAsVar e

which aims to fill this data-structure:

namespace Nat.Linear
inductive Expr where
  | num  (v : Nat)
  | var  (i : Var)
  | add  (a b : Expr)
  | mulL (k : Nat) (a : Expr)
  | mulR (a : Expr) (k : Nat)
  deriving Inhabited

Mario Carneiro (Aug 08 2023 at 04:58):

unfolding the OfNat application yields a Zero.zero

Tobias Grosser (Aug 08 2023 at 05:01):

I was looking at the example (m c : ℕ) : sizeOf c < 1 + m + sizeOf c := by example. unfolding sizeOf does not reach OfNat.

Mario Carneiro (Aug 08 2023 at 05:02):

there is a mathlib simp lemma taking that goal to 0 < 1 + m

Tobias Grosser (Aug 08 2023 at 05:02):

Ahhhh

Mario Carneiro (Aug 08 2023 at 05:02):

where 0 is spelled using an instance chain involving Zero

Mario Carneiro (Aug 08 2023 at 05:03):

because it's a theorem about additive monoids

Tobias Grosser (Aug 08 2023 at 05:04):

Ah, now I see. If we use more generic simp lemmas, than Zero.zero will eventually pop up.

Tobias Grosser (Aug 08 2023 at 05:04):

And then simp (config := { arith := true }) only cannot interpret it.

Tobias Grosser (Aug 08 2023 at 05:05):

Mario Carneiro said:

I don't see why we want to unfold numbers in the first place, that sounds bad?

How would this help?

Mario Carneiro (Aug 08 2023 at 05:06):

if we see a OfNat n application, we commit to interpreting the expression as .nat n and assert defeq of the instance to the nat instance, instead of unfolding things

Tobias Grosser (Aug 08 2023 at 05:08):

That seems to make sense.

Tobias Grosser (Aug 08 2023 at 05:08):

Thank you.


Last updated: Dec 20 2023 at 11:08 UTC