Zulip Chat Archive
Stream: mathlib4
Topic: progress on fail_if_no_progress
Thomas Murrills (Jul 09 2023 at 10:56):
I'm (finally!) taking a look again at #3757. :) I've revamped what I think the config ought to look like in the spirit of providing all knobs you might want, in part informed by what I think a generic configurable equality assertion ought to look like—though I'm doing this "by hand" instead of attempting to actually produce something general, since it's probably more important to get it working first and then take care of the general case, rather than the other way around! (Though I would like to address the general case here at some point! I have some ideas if anyone's curious.)
In part, I'm trying to make it more natural to write your own bespoke FailIfNoProgress.Config
instance by propagating defaults down from global settings, as per #lean4 > Inheriting defaults from parent structures .
I need to tweak and fill out the actual implementation to match the new configs, but this should be mostly mechanical; then more docstrings are needed, which is again straightforward. I'd also like to rewrite some of the code in that PR, as it looks quite a bit worse now than it did then...though I'm told that's a common experience :)
Thomas Murrills (Jul 09 2023 at 11:01):
I'll ask a couple miscellaneous questions in this thread as I try to finish it up! @Scott Morrison and @Alex J. Best, I hope to have it cleaned up enough to be commentable-on (comment-on-able?) in a day or two if you're interested (though my current circumstances are a bit volatile when it comes to being sure of such a thing); or, if there are obvious tasks either of you would like to address there, please feel free. :)
Here's one such misc. question: I have functions zip?
, zipM?
, zipWith?
, zipWithM?
, zipWithFold?
, and zipWithFoldM?
which return none
if the lengths of the lists don't match. The relevance here is that we ought to check that the lengths of two lists (of e.g. goals or hypotheses) which we're comparing match before we actually start comparing the elements. Besides the fact that I think a couple of them weren't implemented correctly back then (oops!), (1) do these exist somewhere and (2) if not, where should they go? They seem more general than fail_if_no_progress
.
Scott Morrison (Jul 09 2023 at 12:02):
I doubt these exist. It's not obvious to me that an Option
valued version of zip
is the right answer here, however.
Why not just separately compare the lengths, before ever looking at the zip
? If the length don't match we can just terminate, as clearly the goal has changed by any of the standards.
We're just comparing things like lists of hypotheses here, so there is no performance issue to worry about.
If there were a reason for a richer version of zip
, maybe something like zipWithLeftovers : List α → List β → List (α × β) × List α × List β
(possibly an ⊕
at the end there?) would be the right answer. You can match on it to detect whether the lists had different lengths. I don't like the Option
valued alternative because it is throwing away some but not all of the additional information available beyond zip
, and it seems overly specific.
Kyle Miller (Jul 09 2023 at 12:07):
Related are docs#List.zipWithLeft and docs#List.zipWithRight (and docs#List.zipLeft and docs#zipRight). List.zipWithLeftovers
and a corresponding List.zipLeftovers
seem like they'd be a nice addition (modulo the names, since With
implies there's a function being mapped over the zipping, and List.zipLeftovers
isn't so descriptive on its own)
Scott Morrison (Jul 09 2023 at 12:07):
zipAndTail(s)
?
Thomas Murrills (Jul 09 2023 at 12:58):
plus, zipLeftovers
implies the existence of zipRightovers
:)
Thomas Murrills (Jul 09 2023 at 13:01):
also it’s a moot point, but just curious, what did you mean by throwing away some but not all of the information available beyond zip? Wouldn’t we be throwing it all away?
Yakov Pechersky (Jul 09 2023 at 13:02):
In python, it's called zip_longest
Scott Morrison (Jul 10 2023 at 01:06):
Thomas Murrills said:
also it’s a moot point, but just curious, what did you mean by throwing away some but not all of the information available beyond zip? Wouldn’t we be throwing it all away?
zipwithTail : List α → List β → List (α × β) × (List α ⊕ List β)
is (EDIT: err, not quite...) an isomorphism from the inputs to the output. zip
throws away "everything" except the List (α × β)
part, while your zip?
throws away everything except whether one of the two tails is non-empty. The fact that there are several intermediate options between zipWithTail
and zip
besides zip?
made me dubious that any of them were generally useful.
Thomas Murrills (Jul 12 2023 at 10:42):
Ok, #3757 essentially passes CI, besides a build-trivial refactor passes CI and is out for review! A couple finishing review questions that I can think of right now:
Thomas Murrills (Jul 12 2023 at 10:42):
- I introduce the obvious instance for
BEq MetavarKind
by hand. Is there a better way of doing this? Or a better location? (Should I or someone else even PR lean4 so thatinductive MetavarKind
isderiving ... DecidableEq
?)
Thomas Murrills (Jul 12 2023 at 10:42):
- The last three commits refactor the comparison machinery to a new file, since it seemed a bit crowded in
FailIfNoProgress.lean
, and the content could serve an independent purpose. That is, it seems like a single "functional unit" not specific tofail_if_no_progress
, and imo makes the files and logic cleaner if it's an import. But, it's only used infail_if_no_progress
at the moment, so I'm not sure if it should really be split off. Yea or nay? (I can simply revert if nay.)
Jannis Limperg (Jul 20 2023 at 12:33):
I really like the gist of this PR, and I agree that the comparison functions are more widely applicable and should live in their own files. I have two questions about the functionality:
- Would it be possible to get a mode which ignores changes to
FVarId
s? That is, fvars at the same position in the context with the same type should be considered the same, even if they have differentFVarId
s. Rationale:FVarId
s are an implementation detail and only affect the respective goal. Use case: a tactic which uses the revert-intro trick, but then ends up not doing anything betweenrevert
andintro
, changes all theFVarId
s but has not made progress. - Would it be possible to get a mode which checks whether mvars occurring in the goal are the same, even if they have different
MVarId
s? The difficulty with this is that we still want to ensure that if an mvar appears multiple times in the same goal, its equivalent in the other goal also occurs in the same positions. Not sure if this is relevant forfail_if_no_progress
; if not, it probably shouldn't be part of this PR.
Thomas Murrills (Jul 20 2023 at 16:15):
Nice! :)
- Yes, it is! The basic version of this is the default—this is
checkFVarId := false
inLocalDeclComparisonConfig
. However, this doesn't do the analogous thing you're suggesting forMVarId
s, i.e. consider equivalentFVarId
s to be equivalent when they appear in subsequent expressions. And on that note: - Hmm—I'd definitely be interested in doing this (and likewise for
FVarId
s), and I think it is relevant tofail_if_no_progress
! But it might be worth splitting it into a separate PR, as it breaks the (current) modularity of the implementation and would probably be worthy of review by itself. That would probably necessitate keeping track of theMVarId
s andFVarId
s that were taken to be equivalent, and then replacing them in each type expression we encountered when we compare them.
This sort of flow would also be more efficient! Currently, we check each component against each other component in sequence, when we really ought to defer all the potentially-expensive isDefEq
checks until the end, to see if the easier-to-check properties are sufficient for distinguishing our two states. But being able to defer checks would complicate the types of the comparison functions (or have us introduce primed versions of each comparison def which could defer checks). Or maybe we could just pass all the equivalence info from previous hypotheses/goals as arguments to the comparison functions without changing the return types—that would be less efficient, but probably easier and cleaner type-wise. (Or maybe there's an implementation involving Task
, which I don't know anything about yet, but the name sounds possibly relevant.)
Jannis Limperg (Jul 20 2023 at 16:46):
I dug up my implementation. It's more lengthy than I thought... (In fact, it doesn't quite fit into a Zulip message.)
import Std.Lean.Meta.SavedState
open Lean Lean.Meta
namespace Aesop
-- TODO caching -- but maybe the ptrEq optimisation is enough
namespace EqualUpToIdsM
structure Context where
commonMCtx : MetavarContext
mctx₁ : MetavarContext
mctx₂ : MetavarContext
structure State where
equalMVarIds : HashMap MVarId MVarId := {}
equalLMVarIds : HashMap LMVarId LMVarId := {}
equalFVarIds : HashMap FVarId FVarId := {}
end EqualUpToIdsM
abbrev EqualUpToIdsM :=
ReaderT EqualUpToIdsM.Context $ StateRefT EqualUpToIdsM.State MetaM
-- Make the compiler generate specialized `pure`/`bind` so we do not have to optimize through the
-- whole monad stack at every use site. May eventually be covered by `deriving`.
@[inline, always_inline]
instance : Monad EqualUpToIdsM :=
{ inferInstanceAs (Monad EqualUpToIdsM) with }
protected def EqualUpToIdsM.run (x : EqualUpToIdsM α)
(commonMCtx mctx₁ mctx₂ : MetavarContext) :
MetaM α :=
(·.fst) <$> (x { commonMCtx, mctx₁, mctx₂ }).run {}
namespace Unsafe
mutual
unsafe def levelsEqualUpToIdsCore (l₁ l₂ : Level) : EqualUpToIdsM Bool :=
if ptrEq l₁ l₂ then
return true
else
levelsEqualUpToIdsCore' l₁ l₂
unsafe def levelsEqualUpToIdsCore' : Level → Level → EqualUpToIdsM Bool
| .zero, .zero => return true
| .succ l₁, .succ l₂ => levelsEqualUpToIdsCore l₁ l₂
| .max l₁ m₁, .max l₂ m₂ =>
levelsEqualUpToIdsCore l₁ l₂ <&&> levelsEqualUpToIdsCore m₁ m₂
| .imax l₁ m₁, .imax l₂ m₂ =>
levelsEqualUpToIdsCore l₁ l₂ <&&> levelsEqualUpToIdsCore m₁ m₂
| .param n₁, .param n₂ => return n₁ == n₂
| (.mvar m₁), l₂ => compareMVar (swap := false) m₁ l₂
| l₁, (.mvar m₂) => compareMVar (swap := true) m₂ l₁
| _, _ => return false
where
@[inline, always_inline]
go (swap : Bool) (l₁ l₂ : Level) : EqualUpToIdsM Bool :=
if swap then
levelsEqualUpToIdsCore l₂ l₁
else
levelsEqualUpToIdsCore l₁ l₂
@[inline, always_inline]
checkMVarEquality (m₁ m₂ : LMVarId) : EqualUpToIdsM Bool := do
let commonMCtx := (← read).commonMCtx
if commonMCtx.lDepth.contains m₁ || commonMCtx.lDepth.contains m₂ then
return m₁ == m₂
if let some m₂' := (← get).equalLMVarIds.find? m₁ then
return m₂' == m₂
else
modify λ s => { s with equalLMVarIds := s.equalLMVarIds.insert m₁ m₂ }
return true
@[specialize]
compareMVar (swap : Bool) (m₁ : LMVarId) (l₂ : Level) :
EqualUpToIdsM Bool := do
if let some l₁ ← getLevelMVarAssignment? m₁ then
go swap l₁ l₂
else if let .mvar m₂ := l₂ then
if let some l₂ ← getLevelMVarAssignment? m₂ then
go swap (.mvar m₁) l₂
else if swap then
checkMVarEquality m₂ m₁
else
checkMVarEquality m₁ m₂
else
return false
end
end Unsafe
@[implemented_by Unsafe.levelsEqualUpToIdsCore]
opaque levelsEqualUpToIdsCore (l₁ l₂ : Level) : EqualUpToIdsM Bool
private def namesEqualUpToMacroScopes (n₁ n₂ : Name) : Bool :=
n₁.hasMacroScopes == n₂.hasMacroScopes &&
n₁.eraseMacroScopes == n₂.eraseMacroScopes
private def lctxDecls (lctx : LocalContext) : Array LocalDecl :=
lctx.foldl (init := Array.mkEmpty lctx.numIndices) λ decls d => decls.push d
structure GoalContext where
mdecl₁ : MetavarDecl
mdecl₂ : MetavarDecl
equalFVarIds : HashMap FVarId FVarId := {}
namespace Unsafe
mutual
unsafe def localDeclsEqualUpToIdsCore :
LocalDecl → LocalDecl → ReaderT GoalContext EqualUpToIdsM Bool
| .cdecl _ _ userName₁ type₁ bi₁ kind₁,
.cdecl _ _ userName₂ type₂ bi₂ kind₂ =>
pure (namesEqualUpToMacroScopes userName₁ userName₂ &&
bi₁ == bi₂ && kind₁ == kind₂) <&&>
exprsEqualUpToIdsCore type₁ type₂
| .ldecl _ _ userName₁ type₁ v₁ _ kind₁,
.ldecl _ _ userName₂ type₂ v₂ _ kind₂ =>
pure (namesEqualUpToMacroScopes userName₁ userName₂ &&
kind₁ == kind₂) <&&>
exprsEqualUpToIdsCore type₁ type₂ <&&>
exprsEqualUpToIdsCore v₁ v₂
| _, _ => return false
unsafe def exprsEqualUpToIdsCore (e₁ e₂ : Expr) :
ReaderT GoalContext EqualUpToIdsM Bool := do
if ptrEq e₁ e₂ then
return true
else
exprsEqualUpToIdsCore' e₁ e₂
unsafe def exprsEqualUpToIdsCore' :
Expr → Expr → ReaderT GoalContext EqualUpToIdsM Bool
| .bvar i, .bvar j => return i == j
| .fvar fvarId₁, .fvar fvarId₂ =>
return (← read).equalFVarIds.find? fvarId₁ == some fvarId₂
| .sort u, .sort v => levelsEqualUpToIdsCore u v
| .const decl₁ lvls₁, .const decl₂ lvls₂ =>
return decl₁ == decl₂ && lvls₁ == lvls₂
| .app f₁ x₁, .app f₂ x₂ =>
exprsEqualUpToIdsCore f₁ f₂ <&&> exprsEqualUpToIdsCore x₁ x₂
| .lam n₁ t₁ e₁ bi₁, .lam n₂ t₂ e₂ bi₂ =>
pure (n₁ == n₂ && bi₁ == bi₂) <&&>
exprsEqualUpToIdsCore t₁ t₂ <&&>
exprsEqualUpToIdsCore e₁ e₂
| .forallE n₁ t₁ e₁ bi₁, .forallE n₂ t₂ e₂ bi₂ =>
pure (n₁ == n₂ && bi₁ == bi₂) <&&>
exprsEqualUpToIdsCore t₁ t₂ <&&>
exprsEqualUpToIdsCore e₁ e₂
| .letE n₁ t₁ v₁ e₁ _, .letE n₂ t₂ v₂ e₂ _ =>
pure (n₁ == n₂) <&&>
exprsEqualUpToIdsCore t₁ t₂ <&&>
exprsEqualUpToIdsCore v₁ v₂ <&&>
exprsEqualUpToIdsCore e₁ e₂
| .lit l₁, .lit l₂ => return l₁ == l₂
| .mdata _ e₁, e₂ => exprsEqualUpToIdsCore e₁ e₂
| e₁, .mdata _ e₂ => exprsEqualUpToIdsCore e₁ e₂
| .proj n₁ i₁ e₁, .proj n₂ i₂ e₂ =>
pure (n₁ == n₂ && i₁ == i₂) <&&> exprsEqualUpToIdsCore e₁ e₂
| .mvar m₁, e₂ => compareMVar (swap := false) m₁ e₂
| e₁, .mvar m₂ => compareMVar (swap := true) m₂ e₁
| _, _ => return false
where
@[inline, always_inline]
go (swap : Bool) (e₁ e₂ : Expr) : ReaderT GoalContext EqualUpToIdsM Bool :=
if swap then
exprsEqualUpToIdsCore e₂ e₁
else
exprsEqualUpToIdsCore e₁ e₂
@[inline, always_inline]
checkMVarEquality (m₁ m₂ : MVarId) : EqualUpToIdsM Bool := do
let commonMCtx := (← read).commonMCtx
if commonMCtx.decls.contains m₁ || commonMCtx.decls.contains m₂ then
return m₁ == m₂
else if let some m₂' := (← get).equalMVarIds.find? m₁ then
return m₂' == m₂
else if ← mvarIdsEqualUpToIdsCore m₁ m₂ then
modify λ s => { s with equalMVarIds := s.equalMVarIds.insert m₁ m₂ }
return true
else
return false
@[specialize]
compareMVar (swap : Bool) (m₁ : MVarId) (e₂ : Expr) :
ReaderT GoalContext EqualUpToIdsM Bool := do
if let some e₁ ← getExprMVarAssignment? m₁ then
go swap e₁ e₂
else if let .mvar m₂ := e₂ then
if let some e₂ ← getExprMVarAssignment? m₂ then
go swap (.mvar m₁) e₂
else if swap then
checkMVarEquality m₂ m₁
else
checkMVarEquality m₁ m₂
else
return false
unsafe def mvarIdsEqualUpToIdsCore (mvarId₁ mvarId₂ : MVarId) :
EqualUpToIdsM Bool := do
let gctx ← read
let (some mdecl₁) := gctx.mctx₁.decls.find? mvarId₁ | throwError
"unknown metavariable '?{mvarId₁.name}'"
let (some mdecl₂) := gctx.mctx₂.decls.find? mvarId₂ | throwError
"unknown metavariable '?{mvarId₂.name}'"
let decls₁ := lctxDecls mdecl₁.lctx
let decls₂ := lctxDecls mdecl₂.lctx
if decls₁.size != decls₂.size then
return false
let mut gctx : GoalContext := { mdecl₁, mdecl₂ }
for ldecl₁ in decls₁, ldecl₂ in decls₂ do
if ! (← localDeclsEqualUpToIdsCore ldecl₁ ldecl₂ |>.run gctx) then
return false
else
let equalFVarIds := gctx.equalFVarIds.insert ldecl₁.fvarId ldecl₂.fvarId
gctx := { gctx with equalFVarIds }
exprsEqualUpToIdsCore mdecl₁.type mdecl₂.type |>.run gctx
end
end Unsafe
@[implemented_by Unsafe.mvarIdsEqualUpToIdsCore]
opaque mvarIdsEqualUpToIdsCore (mvarId₁ mvarId₂ : MVarId) : EqualUpToIdsM Bool
def mvarIdsEqualUpToIds (commonMCtx mctx₁ mctx₂ : MetavarContext)
(mvarId₁ mvarId₂ : MVarId) : MetaM Bool := do
mvarIdsEqualUpToIdsCore mvarId₁ mvarId₂ |>.run commonMCtx mctx₁ mctx₂
def tacticStatesEqualUpToIds (commonMCtx mctx₁ mctx₂ : MetavarContext)
(goals₁ goals₂ : List MVarId) : MetaM Bool := do
if goals₁.length != goals₂.length then
return false
else
for g₁ in goals₁, g₂ in goals₂ do
if ! (← mvarIdsEqualUpToIds commonMCtx mctx₁ mctx₂ g₁ g₂) then
return false
return true
Jannis Limperg (Jul 20 2023 at 16:47):
open Lean.Elab.Tactic in
def assertEqualTactics (t₁ t₂ : TacticM Unit) : TacticM Unit := do
let commonMCtx ← getMCtx
let (state₁, goals₁) ← runTactic t₁
let (state₂, goals₂) ← runTactic t₂
let eq ←
tacticStatesEqualUpToIds commonMCtx state₁.meta.mctx state₂.meta.mctx
goals₁ goals₂
if ! eq then
throwError "Tactics produced different tactic states.\nTactic 1:{indentD $ ← ppTacticState state₁ goals₁}\nTactic 2:{indentD $ ← ppTacticState state₂ goals₂}"
where
runTactic (t : TacticM Unit) : TacticM (Meta.SavedState × List MVarId) := do
let s ← withoutModifyingState do t; saveState
return (s.term.meta, s.tactic.goals)
ppTacticState (state : Meta.SavedState) (goals : List MVarId) :
MetaM MessageData :=
state.runMetaM' do
addMessageContext $ .joinSep (goals.map toMessageData) "\n"
open Lean.Elab.Tactic in
elab &"assert_equal_tactics "
" { " ts₁:tacticSeq " } " " { " ts₂:tacticSeq " } " : tactic => do
assertEqualTactics (evalTactic ts₁) (evalTactic ts₂)
example : True := by
assert_equal_tactics { trivial } { trivial }
trivial
example : True := by
assert_equal_tactics { open Classical in trivial } { trivial }
trivial
example (n m : Nat) : True := by
fail_if_success assert_equal_tactics { cases n } { cases m }
trivial
example : 0 < 3 := by
apply Nat.lt_trans
assert_equal_tactics { exact Nat.lt_succ_self 0 } { exact Nat.lt_succ_self 0 }
(case m => exact 1); all_goals decide
example : 0 < 3 := by
apply Nat.lt_trans
assert_equal_tactics { apply Nat.lt_trans } { apply Nat.lt_trans }
(case m => exact 1); all_goals decide
end Aesop
Thomas Murrills (Jul 20 2023 at 17:02):
Oh nice! :D That seems to use some neat low-level functionality I'm not yet familiar with—e.g., when exactly to use @[inline]
or @[always_inline]
, what @[specialize]
means, etc. (I keep meaning to dig a bit deeper on those.)
Would it be useful to pepper in ptrEq
throughout the PR'd fail_if_no_progress
in the way you've done here, or is that already taken care of by beq
/isDefEq
?
In the fail_if_no_progress
context (which is a bit more general, I suppose) I wonder if it instead makes sense to traverse the expression and e.g. replace mvar1
with mvar2
in e₁
once they've been deemed equivalent, then compare this e₁'
with e₂
. That way, we get to use isDefEq
if we like. Though I'm not sure how to handle different levels in that context...maybe they could be abstracted out into level mvars for isDefEq
? For beq
, maybe a more specific implementation like the one you have here would be useful. It might also be useful to simply use your implementation as a drop-in replacement for beq
when ignoring id's (instead of traversing & replacing)!
Jannis Limperg (Jul 20 2023 at 17:30):
Thomas Murrills said:
Oh nice! :D That seems to use some neat low-level functionality I'm not yet familiar with—e.g., when exactly to use
@[inline]
or@[always_inline]
, what@[specialize]
means, etc. (I keep meaning to dig a bit deeper on those.)
I'm mostly cargo-culting this stuff myself; haven't done any measurements. @[specialize]
should be analogous to Haskell's {- SPECIALIZE -}
, which, for a function with instance argument [C α]
, generates a specialized version of the function for each instance of C
at which the function is used.
Would it be useful to pepper in
ptrEq
throughout the PR'dfail_if_no_progress
in the way you've done here, or is that already taken care of bybeq
/isDefEq
?
The core functions should already be using pointer equality where appropriate.
In the
fail_if_no_progress
context (which is a bit more general, I suppose) I wonder if it instead makes sense to traverse the expression and e.g. replacemvar1
withmvar2
ine₁
once they've been deemed equivalent, then compare thise₁'
withe₂
. That way, we get to useisDefEq
if we like. Though I'm not sure how to handle different levels in that context...maybe they could be abstracted out into level mvars forisDefEq
? Forbeq
, maybe a more specific implementation like the one you have here would be useful. It might also be useful to simply use your implementation as a drop-in replacement forbeq
when ignoring id's (instead of traversing & replacing)!
For beq
, you should probably use my code or something like it. That'll be more efficient than replacing mvars. (Also more complex though.) For isDefEq
, I guess replacing mvars is the only practicable way.
Alex J. Best (Jul 20 2023 at 23:12):
BTW my impression was (though I could be wrong), that in Lean instance arguments were always considered for specialization by the compiler, so for lean it is mostly helpful when you have something higher order, like a function argument, but that isn't a typeclass.
Jannis Limperg (Aug 11 2023 at 11:38):
@Thomas Murrills I've now tested my equality checking algorithm and of course it turned out to be more than a little buggy. A new version is now in Aesop. This one is used all over the Aesop test suite, so I'm fairly confident that it is mostly correct.
Last updated: Dec 20 2023 at 11:08 UTC