Zulip Chat Archive
Stream: mathlib4
Topic: simp normal form for `Vector` indexing: `get` vs `.[.]`
Alex Keizer (Jun 19 2023 at 12:28):
In a recent PR the question came up of what we want the simp normal form of indexing a Vector \a n
by a Fin n
to be, either Vector.get
or the .[.]
notation.
For Array
, we take the .[.]
notation as the normal form, while for List
we use List.get
.
But, we generally don't index List
's as much.
So, two questions for the community:
- Do we want to standardize on a consistent simp normal form, using either a
get
function or the.[.]
notation, accrosList
,Vector
,Array
, and any other potential collections? And - Which of the two forms should we use as the simp normal form for
Vector
in particular?
Scott Morrison (Jun 19 2023 at 12:33):
I would propose we use .get
for List
and [.]
for Array
and Vector
.
Scott Morrison (Jun 19 2023 at 12:33):
In fact, I'd propose we consider changing the definition of Vector
to be in terms of Array
rather than List
!
Alex Keizer (Jun 20 2023 at 15:37):
It turns out that Std
has the following simp-lemma:
@[simp] theorem getElem_fin [GetElem Cont Nat Elem Dom] (a : Cont) (i : Fin n) (h : Dom a i) :
a[i] = a[i.1] := rfl
Meaning that the actual simp normal form will be x[i.1]
/x[Fin.val i]
. This seems like quite a footgun to me: it's not immediately apparent that a goal with x[i]
got rewritten to x[↑i]
(the arrow is quite small in my vscode font, at least), making it surprising to see that a simp-lemma which accidentally used the x[i]
form is not actually firing.
How is this problem avoided with Array
? Should we maybe get rid of the Fin
instance of GetElem
entirely, so that simp-lemmas can not use the wrong form?
Gabriel Ebner (Jun 20 2023 at 16:25):
The linter will catch that.
Alex Keizer (Jun 21 2023 at 06:58):
Which linter? It doesn't seem to be working for me (I'm not seeing any indication in vscode at least) in a theorem where I wrote v[i.succ.1]
, even though it seems the normal form for that one should rather be v[i.1 + 1]'i.succ.2
Mario Carneiro (Jun 21 2023 at 07:07):
that one is in simp normal form though?
Mario Carneiro (Jun 21 2023 at 07:08):
well maybe i.succ.1
should be rewritten to i.1 + 1
but maybe there is no such simp lemma (or it's not a dsimp lemma, which would be required in this case since there is a dependency)
Alex Keizer (Jun 21 2023 at 07:18):
Is it? The following makes me believe that there is already such a lemma, or am I misunderstanding what simp normal form means?
import Mathlib.Data.Vector
import Mathlib.Data.Fin.Basic
example (v : Vector α (n+1)) (i : Fin n) : v[i.succ] = z := by
simp
-- Goal is: `⊢ v[↑i + 1] = z`
EDIT: added imports for an MWE
EDIT2: Hmm, by dsimp
doesn't rewrite the goal, so I guess your point about it not being a dsimp lemma is true. I'm afraid I don't entirely know how that relates to determining what the simp normal form is/should be. I'm just seeing that my goals with v[i.succ.1]
got rewritten to v[i.1 + 1]
by simp, so I concluded the latter should be the normal form. Is this wrong?
Mario Carneiro (Jun 21 2023 at 08:59):
You just wrote something different than your previous message. The example has v[i.succ]
but you are talking about v[i.succ.1]
Alex Keizer (Jun 21 2023 at 10:09):
Ah, you're right, it seems I minimized wrongly, sorry about that. Changing it to v[i.succ.1]
doesn't actually change anything, though.
import Mathlib.Data.Vector
import Mathlib.Data.Fin.Basic
example (v : Vector α (n+1)) (i : Fin n) : v[i.succ.1] = z := by
simp
-- Goal is: `⊢ v[↑i + 1] = z`
Back to my original question about the linter: the following is also not showing any indication, even though this one I am 100% sure it is not in simp-normal form:
@[simp]
theorem bad_theorem (v : Vector α n) (i j : Fin n) (h : i = j) : v[i] = v[j] := by
simp -- Goal: ⊢ v[↑i] = v[↑j]
rw[h]
Is this linter supposed to run interactively, or do I need to explicitly run something?
Alex J. Best (Jun 21 2023 at 12:09):
You need to put #lint
at the bottom of your file for the simp linters
Gabriel Ebner (Jun 21 2023 at 16:06):
Also, bad_theorem
is not a simp theorem no matter how you write the indices, since the rhs has more variables than the lhs so simp won't know what to rewrite to. Syntactically, it could be a @[congr]
lemma--but there's no need to add it since the built-in congruences work just fine for getElem
.
Alex Keizer (Jun 23 2023 at 11:02):
Sure, I wasn't actually proposing to add bad_theorem
as a simp-lemma, I just wanted an example of something which is definitely not in simp normal form.
Alex Keizer (Jun 23 2023 at 11:40):
Regardless, I now have 2 PRs for mathlib: #4994 makes the change to v[i.1]
as normal form, and #5421 is an alternate proposal that removes the simp
attribute from getElem_fin
and use v[i]
as the normal form. (I've also submitted a PR to std
which removes the simp attribute directly at the declaration).
For both PRs I've only made the changes in Vector
files, so CI is still failing on downstream files. But, I wanted to first get some more opinions and reach consensus on which direction to go in. So far, there are three options:
- Stick with the status quo:
Vector.get
(my personal preference, but there does seem consensus that we want to move away from it) - Use
getElem
withFin
, i.e.,v[i]
. This is #5421 - Use
getElem
withNat
, i.e.,v[i.1]
. This is the original PR #4994
Benefits of option 1
I like get xs i
because it has less implicit variables and no type class synthesis.
Thus, it plays nicer with type inference. Then again, there is no harm in specifying types a bit more, so I don't particularly mind this point.
example (xs : Vector α n) (i) : xs.get i = b := sorry
example (xs : Vector α n) (i : Fin n) : xs[i] = b := sorry
More importantly, moving to getElem
causes a few simp lemmas to no longer infer their arguments automatically.
Compare the three versions of scanl_get
(Admittedly, by far the most affected proof, most proofs were fine).
/-- The original/Option 1: `get v i` -/
(scanl f b v).get i.succ = f ((scanl f b v).get (Fin.castSucc i)) (v.get i) := by
cases' n with n
· exact i.elim0
induction' n with n hn generalizing b
· have i0 : i = 0 := Fin.eq_zero _
simp [scanl_singleton, i0, get_zero]; simp [get_eq_get]
· rw [← cons_head_tail v, scanl_cons, get_cons_succ]
refine' Fin.cases _ _ i
· simp only [get_zero, scanl_head, Fin.castSucc_zero, head_cons]
· intro i'
simp only [hn, Fin.castSucc_fin_succ, get_cons_succ]
/-- Option 2: `v[i]` -/
theorem scanl_get (i : Fin n) :
(scanl f b v)[i.succ] = f ((scanl f b v)[Fin.castSucc i]) v[i] := by
cases' n with n
· exact i.elim0
induction' n with n hn generalizing b
· have i0 : i = (0 : Fin 1) := Fin.eq_zero _
simp [scanl_singleton, i0, get_zero]; simp [get_eq_get]
· rw [← cons_head_tail v, scanl_cons, get_cons_succ (n:=n+2) _ _ i]
refine' Fin.cases _ _ i
· simp only [get_zero (n:=n+2), get_zero (n:=n+1), scanl_head, Fin.castSucc_zero, head_cons]
· intro i'
simp only [hn, Fin.castSucc_fin_succ, get_cons_succ (n:=n+2), get_cons_succ (n:=n+1)]
/-- Option 3: `v[i.1]` -/
theorem scanl_get (i : Fin n) :
(scanl f b v)[i.1 + 1]'i.succ.2 = f ((scanl f b v)[i.1]) v[i.1] := by
cases' n with n
· exact i.elim0
induction' n with n hn generalizing b
· simp [scanl_singleton, get_zero, get_eq_get, get_cons_succ _ _ i]
· rw [← cons_head_tail v, scanl_cons, get_cons_succ_nat (n:=n+2) _ _ i.1]
refine' Fin.cases _ _ i
· simp [get_zero_nat (n := n + 1), get_zero_nat (n := n + 2), scanl_head, head_cons]
· intro i'
simp only [Fin.val_succ, hn, get_cons_succ_nat (n := n + 2), get_cons_succ_nat (n := n + 1)]
In particular, notice how in option 1 simp[get_zero]
and simp[get_cons_succ]
just work, but in the other two we have to specify the value of n
for it to do the rewrite.
Benefits of Option 2 (vs. option 3)
My reason for suggesting the alternative PR, is that for option 3 the normal form of what used to be get xs i.succ
should now be spelled xs[i.1 + 1]'i.succ.2
, which felt a bit to verbose to me.
I think the effect of sticking with option 3 (v[i.1]
) would be that the easiest way to state theorems would be to avoid Fin
entirely, and state them as v[i]'h
where i : Nat
and h : i < n
. I quite like the Fin
abstraction, so I would prefer to keep it!
Alex Keizer (Jun 23 2023 at 11:44):
@Chris Hughes @Gabriel Ebner and @Scott Morrison, I'd like your opinions on this in particular!
Mario Carneiro (Jun 23 2023 at 11:46):
The part I don't understand re: the std change is that std doesn't define Vector
or Bitvec
at all, the theorems under discussion are about arrays
Alex Keizer (Jun 23 2023 at 11:55):
Well, Std
provides a generic GetElem \a (Fin n) _ _
instance for any implementors of GetElem \a Nat _ _
. We want to index Vector
s with both Nat
and Fin n
, so we define GetElem (Vector a n) Nat
, which gives us GetElem (Vector a n) (Fin n) _ _
(but also GetElem (Vector a n) (Fin m) _ _
, with appropriate side conditions ) from this generic instance in Std
. The lemma in question, that rewrites v[i]
for i : Fin n
into v[i.1]
applies to all versions of this generic GetElem cont (Fin n)
instance, so also Vectors
Alex Keizer (Jun 23 2023 at 11:56):
I guess an alternative would be to restrict that GetElem
instance to just Arrays. In fact, I would be in favour of that, so that we can have a more specialized instance for Vector
, only allowing a Fin n
to index a Vector \a n
if the indices match.
Alex Keizer (Jun 23 2023 at 12:14):
Actually, part way through the change I've had to define a specialized GetElem
instance anyway
section GetElem
variable (cont : ℕ → Type u)
instance [GetElem (cont n) Nat elem (fun _ i => i < n)] :
GetElem (cont n) (Fin n) elem fun _ _ => True where
getElem xs i _ := getElem xs i.1 i.2
end GetElem
Which makes the std change no longer necessary. Still, I do think we should restrict that generic instance from std
to just Arrays anyway.
If we have both then the following is accepted, but refers to the generic instance (the indices don't match), and rewrites that do use the new instance won't apply.
example (v : Vector \a (n+1)) (i : Fin n) : a := v[i]
Since the pretty printer does not show which instance is used by default, this makes for very painful debugging
Mario Carneiro (Jun 23 2023 at 12:50):
I think you should stick with Vector.get
. The generic instance is supposed to unfold in this way, while the Vector.get
function can assert that the indices match
Mario Carneiro (Jun 23 2023 at 12:51):
the reasons seem to be similar as to why List
uses a get
function
Alex Keizer (Jun 23 2023 at 13:15):
I think List
can't even define the stricter GetElem
function, since the approriate index type Fin (xs.length)
depends on the concrete list being indexed, whereas for Vector
we know the index from the vector type alone.
Andrés Goens (Jun 23 2023 at 14:24):
would that mean there'd be no GetElem
for Fin n
for Array
at all? Because one thing I've always found confusing with this is that something like a classical loop in do
notation won't work:
def foo (a : Array Nat) : Nat := Id.run do
let mut res := 0
for i in Array.range a.size do
res := res + a[i]
return res
and thought if there was a similar function range' (a : Array) -> Array (Fin a.size)
then that should work with the GetElem
instance the way you'd expect it to
Alex Keizer (Jun 23 2023 at 14:26):
There is one, it's the problematic generic instance, but it effectively ignores the index n
. Instead, it takes as side-condition a proof that the value i.val
is less than the size of the array/vector.
Alex Keizer (Jun 23 2023 at 14:27):
So that you can index an array of size 2 with 1 : Fin 4
, even though the index is too high (because the actual value is still in bounds)
Andrés Goens (Jun 23 2023 at 14:29):
I see, yeah that sounds more problematic for sure, maybe it makes sense to just have an instance for the case that n
is the size of the array? I'm not sure I understand why the instance by itself is problematic for simp normal form?
Alex Keizer (Jun 23 2023 at 14:31):
Because if you write v[Fin.succ i]
, then the side condition is sometimes inferred as (Fin.succ i).1 < n
and other times as i.1 + 1 < n
. These are different types, so if your lemma used on side condition, and your goal another, then the rewrite fails
Andrés Goens (Jun 23 2023 at 14:34):
oh I guess you mean if you have an instance you want to be able to reason about it and thus need a simp normal form, yeah that makes sense
Chris Hughes (Jun 23 2023 at 14:39):
I have a strong preference for either Option 1 or Option 2 as a simp normal form. Using Fin
is usually much better than using a Nat
and a proof. The proof term can make rewriting difficult, since it depends on the Nat
, and we can define operations on Fin
which bundle the proofs, and avoid us having to write the same proofs over again. We've already moved away from List.NthLE
in the transition from mathlib3 to mathlib4.
Alex Keizer (Jun 23 2023 at 14:50):
Andrés Goens said:
I see, yeah that sounds more problematic for sure, maybe it makes sense to just have an instance for the case that
n
is the size of the array? I'm not sure I understand why the instance by itself is problematic for simp normal form?
Also, for arrays it's not possible to only have an instance for Fin (xs.size)
, since the index type is not able to depend on the concrete array being indexed. I guess that's why the current instance is set up with the side condition
Alex Keizer (Jun 23 2023 at 14:51):
Chris Hughes said:
I have a strong preference for either Option 1 or Option 2 as a simp normal form. Using
Fin
is usually much better than using aNat
and a proof. The proof term can make rewriting difficult, since it depends on theNat
, and we can define operations onFin
which bundle the proofs, and avoid us having to write the same proofs over again. We've already moved away fromList.NthLE
in the transition from mathlib3 to mathlib4.
I guess with Mario preferring not to make the changes to Std needed to make Option 2 not be a big footgun, that leaves only Option 1 (sticking with Vector.get
)?
Tobias Grosser (Jun 23 2023 at 15:03):
Cool. Option 1 seems to be a good one.
Alex Keizer (Jun 23 2023 at 15:09):
Allright, then I'll restore the PR to using get
Last updated: Dec 20 2023 at 11:08 UTC