Imported from fmhall/lean-png (
.claude/skills/lean-fuel-induction/SKILL.md). Install upstream withnpx skills add fmhall/lean-png --skill lean-fuel-induction. Copyright stays with the author.
Lean 4 Fuel Induction and Loop Invariant Patterns
Avoid forIn on Range in Proofs
forIn [:n] uses Std.Legacy.Range.forIn' with a well-founded recursion loop✝
that CANNOT be unfolded by name. with_unfolding_all rfl only works for concrete
values (:= [:0], [:1]) not symbolic n.
If you need to prove properties of a for i in [:n] loop, replace it with explicit
recursion (see copyLoop in Inflate.lean).
Fuel Independence Proof Pattern
For fuel-based recursive functions:
f x (fuel + 1) = some result → ∀ k, f x (fuel + k) = some result
Use induction on fuel with:
conv => lhs; rw [show n+1+k = (n+k)+1 from by omega]beforeunfold f at h ⊢so both sides unfold at the same successor levelcaseson each non-recursive operationihfor recursive calls
For if reduction in h, use rw [if_pos/if_neg] NOT simp [cond] — simp
over-simplifies (strips some/pure wrappers).
For guard in do-blocks, use by_cases on the condition then:
simp only [guard, hcond, ↓reduceIte]
The guard uses Alternative.guard, NOT Option.guard.
Loop Invariant Proof Pattern
For recursive functions like copyLoop buf start distance k length, prove a
generalized invariant by well-founded induction carrying the full buffer state:
- State the invariant relating
bufto the originaloutput - Base case:
k = length,copyLoopreturnsbuf, use hypothesis - Inductive step: show
buf.push xsatisfies the invariant fork+1
Key lemmas:
push_getElem_lt— push preserves earlier elementspush_data_toList—(buf.push b).data.toList = buf.data.toList ++ [b]List.ofFn_succ_last— snoc decomposition ofList.ofFn
Combined Invariant Lemma Pattern for BitReader/State Operations
When proving that a chain of operations on a stateful type (like BitReader) preserves
multiple properties (data equality, position bound, position invariant), bundle all into
a single ∧ return rather than proving each separately:
private theorem op_inv (br br' : BitReader) ...
(h : op br = .ok (result, br'))
(hpos : br.bitOff = 0 ∨ br.pos < br.data.size)
(hple : br.pos ≤ br.data.size) :
br'.data = br.data ∧
(br'.bitOff = 0 ∨ br'.pos < br'.data.size) ∧
br'.pos ≤ br'.data.size
Chain with: have ⟨hd₁, hpos₁, hple₁⟩ := op_inv ...
then exact ⟨hd'.trans hd₁, hpos', hple'⟩.
This avoids 3× boilerplate and makes chaining across multiple operations clean.
See GzipCorrect.lean for examples (readBit_inv through decode_inv).
Threading Invariants Through Long Call Chains
For proofs like inflateLoop_endPos_le that must thread invariants through
5+ sequential monadic operations, the pattern is:
-- Each operation produces updated state + invariants
have ⟨hd₁, hpos₁, hple₁⟩ := readBits_inv br br₁ _ _ h_readBits hpos hple
have ⟨hd₂, hpos₂, hple₂⟩ := decode_inv tree br₁ br₂ _ h_decode hpos₁ hple₁
have ⟨hd₃, hpos₃, hple₃⟩ := readBits_inv br₂ br₃ _ _ h_readBits₂ hpos₂ hple₂
-- ...
-- At the end, compose data equalities:
exact ⟨hd_final.trans (hd₃.trans (hd₂.trans hd₁)), hpos_final, hple_final⟩
Key points:
- Each
_invlemma takes the output invariants from the previous operation as input - Data equality chains compose right-to-left via
.trans:hd₃.trans (hd₂.trans hd₁)provesbr₃.data = br.datafrombr₃.data = br₂.data,br₂.data = br₁.data,br₁.data = br.data - For recursive calls (induction step), pass the final state's invariants to
ih, then.transthe recursive result with the accumulated chain
Deeply Nested Multi-Path Case Splits
When a function branches based on a decoded symbol (e.g., literal vs end-of-block vs length-distance), the invariant proof must cover all paths:
split at h
· -- Path 1 (e.g., literal byte)
have ⟨hd', hp', hl'⟩ := ih br₁ _ h hpos₁ hple₁
exact ⟨hd'.trans hd₁, hp', hl'⟩
· split at h
· -- Path 2 (e.g., end of block) — state unchanged
simp only [Except.ok.injEq, Prod.mk.injEq] at h
obtain ⟨_, rfl⟩ := h
exact ⟨hd₁, hpos₁, hple₁⟩
· -- Path 3 (e.g., length+distance) — chain through more operations
-- ... extract sub-operations, chain their _inv results ...
exact ⟨hd'.trans (hd₄.trans (hd₃.trans (hd₂.trans hd₁))), hp', hl'⟩
The error path for each sub-operation is handled by simp [h_op] at h which
derives a contradiction from .error = .ok.
termination_by Proofs vs Nat.strongRecOn
When proving properties about a function defined with termination_by expr,
prefer defining the theorem with the same termination_by + decreasing_by
and making recursive calls directly, over using Nat.strongRecOn:
-- Good: matches the function's own recursion structure
private theorem f_property (data : ByteArray) (pos : Nat) (hpos : pos ≤ data.size) :
P (f data pos) := by
by_cases h : base_case
· ... -- base case
· have h_rec := f_property data (pos + step) (by omega) -- recursive call
...
termination_by data.size - pos
decreasing_by omega
Nat.strongRecOn creates an induction variable n separate from the actual
measure data.size - pos. This causes two problems:
- The induction hypothesis involves elaborating the full recursive term,
hitting
maxRecDepthon complex functions - The final arithmetic goals have
ninstead ofdata.size - pos, requiring explicitsubstorhave : n = data.size - pos
Direct termination_by avoids both issues since data.size - pos stays
concrete throughout the proof.
Suffix/Append Proofs vs Fuel-Independence Proofs
In fuel-independence proofs, simp only [hds] at h ⊢ processes both hypothesis
and goal simultaneously (same function call in both).
In suffix/append proofs, h has f bits while ⊢ has f (bits ++ suffix) —
simp only [hds] won't match the goal.
Pattern:
simp only [hds, bind, Option.bind] at h— process the hypothesisrw [f_append ...] at ⊢— transform the goalsimp only [bind, Option.bind]— reduceOption.bind (some (...)) (fun ...)in goal
For if branches appearing in both sides, use by_cases hcond : condition
then rw [if_pos/if_neg hcond] at h ⊢.
Note: split at h ⊢ (multiple targets) is NOT supported — use by_cases instead.
Except Suffix Invariance: When dsimp/simp Is Unnecessary
In Except monad suffix proofs (f (brAppend br suffix) = ... brAppend br' suffix),
two common false patterns waste build iterations:
-
After
cases hx : pure_func args | ok val =>: Thecasesalready reduces the goal'smatchfor the.okbranch. Do NOTsimp only [hx] at ⊢— only apply toh. Usesimp only [hx] at h; dsimp only [] at h ⊢if needed, or justsimp only [hx] at h. -
After
rw [if_neg hcond] at h ⊢: Theifexpression is fully reduced. A subsequentsimp only [pure, Except.pure] at h ⊢ordsimp only [] at h ⊢often makes no progress. Only add these if the goal still contains unreducedpure/Except.purewrappers — check by building first without them.
Rule of thumb: Start minimal (just rw [if_neg hcond] at h ⊢ then the next
operation), add dsimp/simp only when the build tells you the goal isn't reduced.
maxRecDepth and maxHeartbeats Reduction
After getting proofs to work, reduce maxRecDepth and maxHeartbeats to the
minimum needed. This speeds up compilation and catches unnecessary unfolding.
Protocol:
- Start with whatever value makes the proof work (e.g., 4096 / 4,000,000)
- Try halving: 2048 / 2,000,000
- Table correspondence lemmas (non-recursive
simpchains) often work at 512 - Non-recursive proofs may not need any
set_optionat all
Common values by proof type:
- Recursive monadic chain (5+ operations):
maxRecDepth 2048or4096 - Single unfold + case split:
maxRecDepth 512or default - Pure
simp/omegaproofs: noset_optionneeded
Always test by removing the set_option entirely first — it may no longer be
needed after proof cleanup.
Single-Step Unfolding of WF-Recursive Definitions
When proving properties about a well-founded recursive function f, you often
need to unfold exactly one level of f in the goal without recursively unfolding
all occurrences.
Problem: unfold f and delta f unfold ALL occurrences of f, including
recursive calls in the body. For recursive functions like encodeStored, this
produces enormous goals or infinite unfolding. simp only [f, ...] loops for
the same reason. conv => unfold f is invalid inside conv blocks.
Solution: Use rw [f.eq_1] — Lean 4 auto-generates an equation lemma
f.eq_1 for every definition. It rewrites exactly one application of f.
-- BAD: unfolds encodeStored everywhere, including recursive calls
unfold encodeStored
-- BAD: loops because encodeStored appears in its own body
simp only [encodeStored, ...]
-- GOOD: rewrites exactly one occurrence
rw [encodeStored.eq_1]
When to use a standalone lemma: If rw [f.eq_1] produces a goal too large
to work with directly (many if/match branches), prove a specialized lemma
that unfolds f under specific conditions:
private theorem f_case2 (xs : List α) (h : ¬(xs.length ≤ N)) :
f xs = ... body for this case ... := by
rw [f.eq_1]
simp only [h, ↓reduceIte, ...]
Then rw [f_case2 _ h] in the main proof. This was essential for
encodeStored_non_final where unfold encodeStored was unusable.
Also beware let bindings: If f uses let x := ...; body, trying to
rewrite with ← f to fold back won't work because rw can't match through
let bindings. Use a standalone lemma instead.
Opaque Loop Catalog and Refactoring Priority (Zip/Native/)
This catalog covers every function using opaque loop constructs (while,
for ... in [:n], forIn) in Zip/Native/. For each, it assesses
whether WF refactoring is needed for spec proofs.
Recommendation: Per-function WF refactoring over generic Range.forIn
A generic Range.forIn invariant lemma (analogous to List.foldl_*) is
theoretically possible but impractical:
- The
forInwrapper involvesStd.Legacy.Range.forIn'with a hiddenloop✝that can't be named or unfolded - A generic invariant would need to parameterize over the monad, body, and accumulator, making it very complex to apply
- Each loop in this codebase has different state shapes and invariants
Continue with per-function WF refactoring for loops that need spec proofs. Leave opaque loops that don't need unfolding.
Priority 1: Needs WF refactoring (blocking sorry proofs)
| Function | File | Loop | State vars | Blocking theorem |
|---|---|---|---|---|
buildFseTable (fill loops) |
Fse.lean:148 | 4× for ... in [:n] + while |
5+ | buildFseTable_cells_size (sorry) |
decompressZstd |
ZstdFrame.lean:308 | while pos < data.size |
2 | Top-level decompression specs |
Priority 2: Would benefit from WF but not urgently blocking
| Function | File | Loop | State vars | Notes |
|---|---|---|---|---|
decodeSequences |
ZstdSequence.lean:290 | for i in [:numSeq] |
4+ | Interleaved FSE decoding; complex state |
xxHash64 (stripe loop) |
XxHash.lean:100 | while pos < stripeEnd |
5 | Blocked by UInt64 kernel eval anyway |
decodeHuffmanStream |
ZstdHuffman.lean:223 | for _ in [:count] |
2 | No spec theorems yet |
Priority 3: Probably leave as-is
| Function | File | Loop | Notes |
|---|---|---|---|
parseHuffmanWeightsDirect |
ZstdHuffman.lean:37 | for i in [:n] |
Simple accumulation, no spec needs unfolding |
weightsToMaxBits (weight sum) |
ZstdHuffman.lean:63 | for w in weights |
Summation — already has WF alt (findMaxBitsWF) |
buildZstdHuffmanTable (count/fill) |
ZstdHuffman.lean:76 | 4× for |
Complex but tableSize theorem needs only the fill loops |
parseHuffmanTreeDescriptor (trim) |
ZstdHuffman.lean:176 | while |
Trailing-zero trim, no spec impact |
decodeFseSymbols |
Fse.lean:310 | for i in [:count] |
No spec theorems needed |
decodeFseSymbolsAll |
Fse.lean:335 | for _ in [:fuel] |
Fuel-bounded, no spec impact |
Gzip.decompress (member loop) |
Gzip.lean:21 | for _ in [:1000] |
Bounded member loop, specs don't unfold it |
deflateStored |
Deflate.lean:25 | while |
Compression, not spec'd |
Already refactored (WF-friendly)
These functions already use explicit recursion or well-founded recursion:
findMaxBitsWF(ZstdHuffman.lean) — WF replacement forweightsToMaxBitsdecompressBlocksWF(ZstdFrame.lean) — WF replacement for multi-block frame decompression (PR #667)copyBytes,copyMatch,executeSequences.loop(ZstdSequence.lean) — explicit recursion with spec proofsprocessRemaining8,processRemaining1(XxHash.lean) — WF recursiondecodeFseLoop(Fse.lean) — fuel-based with equation lemmas and spec proofspushZeros,decodeZeroRepeats(Fse.lean) — explicit recursion
Refactoring effort estimates
- buildFseTable: High effort. 4 loops with different state shapes.
Consider refactoring only the specific loops that
cells_sizeneeds (the fill loops at lines 128-143, 166-174), not all 4. - decompressZstd: Low effort. Simple position-advancing loop. But
depends on
decompressFramewhich depends ondecompressBlocks(now refactored). - decodeSequences: High effort. Interleaved FSE state transitions with conditional updates. 4+ state variables with complex interdependencies.
Cross-References
- WF recursion patterns:
lean-wf-recursionskill — for well-founded recursion (termination_by,f.induct,unfoldvsrw [f.eq_1], dependentifguards withdif_pos/dif_neg, fuel-to-WF migration) - Roundtrip proofs:
lean-roundtrip-proofsskill — for suffix invariance chains (_append lemmas), goR (decode-with-remaining), and accumulator equivalence patterns