Zulip Chat Archive
Stream: metaprogramming / tactics
Topic: Tauto infinite loop
Sam Ezeh (Mar 30 2024 at 03:11):
https://github.com/leanprover-community/mathlib4/issues/10590
Would this issue be suitable for me to begin contributing to Mathlib's meta-library? What would an acceptable solution look like? I've written a fix that tags hypotheses with a hashset so we don't visit them twice, would this be an acceptable approach or is there a better way of doing this?
Sam Ezeh (Mar 30 2024 at 03:21):
The diff looks like this
diff --git a/Mathlib/Tactic/Tauto.lean b/Mathlib/Tactic/Tauto.lean
index a61841c23a..cb5062d8a7 100644
--- a/Mathlib/Tactic/Tauto.lean
+++ b/Mathlib/Tactic/Tauto.lean
@@ -111,12 +111,15 @@ partial def distribNotAux (fvars : List Expr) (g : MVarId) : MetaM MVarId :=
Tries to apply de-Morgan-like rules on all hypotheses.
Always succeeds, regardless of whether any progress was actually made.
-/
-def distribNot : TacticM Unit := withMainContext do
- let mut fvars := []
+def distribNot (visited : ST.Ref IO.RealWorld (HashSet FVarId)) : TacticM Unit := withMainContext do
+ let mut fvarIds := []
for h in ← getLCtx do
+ if (← visited.get).contains h.fvarId then
+ continue
if !h.isImplementationDetail then
- fvars := mkFVar h.fvarId :: fvars
- liftMetaTactic' (distribNotAux fvars)
+ fvarIds := h.fvarId :: fvarIds
+ liftMetaTactic' (distribNotAux (fvarIds.map mkFVar))
+ visited.modify (λ s ↦ s.insertMany fvarIds)
/-- Config for the `tauto` tactic. Currently empty. TODO: add `closer` option. -/
structure Config
@@ -158,11 +161,12 @@ duplicated work.
def tautoCore : TacticM Unit := do
_ ← tryTactic (evalTactic (← `(tactic| contradiction)))
_ ← tryTactic (evalTactic (← `(tactic| assumption)))
+ let visited ← ST.mkRef .empty
iterateUntilFailure do
let gs ← getUnsolvedGoals
allGoals (
liftMetaTactic (fun m => do pure [(← m.intros!).2]) <;>
- distribNot <;>
+ distribNot visited <;>
liftMetaTactic (casesMatching casesMatcher (recursive := true) (throwOnNoMatch := false)) <;>
(do _ ← tryTactic (evalTactic (← `(tactic| contradiction)))) <;>
(do _ ← tryTactic (evalTactic (← `(tactic| refine or_iff_not_imp_left.mpr ?_)))) <;>
David Renshaw (Mar 30 2024 at 15:54):
Thanks for looking into this!
David Renshaw (Mar 30 2024 at 15:54):
My initial reaction is: it would be nice to avoid the need for ref cells.
David Renshaw (Mar 30 2024 at 15:55):
Here's a more narrow "fix", that just bails when replace
does not get rid of the old hypothesis:
diff --git a/Mathlib/Tactic/Tauto.lean b/Mathlib/Tactic/Tauto.lean
index a61841c23a..9e6e61e026 100644
--- a/Mathlib/Tactic/Tauto.lean
+++ b/Mathlib/Tactic/Tauto.lean
@@ -25,7 +25,13 @@ def distribNotOnceAt (hypFVar : Expr) (g : MVarId) : MetaM AssertAfterResult :=
let .fvar fvarId := hypFVar | throwError "not fvar {hypFVar}"
let h ← fvarId.getDecl
let e : Q(Prop) ← (do guard <| ← Meta.isProp h.type; pure h.type)
- let replace (p : Expr) := g.replace h.fvarId p
+ let replace (p : Expr) : MetaM AssertAfterResult := do
+ let result ← g.replace h.fvarId p
+ result.mvarId.withContext do
+ if (← getLCtx).contains h.fvarId
+ then throwError "'replace' did not remove the existing hypothesis"
+ pure result
+
match e with
| ~q(¬ ($a : Prop) = $b) => do
let h' : Q(¬$a = $b) := h.toExpr
David Renshaw (Mar 30 2024 at 15:58):
On the other hand, with your fix, tauto
can probably prove more things than with my fix. (EDIT: or maybe not? see my later comments below)
David Renshaw (Mar 30 2024 at 16:11):
Added a comment on the issue: https://github.com/leanprover-community/mathlib4/issues/10590#issuecomment-2028196263
David Renshaw (Mar 30 2024 at 16:26):
My initial reaction is: it would be nice to avoid the need for ref cells.
I believe it's possible for fvars to be shared among multiple subgoals being operated on by the allGoals
combinator, and I think that we typically want distribNot
to act on all of the hypotheses of these subgoals. So it seems potentially problematic to share the visited
HashSet among them.
Sam Ezeh (Mar 30 2024 at 17:14):
David Renshaw said:
My initial reaction is: it would be nice to avoid the need for ref cells.
I believe it's possible for fvars to be shared among multiple subgoals being operated on by the
allGoals
combinator, and I think that we typically wantdistribNot
to act on all of the hypotheses of these subgoals. So it seems potentially problematic to share thevisited
HashSet among them.
Oh I see! I had a look at the comment on GitHub and I agree, there is no point in sharing the visited hash set. I was also worried about introducing a regression but as you noted, mathlib3 simply failed on that case anyway so that's not an issue.
Sam Ezeh (Mar 30 2024 at 17:16):
Should I submit a PR with your solution?
David Renshaw (Mar 30 2024 at 17:34):
Sounds good to me!
Sam Ezeh (Mar 31 2024 at 14:39):
Using this example
import Mathlib.Data.Part
open Part
theorem mem_restrict (p : Prop) (o : Part α) (h : p → o.Dom) (a : α) :
a ∈ restrict p o h ↔ p ∧ a ∈ o := by
dsimp [restrict, mem_eq]
tauto
I get the following error when using the new tauto
/-
don't know how to synthesize placeholder
context:
α: Type u_1
p: Prop
o: Part α
h: p → o.Dom
a: α
em✝: (a : Prop) → Decidable a
⊢ (∃ (h_1 : p), o.get _ = a) ↔ p ∧ ∃ (h : o.Dom), o.get h = a
-/
This really confused me because I would've expected it to act as any other error and iterateUntilFailure continues to succeed and I don't understand how throwing an error in replace causes issues with synthesis. After poking around at the code I was able to get the synthesis issue by throwing an error in replace
but it only occurs when I also call g.replace
. I tried the trySynthInstanceQ
and working by cases on the Option type it returns but the tauto' tactic still fails with the same synthesis issue. What's going on here?
Sam Ezeh (Mar 31 2024 at 14:39):
Another thing is that CI still passed despite this, would it be worth adding a test case for this somewhere? If so, how would I go about doing this in Mathlib?
Sam Ezeh (Mar 31 2024 at 14:42):
So this diff is enough to cause it
diff --git a/Mathlib/Tactic/Tauto.lean b/Mathlib/Tactic/Tauto.lean
index a61841c23a..19d211fdd7 100644
--- a/Mathlib/Tactic/Tauto.lean
+++ b/Mathlib/Tactic/Tauto.lean
@@ -25,7 +25,10 @@ def distribNotOnceAt (hypFVar : Expr) (g : MVarId) : MetaM AssertAfterResult :=
let .fvar fvarId := hypFVar | throwError "not fvar {hypFVar}"
let h ← fvarId.getDecl
let e : Q(Prop) ← (do guard <| ← Meta.isProp h.type; pure h.type)
- let replace (p : Expr) := g.replace h.fvarId p
+ let replace (p : Expr) := do
+ let _ ← g.replace h.fvarId p
+ throwError "always fails"
+
match e with
| ~q(¬ ($a : Prop) = $b) => do
let h' : Q(¬$a = $b) := h.toExpr
David Renshaw (Mar 31 2024 at 15:31):
Huh, weird.
This seems to fix it:
diff --git a/Mathlib/Tactic/Tauto.lean b/Mathlib/Tactic/Tauto.lean
index 9e6e61e026..2fa55ba8d9 100644
--- a/Mathlib/Tactic/Tauto.lean
+++ b/Mathlib/Tactic/Tauto.lean
@@ -26,10 +26,13 @@ def distribNotOnceAt (hypFVar : Expr) (g : MVarId) : MetaM AssertAfterResult :=
let h ← fvarId.getDecl
let e : Q(Prop) ← (do guard <| ← Meta.isProp h.type; pure h.type)
let replace (p : Expr) : MetaM AssertAfterResult := do
+ let s ← saveState
let result ← g.replace h.fvarId p
result.mvarId.withContext do
if (← getLCtx).contains h.fvarId
- then throwError "'replace' did not remove the existing hypothesis"
+ then
+ s.restore
+ throwError "'replace' did not remove the existing hypothesis"
pure result
match e with
David Renshaw (Mar 31 2024 at 15:31):
... but I don't yet have a good understanding of why the explicit save/restore is needed. I had thought that the exception handling would take care of the necessary rollback.
David Renshaw (Apr 01 2024 at 20:21):
I made a little example program to test my understanding:
import Lean
open Lean Meta Elab.Tactic
syntax (name := foo) "foo" : tactic
elab_rules : tactic
| `(tactic| foo) => do
let g ← getMainGoal
try
g.assign (mkNatLit 1)
throwError "oops!"
catch _ =>
if ←g.isAssigned
then throwError "oh no!"
g.assign (mkNatLit 2)
pure ()
def x : Nat := by foo
#print x
-- 2
David Renshaw (Apr 01 2024 at 20:23):
and that works like I expected -- the throwError
rolls back the g.assign
David Renshaw (Apr 01 2024 at 20:25):
so I still don't understand why the saveState
and s.restore
seem to be needed in the proposed tauto
fix.
Thomas Murrills (Apr 01 2024 at 20:53):
I think the issue is with liftMetaTactic
:
import Lean
open Lean Meta Elab.Tactic
syntax (name := foo) "foo" : tactic
elab_rules : tactic
| `(tactic| foo) => liftMetaTactic fun g => do
try
g.assign (mkNatLit 1)
throwError "oops!"
catch _ =>
if ← g.isAssigned then throwError "oh no!"
g.assign (mkNatLit 2)
pure []
def x : Nat := by foo -- oh no!
#print x
-- 1
I wonder if this is related to lean4#3001 (though I haven’t checked that that instance is actually relevant here).
David Renshaw (Apr 01 2024 at 20:59):
Ah, good find.
David Renshaw (Apr 01 2024 at 21:00):
@Thomas Murrills do you agree that this seems like a bug?
Thomas Murrills (Apr 01 2024 at 21:02):
Absolutely!
Thomas Murrills (Apr 01 2024 at 21:05):
Actually, this seems like a problem with theMetaM
tryCatch
instance somehow.
import Lean
open Lean Meta Elab.Tactic
def x : MVarId → MetaM Unit
| g => do
try
g.assign (mkNatLit 1)
throwError "oops!"
catch _ =>
if ← g.isAssigned then throwError "oh no!"
g.assign (mkNatLit 2)
#eval show MetaM _ from do -- oh no!
let a ← mkFreshExprMVar (Expr.const ``Nat [])
x a.mvarId!
Thomas Murrills (Apr 01 2024 at 21:07):
(no liftM
in sight)
David Renshaw (Apr 01 2024 at 21:10):
trying to unravel this MonadExcept
/ MonadExceptOf
stack of instances has me like :face_with_spiral_eyes:
Thomas Murrills (Apr 01 2024 at 21:12):
Ooh, take a look at #general > try, salvage, catch ; that’s not directly useful per se, but involved unraveling that stack!
Thomas Murrills (Apr 01 2024 at 21:13):
It goes all the way down to EStateM
under EIO
.
Thomas Murrills (Apr 01 2024 at 21:15):
I think there’s a special tryCatch
for TacticM
and CoreM
, but iirc the rest are in terms of monad transformers.
David Renshaw (Apr 01 2024 at 21:17):
is it possible that the special TacticM
is specifically adding the save/restore behavior, and MetaM
is not getting it (perhaps even intentionally)?
Thomas Murrills (Apr 01 2024 at 21:18):
Yes, I think it’s quite possible that the “right” behavior is from the special TacticM
restore
used in its tryCatch
. But I would doubt it’s intentional…
Thomas Murrills (Apr 01 2024 at 21:28):
I’m suspicious of the MonadExceptOf
instance for StateRefT'
in Init.Control.StateRef, as MetaM
uses a StateRefT'
to store the MetavarContext
…
David Renshaw (Apr 01 2024 at 21:37):
where is StateRefT
defined? When I jump to definition it takes me to StateRefT'
.
David Renshaw (Apr 01 2024 at 21:39):
oh.. it's a macro (unclear where it's defined, but maybe not important)
David Renshaw (Apr 01 2024 at 22:11):
The EST
monad does not implement try/catch backtracking:
import Lean
open Lean Meta Elab.Tactic Elab.Command
#eval show MetaM Unit from do
let x := runEST (ε := String) fun σ ↦ do
let r : ST.Ref σ Nat ← ST.mkRef 0
try
r.set 1
throw "boom"
catch _ =>
dbg_trace ←r.get -- prints "1"
r.set 2
dbg_trace x
pure ()
David Renshaw (Apr 01 2024 at 22:12):
... and that seems reasonable to me.
David Renshaw (Apr 01 2024 at 22:15):
I suppose that the reason I expect MetaM
try/catch to backtrack the Meta state is that TacticM
does it, and TacticM
is "just" a wrapper of MetaM
.
Thomas Murrills (Apr 02 2024 at 00:33):
I’d expect it to backtrack even here—but I don’t necessarily know what other languages do with ST
. In general I’d expect try
/catch
to always backtrack, or at the very least to have consistent behavior across monads.
Thomas Murrills (Apr 02 2024 at 00:38):
I think either
- we should fix these
Ref
-relatedtryCatch
instances so that they backtrack (like everything else) - we should ensure that all of Lean’s nongeneric monads (
MetaM
,TermElabM
, etc.) backtrack undertryCatch
(and ideallyMonadControl
)
Mario Carneiro (Apr 02 2024 at 01:04):
Thomas Murrills said:
I’d expect it to backtrack even here—but I don’t necessarily know what other languages do with
ST
. In general I’d expecttry
/catch
to always backtrack, or at the very least to have consistent behavior across monads.
try
catch
generally does not backtrack in lean, this is what MonadBacktrack
is for. The reason for this is because backtracking state involves overhead which you don't want to pay unless you know that it will be needed, so doing this by default in all state monads would cause performance issues when using lean's many linear data structures
Mario Carneiro (Apr 02 2024 at 01:05):
It would be lovely to have some kind of explicit opt in backtracking try
/ catch
though, rather than having to save and restore the state manually, which is error prone
David Renshaw (Apr 02 2024 at 01:10):
@Mario Carneiro if I'm working in a monad M
, is there an easy way to look up whether try/catch in M
will backtrack or not?
David Renshaw (Apr 02 2024 at 01:10):
I'm trying to do #synth MonadBacktrack TacticM
, but it's not working
Mario Carneiro (Apr 02 2024 at 01:10):
Mario Carneiro (Apr 02 2024 at 01:11):
... you know the "instances" list in doc-gen would be a lot nicer if it showed the types rather than the names of the instances
David Renshaw (Apr 02 2024 at 01:11):
yeah, that gives me a list of instances, but I don't see TacticM
in there, so presumably it comes from some monad tranformer instance
David Renshaw (Apr 02 2024 at 01:12):
oh, wait maybe it is there, just with an enormous obfuscated name
David Renshaw (Apr 02 2024 at 01:34):
Mario Carneiro said:
The way we are manually backtracking in mathlib4#11799 is by calling MonadBacktrack.saveState
. So I don't see how MonadBacktrack
can also be the marker for "this monad automatically backtracks in try/catch".
Mario Carneiro (Apr 02 2024 at 01:34):
it's not, it's the interface for manually backtracking
Mario Carneiro (Apr 02 2024 at 01:35):
the marker for "automatically backtracks" AFAIK is nothing because nothing does that
Mario Carneiro (Apr 02 2024 at 01:35):
...although now that I come to think of it some OrElse
instances backtrack
Mario Carneiro (Apr 02 2024 at 01:36):
it's probably in need of a proper census to find out how inconsistent things actually are
David Renshaw (Apr 02 2024 at 01:36):
Restating my question: what piece of documentation tells me that TacticM
does try/catch backtracking and MetaM
does not?
David Renshaw (Apr 02 2024 at 01:36):
It seems the answer is "none".
Mario Carneiro (Apr 02 2024 at 01:37):
the answer is "look at the code"
Mario Carneiro (Apr 02 2024 at 01:37):
aka "none"
David Renshaw (Apr 02 2024 at 01:37):
and that's really difficult because of the layers of monad transformer indirection!
Mario Carneiro (Apr 02 2024 at 01:37):
well on the flip side that's good because there are only a few monad transformer primitives to look at
Mario Carneiro (Apr 02 2024 at 01:42):
Looks like the implementation of tryCatch on EStateM
uses tricky typeclass machinery such that it backtracks only if there is a Backtrackable
instance available, see src#EStateM.nonBacktrackable
Mario Carneiro (Apr 02 2024 at 01:45):
TacticM
also has a special case MonadExcept
instance, but not MonadExceptOf
, which means that tryCatch
and tryCatchThe
behave differently...
Mario Carneiro (Apr 02 2024 at 01:47):
the monad instance for StateT
(src#StateT.instMonadExceptOfStateT) appears to use the state nonlinearly, which seems like a bug, but is also AFAICT the only thing you could write for that instance. It seems like this means StateT will always backtrack everything, even if the state is not backtrackable?
Mario Carneiro (Apr 02 2024 at 01:49):
by contrast, the monad instance for StateRefT'
(src#StateRefT'.instMonadExceptOfStateRefT'), despite using basically the same code, will not backtrack thanks to the magic of IO refs
Mario Carneiro (Apr 02 2024 at 02:05):
So it looks like the conclusion is:
Does it automatically backtrack on tryCatch
?
StateT
: yesStateRefT'
: noEStateM
: only if the state isBacktrackable
. Is it backtrackable?- no (why does this class even exist then?)
TacticM
: yes
All of the special case instances except MonadExcept TacticM
don't change the backtracking behavior
Does <|>
backtrack:
MetaM
: yesParser
: it's complicatedFormatterM
/ParenthesizerM
: yes- otherwise: it's the same as
try catch
, see above
Mario Carneiro (Apr 02 2024 at 02:09):
in summary, state tracked in StateT
generally backtracks, while state in StateRefT'
doesn't, and most lean monads store all the state in StateRefT'
or EStateM
and so most of the state is not automatically backtracked. <|>
is more likely to backtrack than try catch
. Should this be documented more explicitly? Absolutely.
Mario Carneiro (Apr 02 2024 at 02:10):
this is an API mismatch between StateT
and StateRefT
that I had not previously realized
Thomas Murrills (Apr 02 2024 at 08:18):
This is a massive footgun, right? I’m not sure documentation alone would prevent the footgun from going off. Shouldn’t all try
/catch
uses behave the same (one way or another) with respect to backtracking, just as a matter of, well, good UX? Otherwise, you need to think about the details of each monad you’re in, and therefore about which lift you use—and a big part of the usefulness of lifts is that you don’t really have to think about them.
Likewise, if you know a monad you’re using has state, now you need to look at the (extrinsic) implementation details of that state to know what your code means, even though the interaction with that state uses the same API (or API which obscures how the state is implemented).
I think (but am ultimately guessing) that many people would expect state to be rewound by try
/catch
, and that not doing so is a footgun (less so if it were uniformly not rewound, but a footgun nonetheless).
So, my (ideal-world) proposal would be to uniformly backtrack state with try
, with an opt-in non-backtracking try
variant (try!
? or simply do … catch
?), for when you’re sure you want to prioritize optimization over intuitive semantics. Since it’s the less intuitive/more finicky thing, my thinking is that it’s better for non-backtracking to be the one to require the deliberate opt-in.
My not-quite-as-ideal-but-still-better-world proposal would be to use backtracking by default in try
/catch
in all of the major Lean monads—MetaM
should work like TacticM
and CoreM
, etc.
Mario Carneiro (Apr 02 2024 at 08:30):
Otherwise, you need to think about the details of each monad you’re in, and therefore about which lift you use—and a big part of the usefulness of lifts is that you don’t really have to think about them.
Lifts don't really change things here. What matters is what kind of state it is, i.e. whether the state in the monad is being tracked via the StateT
part of your monad transformer stack or a StateRefT
part. Lifts will not move state from one slot to another. So I think it's okay that the answer is "it's monad dependent, but the monad author has made choices about what should be persisted and what hasn't, hopefully in a way that makes sense"
Mario Carneiro (Apr 02 2024 at 08:32):
That is, for a given piece of lean state like the MetavarContext
, you can ask whether it is persisted or not in every monad which has one of these things and get a consistent answer. (Spoiler, it is backtracked by <|>
and not by try catch
.)
Thomas Murrills (Apr 02 2024 at 15:05):
Hmm, it’s my opinion that you should not have to ask about the implementation details of the state to know the semantics of try
/catch
. If you want something to behave differently, I think there should be different API.
Also, here, lifts did move state from one slot to another because of TacticM
’s custom tryCatch
! So, more narrowly, I at least think the core Lean monads should behave consistently with respect to each other.
Mario Carneiro (Apr 02 2024 at 15:07):
well in any case I can't do much about it
Mario Carneiro (Apr 02 2024 at 15:07):
take it up with core if you want to fight that battle
Mario Carneiro (Apr 02 2024 at 15:08):
I can't even imagine how much code would have to be reviewed after a change of that magnitude
Thomas Murrills (Apr 02 2024 at 15:11):
Haha, yeah, I am just speaking in principle, not at you directly :)
Thomas Murrills (Apr 02 2024 at 15:31):
When I’ve got a bit of time, I think I’ll ask in #lean4 for opinions on at least the core monad issue, and in the meantime, I’ll see if I can help out with documentation. That would certainly be better than nothing! :)
Mario Carneiro (Apr 02 2024 at 15:32):
maybe if you add docs you can highlight how inconsistent the behavior is without saying anything directly :)
Last updated: May 02 2025 at 03:31 UTC