Skip to content

Commit c9253b7

Browse files
authored
feat(fc): prove incremental weight maintenance agrees with the naive tally (#74)
Deliver the weight-delta layer of #67: the algebraic core that makes proto-array-style incremental head computation a refinement of the naive spec walk. - Weights.Equiv: extensional equality of weight maps, with get_bump characterizing bump as pointwise +1. - creditChain_get_add / accumulateAncestorWeights_append: the vote tally is pointwise additive, so per-vote weight deltas are well-defined. - accumulateAncestorWeights_perm: the tally is order-free — an incremental maintainer may apply vote updates in any order. - ghostWalk_congr_weights / computeLmdGhostHead_incremental: the GHOST descent (children, threshold, comparison, tie-break) reads weights only through get, so any maintained map extensionally equal to the full tally selects exactly the spec head. creditChain is made public (was private) so the lemmas can reason about each step, mirroring the ghostWalk precedent. The best-child/best-descendant cache layer of proto-array sits on top and is follow-up; its correctness reduces to the walk over the same weight function proved here.
1 parent 4ad8e28 commit c9253b7

3 files changed

Lines changed: 284 additions & 2 deletions

File tree

LeanSpec.lean

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import LeanSpec.Forks.Lstar.StateTransition
1818
import LeanSpec.Forks.Lstar.Store.Ancestry
1919
import LeanSpec.Forks.Lstar.Store.BlockProduction
2020
import LeanSpec.Forks.Lstar.Store.ChainAlignment
21+
import LeanSpec.Forks.Lstar.Store.IncrementalWeights
2122
import LeanSpec.Forks.Lstar.Store.OnBlock
2223
import LeanSpec.Forks.Lstar.Store.Prune
2324
import LeanSpec.Forks.Lstar.Store.Store
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
/-
2+
Incremental (proto-array-style) weight maintenance agrees with the
3+
naive re-tally.
4+
5+
Upstream's `_compute_lmd_ghost_head` re-tallies every vote on every call
6+
(`_accumulate_ancestor_weights` folded over the full LMD view).
7+
Production clients instead maintain the weight map incrementally: when
8+
the vote set changes, only the delta (votes added, votes removed is
9+
handled by re-extraction here — the LMD view is always a function of the
10+
pool) is credited. Issue #67 asks for the refinement theorem making that
11+
optimization a conformance target.
12+
13+
This file proves the algebraic core of that refinement:
14+
15+
- `Weights.Equiv` — extensional equality of weight maps (`get`-equal;
16+
association lists differing in entry order or zero entries are the
17+
same weight function).
18+
- `creditChain_get_add` / `accumulateAncestorWeights_append` — the
19+
tally is **pointwise additive**: crediting a batch is the pointwise
20+
sum of crediting its parts. This is exactly the property that makes
21+
per-vote weight *deltas* well-defined.
22+
- `accumulateAncestorWeights_perm` — the tally is **order-free**: any
23+
permutation of the vote list yields the same weight function, so an
24+
incremental maintainer may apply vote updates in any order.
25+
- `ghostWalk_congr_weights` / `computeLmdGhostHead_congr_weights` —
26+
the GHOST descent reads weights only through `get`, so extensionally
27+
equal weight maps select the same head (tie-break included).
28+
29+
Together: a client that maintains weights incrementally — in any order,
30+
batching however it likes — computes the same head as the naive spec
31+
walk, provided its maintained map is `get`-equal to the full tally.
32+
The best-child/best-descendant cache layer of proto-array sits on top
33+
and is follow-up work; its correctness reduces to the walk over the
34+
same weight function proved here.
35+
-/
36+
37+
import LeanSpec.Forks.Lstar.Store.Ancestry
38+
39+
namespace LeanSpec.Forks.Lstar
40+
namespace Store
41+
42+
namespace Weights
43+
44+
/-- Extensional equality of weight maps: equal weight on every root.
45+
Association lists differing in order or explicit zeros are identified. -/
46+
def Equiv (w w' : Weights) : Prop := ∀ r : Root, w.get r = w'.get r
47+
48+
theorem Equiv.refl (w : Weights) : Equiv w w := fun _ => rfl
49+
50+
theorem Equiv.symm {w w' : Weights} (h : Equiv w w') : Equiv w' w :=
51+
fun r => (h r).symm
52+
53+
theorem Equiv.trans {w₁ w₂ w₃ : Weights} (h1 : Equiv w₁ w₂)
54+
(h2 : Equiv w₂ w₃) : Equiv w₁ w₃ :=
55+
fun r => (h1 r).trans (h2 r)
56+
57+
/-- `find?` skips entries whose key differs from the searched root. -/
58+
private theorem find?_filter_ne (w : Weights) (r x : Root) (hxr : ¬x = r) :
59+
(w.filter (fun q => !(q.1 == r))).find? (fun p => p.1 == x)
60+
= w.find? (fun p => p.1 == x) := by
61+
induction w with
62+
| nil => rfl
63+
| cons a t ih =>
64+
by_cases har : a.1 = r
65+
· have hnax : ¬a.1 = x := fun hax => hxr (by rw [← hax, har])
66+
rw [List.filter_cons]
67+
rw [if_neg (by simp [har])]
68+
rw [List.find?_cons_of_neg (by simp [hnax]), ih]
69+
· rw [List.filter_cons]
70+
rw [if_pos (by simp [har])]
71+
by_cases hax : a.1 = x
72+
· rw [List.find?_cons_of_pos (by simp [hax]),
73+
List.find?_cons_of_pos (by simp [hax])]
74+
· rw [List.find?_cons_of_neg (by simp [hax]),
75+
List.find?_cons_of_neg (by simp [hax]), ih]
76+
77+
/-- `bump` adds exactly one to the bumped root and nothing elsewhere. -/
78+
theorem get_bump (w : Weights) (r x : Root) :
79+
(w.bump r).get x = w.get x + (if x = r then 1 else 0) := by
80+
unfold bump
81+
cases hf : w.find? (fun p => p.1 == r) with
82+
| some p =>
83+
by_cases hxr : x = r
84+
· subst hxr
85+
unfold get
86+
rw [List.find?_cons_of_pos (by simp)]
87+
simp only [Option.map_some, Option.getD_some]
88+
rw [hf]; rfl
89+
· unfold get
90+
have hnrx : ¬(r = x) := fun h => hxr h.symm
91+
rw [List.find?_cons_of_neg (by simp [hnrx]),
92+
find?_filter_ne w r x hxr]
93+
simp [hxr]
94+
| none =>
95+
by_cases hxr : x = r
96+
· subst hxr
97+
unfold get
98+
rw [List.find?_cons_of_pos (by simp)]
99+
simp only [Option.map_some, Option.getD_some]
100+
rw [hf]; rfl
101+
· unfold get
102+
have hnrx : ¬(r = x) := fun h => hxr h.symm
103+
rw [List.find?_cons_of_neg (by simp [hnrx])]
104+
simp [hxr]
105+
106+
end Weights
107+
108+
/-- The chain credit is pointwise additive over its accumulator: crediting
109+
on top of `w` reads as `w` plus crediting from empty. This is the exact
110+
algebraic fact that makes per-vote weight deltas well-defined. -/
111+
theorem creditChain_get_add (st : Store) (s : Slot) :
112+
∀ (fuel : Nat) (r : Root) (w : Weights) (x : Root),
113+
(creditChain st s fuel r w).get x
114+
= w.get x + (creditChain st s fuel r []).get x
115+
| 0, _, _, _ => rfl
116+
| fuel + 1, r, w, x => by
117+
have h0 : Weights.get ([] : Weights) x = 0 := rfl
118+
unfold creditChain
119+
cases hb : st.getBlock? r with
120+
| none => dsimp only; omega
121+
| some b =>
122+
dsimp only
123+
by_cases hle : b.slot ≤ s
124+
· rw [if_pos hle, if_pos hle]; omega
125+
· rw [if_neg hle, if_neg hle]
126+
have h1 := creditChain_get_add st s fuel b.parentRoot (w.bump r) x
127+
have h2 := creditChain_get_add st s fuel b.parentRoot
128+
(Weights.bump [] r) x
129+
have h3 := Weights.get_bump w r x
130+
have h4 := Weights.get_bump [] r x
131+
omega
132+
133+
/-- Folding the credit step over a batch on top of any accumulator reads
134+
as the accumulator plus folding from empty. -/
135+
private theorem tallyFold_get_add (st : Store) (s : Slot) :
136+
∀ (l : List (Nat × AttestationData)) (w : Weights) (x : Root),
137+
(l.foldl (fun w att =>
138+
creditChain st s (st.blocks.length + 1) att.2.head.root w) w).get x
139+
= w.get x + (l.foldl (fun w att =>
140+
creditChain st s (st.blocks.length + 1) att.2.head.root w) []).get x
141+
| [], w, x => by
142+
have h0 : Weights.get ([] : Weights) x = 0 := rfl
143+
dsimp only [List.foldl_nil]
144+
omega
145+
| v :: t, w, x => by
146+
rw [List.foldl_cons, List.foldl_cons]
147+
have h1 := tallyFold_get_add st s t
148+
(creditChain st s (st.blocks.length + 1) v.2.head.root w) x
149+
have h2 := tallyFold_get_add st s t
150+
(creditChain st s (st.blocks.length + 1) v.2.head.root []) x
151+
have h3 := creditChain_get_add st s (st.blocks.length + 1)
152+
v.2.head.root w x
153+
omega
154+
155+
/-- The batch tally is pointwise additive: tallying `a ++ b` is the
156+
pointwise sum of tallying each part. An incremental maintainer may
157+
therefore credit any new batch on top of an existing tally — the
158+
algebraic fact that makes per-vote weight *deltas* well-defined. -/
159+
theorem accumulateAncestorWeights_append (st : Store)
160+
(a b : List (Nat × AttestationData)) (s : Slot) (x : Root) :
161+
(accumulateAncestorWeights st (a ++ b) s).get x
162+
= (accumulateAncestorWeights st a s).get x
163+
+ (accumulateAncestorWeights st b s).get x := by
164+
unfold accumulateAncestorWeights
165+
rw [List.foldl_append]
166+
exact tallyFold_get_add st s b _ x
167+
168+
/-- Prepending one vote adds its chain credit pointwise. -/
169+
private theorem accumulate_cons_get (st : Store)
170+
(v : Nat × AttestationData) (l : List (Nat × AttestationData))
171+
(s : Slot) (x : Root) :
172+
(accumulateAncestorWeights st (v :: l) s).get x
173+
= (accumulateAncestorWeights st [v] s).get x
174+
+ (accumulateAncestorWeights st l s).get x := by
175+
have h := accumulateAncestorWeights_append st [v] l s x
176+
simpa using h
177+
178+
/-- The tally is order-free: permuting the vote list leaves the weight of
179+
every root unchanged, so incremental updates may be applied in any
180+
order. -/
181+
theorem accumulateAncestorWeights_perm (st : Store)
182+
{a b : List (Nat × AttestationData)} (hperm : a.Perm b) (s : Slot)
183+
(x : Root) :
184+
(accumulateAncestorWeights st a s).get x
185+
= (accumulateAncestorWeights st b s).get x := by
186+
induction hperm with
187+
| nil => rfl
188+
| cons v _ ih =>
189+
rename_i l₁ l₂ _
190+
have h1 := accumulate_cons_get st v l₁ s x
191+
have h2 := accumulate_cons_get st v l₂ s x
192+
omega
193+
| swap u v l =>
194+
have h1 := accumulate_cons_get st v (u :: l) s x
195+
have h2 := accumulate_cons_get st u l s x
196+
have h3 := accumulate_cons_get st u (v :: l) s x
197+
have h4 := accumulate_cons_get st v l s x
198+
omega
199+
| trans _ _ ih1 ih2 => exact ih1.trans ih2
200+
201+
/-! ## The GHOST walk reads weights only through `get` -/
202+
203+
/-- Eligible children agree between extensionally equal weight maps. -/
204+
theorem childrenOf_congr_weights (st : Store) {w w' : Weights}
205+
(h : Weights.Equiv w w') (minScore : Option Nat) (parent : Root) :
206+
childrenOf st w minScore parent = childrenOf st w' minScore parent := by
207+
unfold childrenOf
208+
congr 1
209+
apply List.filter_congr
210+
intro p _
211+
cases minScore with
212+
| none => rfl
213+
| some m => simp only [h p.1]
214+
215+
/-- The child comparison agrees between extensionally equal weight maps. -/
216+
theorem beats_congr_weights {w w' : Weights} (h : Weights.Equiv w w')
217+
(best cand : Root) : beats w best cand = beats w' best cand := by
218+
unfold beats
219+
rw [h best, h cand]
220+
221+
/-- Folding a pick with pointwise-equal comparisons picks the same. -/
222+
private theorem foldl_pick_congr {f g : Root → Root → Bool}
223+
(h : ∀ a b, f a b = g a b) :
224+
∀ (cs : List Root) (a : Root),
225+
cs.foldl (fun best cand => if f best cand then cand else best) a
226+
= cs.foldl (fun best cand => if g best cand then cand else best) a
227+
| [], _ => rfl
228+
| c :: cs, a => by
229+
rw [List.foldl_cons, List.foldl_cons, h a c]
230+
exact foldl_pick_congr h cs _
231+
232+
/-- The winning child agrees between extensionally equal weight maps. -/
233+
theorem maxChild_congr_weights {w w' : Weights} (h : Weights.Equiv w w')
234+
(cs : List Root) : maxChild w cs = maxChild w' cs := by
235+
cases cs with
236+
| nil => rfl
237+
| cons c t =>
238+
unfold maxChild
239+
dsimp only
240+
rw [foldl_pick_congr (beats_congr_weights h) t c]
241+
242+
/-- The GHOST descent agrees between extensionally equal weight maps:
243+
every step (child enumeration, threshold filter, comparison, tie-break)
244+
reads weights only through `get`. -/
245+
theorem ghostWalk_congr_weights (st : Store) {w w' : Weights}
246+
(h : Weights.Equiv w w') (minScore : Option Nat) :
247+
∀ (fuel : Nat) (head : Root),
248+
ghostWalk st w minScore fuel head
249+
= ghostWalk st w' minScore fuel head
250+
| 0, _ => rfl
251+
| fuel + 1, head => by
252+
unfold ghostWalk
253+
rw [childrenOf_congr_weights st h, maxChild_congr_weights h]
254+
cases maxChild w' (childrenOf st w' minScore head) with
255+
| none => rfl
256+
| some best => exact ghostWalk_congr_weights st h minScore fuel best
257+
258+
/-- #67 (weight-delta layer): head selection depends on the vote tally
259+
only extensionally. A client maintaining the weight map incrementally —
260+
crediting batches in any order (`accumulateAncestorWeights_append`,
261+
`accumulateAncestorWeights_perm`) — selects exactly the head of the
262+
naive spec walk, tie-break included, as long as its maintained map is
263+
`get`-equal to the full tally. -/
264+
theorem computeLmdGhostHead_incremental (st : Store) (startRoot : Root)
265+
(attestations : List (Nat × AttestationData))
266+
{anchor : Block} (hanchor : st.getBlock? startRoot = some anchor)
267+
{w : Weights}
268+
(hw : Weights.Equiv w
269+
(accumulateAncestorWeights st attestations anchor.slot))
270+
(minScore : Option Nat) :
271+
ghostWalk st w minScore (st.blocks.length + 1) startRoot
272+
= computeLmdGhostHead st startRoot attestations minScore := by
273+
unfold computeLmdGhostHead
274+
rw [hanchor]
275+
exact ghostWalk_congr_weights st hw minScore (st.blocks.length + 1)
276+
startRoot
277+
278+
end Store
279+
end LeanSpec.Forks.Lstar

LeanSpec/Forks/Lstar/Store/Store.lean

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,10 @@ def extractAttestationsFromAggregatedPayloads
310310

311311
/-- Credit one vote to its head block and every ancestor above
312312
`startSlot` (the climb inside `_accumulate_ancestor_weights`): the walk
313-
stops at the anchor slot or where the chain leaves the known tree. -/
314-
private def creditChain (st : Store) (startSlot : Slot) :
313+
stops at the anchor slot or where the chain leaves the known tree.
314+
Public so the incremental-weight lemmas (#67,
315+
`Store/IncrementalWeights.lean`) can reason about each step. -/
316+
def creditChain (st : Store) (startSlot : Slot) :
315317
Nat → Root → Weights → Weights
316318
| 0, _, w => w
317319
| fuel + 1, current, w =>

0 commit comments

Comments
 (0)