Zulip Chat Archive

Stream: lean4

Topic: lazy lists


James Gallicchio (Feb 08 2022 at 05:55):

Hey, just wondering if there's already an implementation of lazy lists using Thunks in lean4. Seems to be mentioned in https://leanprover.github.io/lean4/doc/thunk.html but I can't find anything

James Gallicchio (Feb 08 2022 at 05:57):

oh, wait, GitHub search to the rescue: https://github.com/leanprover/lean4/blob/d0996fb9450dc37230adea9d10ecfdf10330ef67/tests/playground/lazylist.lean

James Gallicchio (Feb 08 2022 at 05:58):

It's a bit unfortunate that everything is partial here, but I don't know how else it'd be defined. Hm.

Mario Carneiro (Feb 08 2022 at 06:05):

I don't think anything there needs to be partial

James Gallicchio (Feb 08 2022 at 06:16):

Mario Carneiro said:

I don't think anything there needs to be partial

Yeah, the issue seems to be that Lean can't prove that a lazylist is not an infinite stack of Delayed constructors. But I can't think of how to (a) prove that, if structural recursion won't, nor (b) how to build an infinite stack of Delayed constructors in Lean

Mario Carneiro (Feb 08 2022 at 06:17):

Structural recursion will prove it, but structural recursion on nested inductives has poor support right now

Mario Carneiro (Feb 08 2022 at 06:17):

noncomputable def height (ll : LazyList α) : Nat :=
  @LazyList.rec (motive_1 := fun _ => Nat) (motive_2 := fun _ => Nat)
    α 0 (fun _ _ ih => ih + 1) (fun _ ih => ih + 1) (fun _ ih => ih ()) ll

Mario Carneiro (Feb 08 2022 at 06:17):

you can prove everything decreasing by this measure

James Gallicchio (Feb 08 2022 at 06:19):

ahh, cool. does the noncomputability relax the requirements for structural recursion?

Mario Carneiro (Feb 08 2022 at 06:19):

no, it's just an artifact of the fact that code generation has not yet been implemented for T.rec functions

James Gallicchio (Feb 08 2022 at 06:20):

Huh, okay. Will keep in mind

Mario Carneiro (Feb 08 2022 at 06:20):

T.rec is the underlying axiomatic principle for structural recursion. The equation compiler (and the induction tactic) is the frontend so you don't have to use it directly like this

James Gallicchio (Feb 08 2022 at 06:22):

So then which recursor has code generation been implemented for..?

Mario Carneiro (Feb 08 2022 at 06:22):

none

Mario Carneiro (Feb 08 2022 at 06:23):

If you use T.rec directly, you will always be forced to mark the function noncomputable unless you use implementedBy to redirect the compiler to a different definition with a compilable implementation

James Gallicchio (Feb 08 2022 at 06:26):

oh. huh. but the equation compiler generates a compilable implementation, which is why most user-defined recursive functions remain computable?

Mario Carneiro (Feb 08 2022 at 06:52):

Right. The equations of an equation compiler definition are interpreted "directly", in the manner of a general recursive function without regard to termination

Mario Carneiro (Feb 08 2022 at 06:53):

Here's a translation of the LazyList file to modern lean 4:

/-
Copyright (c) 2019 Microsoft Corporation. All rights reserved.
Released under Apache 2.0 license as described in the file LICENSE.
Author: Leonardo de Moura
-/

inductive LazyList (α : Type u)
| nil : LazyList α
| cons (hd : α) (tl : LazyList α) : LazyList α
| delayed (t : Thunk (LazyList α)) : LazyList α

@[extern cpp inline "#2"]
def List.toLazy {α : Type u} : List α  LazyList α
| []     => LazyList.nil
| (h::t) => LazyList.cons h (toLazy t)

namespace LazyList
variable {α : Type u} {β : Type v} {δ : Type w}

instance : Inhabited (LazyList α) :=
nil

@[inline] protected def pure : α  LazyList α
| a => cons a nil

noncomputable def height (ll : LazyList α) : Nat :=
  @LazyList.rec (motive_1 := fun _ => Nat) (motive_2 := fun _ => Nat)
    α 0 (fun _ _ ih => ih + 1) (fun _ ih => ih + 1) (fun _ ih => ih ()) ll

@[simp] theorem height_nil : (@LazyList.nil α).height = 0 := rfl
@[simp] theorem height_cons : (@LazyList.cons α a l).height = l.height + 1 := rfl
@[simp] theorem height_delayed (as) :
  (@LazyList.delayed α as).height = as.get.height + 1 := by
  have : as = fun x => as.get := by
    cases as with | _ fn => apply congrArg; funext (); rfl
  rw [this]; rfl

def get : LazyList α  LazyList α
| (delayed as) => get as.get
| other        => other
termination_by _ as => as.height

def isEmpty : LazyList α  Bool
| nil          => true
| (cons _ _)   => false
| (delayed as) => isEmpty as.get
termination_by _ as => as.height

def toList : LazyList α  List α
| nil          => []
| (cons a as)  => a :: toList as
| (delayed as) => toList as.get
termination_by _ as => as.height

def head [Inhabited α] : LazyList α  α
| nil          => default
| (cons a as)  => a
| (delayed as) => head as.get
termination_by _ as => as.height

def tail : LazyList α  LazyList α
| nil          => nil
| (cons a as)  => as
| (delayed as) => tail as.get
termination_by _ as => as.height

def append : LazyList α  (Unit  LazyList α)  LazyList α
| nil,        bs => bs ()
| cons a as,  bs => cons a (delayed (append as bs))
| delayed as, bs => append as.get bs
termination_by _ as _ => as.height

instance : Append (LazyList α) :=
fun x y => LazyList.append x (fun _ => y)⟩

def interleave : LazyList α  LazyList α  LazyList α
| nil,        bs => bs
| cons a as,  bs =>
  have : bs.height + as.height < as.height + 1 + bs.height := by
    rw [Nat.add_comm, Nat.add_right_comm]; decreasing_tactic
  cons a (delayed (interleave bs as))
| delayed as, bs =>
  have : as.get.height + bs.height < as.get.height + 1 + bs.height := by
    rw [Nat.add_right_comm]; decreasing_tactic
  interleave as.get bs
termination_by _ as bs => as.height + bs.height

@[specialize] def map (f : α  β) : LazyList α  LazyList β
| nil        => nil
| cons a as  => cons (f a) (delayed (map f as))
| delayed as => map f as.get
termination_by _ as => as.height

@[specialize] def map₂ (f : α  β  δ) : LazyList α  LazyList β  LazyList δ
| nil, _ => nil
| _, nil => nil
| cons a as, cons b bs =>
  have : as.height + bs.height < as.height + 1 + bs.height + 1 := by
    rw [Nat.add_right_comm as.height]
    exact Nat.lt_trans (Nat.lt_succ_self _) (Nat.lt_succ_self _)
 cons (f a b) (delayed (map₂ f as bs))
| delayed as, bs =>
  have : as.get.height + bs.height < as.get.height + 1 + bs.height := by
    rw [Nat.add_right_comm]; decreasing_tactic
  map₂ f as.get bs
| as, delayed bs => map₂ f as bs.get
termination_by _ as bs => as.height + bs.height

@[inline] def zip : LazyList α  LazyList β  LazyList (α × β) :=
map₂ Prod.mk

def join : LazyList (LazyList α)  LazyList α
| nil        => nil
| cons a as  => a ++ delayed (join as)
| delayed as => join as.get
termination_by _ as => as.height

@[inline] protected def bind (x : LazyList α) (f : α  LazyList β) : LazyList β :=
join (x.map f)

instance isMonad : Monad LazyList :=
{ pure := @LazyList.pure, bind := @LazyList.bind, map := @LazyList.map }

instance : Alternative LazyList :=
{ failure := nil
  orElse  := fun as bs => LazyList.append as bs }

def approx : Nat  LazyList α  List α
| 0, as => []
| _, nil => []
| i+1, cons a as => a :: approx i as
| i+1, delayed as => approx (i+1) as.get
termination_by _ as => as.height

@[specialize] partial def iterate (f : α  α) : α  LazyList α
| x => cons x (delayed (iterate f (f x)))

@[specialize] partial def iterate₂ (f : α  α  α) : α  α  LazyList α
| x, y => cons x (delayed (iterate₂ f y (f x y)))

@[specialize] def filter (p : α  Bool) : LazyList α  LazyList α
| nil          => nil
| (cons a as)  => if p a then cons a (delayed (filter p as)) else filter p as
| (delayed as) => filter p as.get
termination_by _ as => as.height

partial def cycle : LazyList α  LazyList α
| xs => xs ++ delayed (cycle xs)

partial def repeat : α  LazyList α
| a => cons a (delayed (repeat a))

def inits : LazyList α  LazyList (LazyList α)
| nil        => cons nil nil
| cons a as  => cons nil (delayed (map (fun as => cons a as) (inits as)))
| delayed as => inits as.get
termination_by _ as => as.height

private def addOpenBracket (s : String) : String :=
if s.isEmpty then "[" else s

def approxToStringAux [ToString α] : Nat  LazyList α  String  String
| _,   nil,        r => (if r.isEmpty then "[" else r) ++ "]"
| 0,   _,          r => (if r.isEmpty then "[" else r) ++ ", ..]"
| n+1, cons a as,  r => approxToStringAux n as ((if r.isEmpty then "[" else r ++ ", ") ++ toString a)
| n,   delayed as, r => approxToStringAux n as.get r
termination_by _ n as _ => as.height

def approxToString [ToString α] (as : LazyList α) (n : Nat := 10) : String :=
as.approxToStringAux n ""

instance [ToString α] : ToString (LazyList α) :=
approxToString

end LazyList

def fib : LazyList Nat :=
LazyList.iterate₂ (·+·) 0 1

def tst : LazyList String := do
  let x  [1, 2, 3].toLazy
  let y  [2, 3, 4].toLazy
  -- dbgTrace (toString x ++ " " ++ toString y) $ λ _,
  guard (x + y > 5)
  pure (toString x ++ " + " ++ toString y ++ " = " ++ toString (x+y))

open LazyList

def iota (i : UInt32 := 0) : LazyList UInt32 :=
iterate (·+1) i

partial def sieve : LazyList UInt32  LazyList UInt32
| nil          => nil
| (cons a as)  => cons a (delayed (sieve (filter (fun b => b % a != 0) as)))
| (delayed as) => sieve as.get

partial def primes : LazyList UInt32 :=
sieve (iota 2)

#eval show IO Unit from do
  let n := 10
  -- IO.println $ tst.isEmpty,
  -- IO.println $ [1, 2, 3].toLazy.cycle,
  -- IO.println $ [1, 2, 3].toLazy.cycle.inits,
  -- IO.println $ ((iota.filter (λ v, v % 5 == 0)).approx 50000).foldl (+) 0,
  IO.println $ (primes.approx 2000).foldl (·+·) 0
  -- IO.println $ tst.head,
  -- IO.println $ fib.interleave (iota.map (+100)),
  -- IO.println $ ((iota.map (+10)).filter (λ v, v % 2 == 0)),
  pure ()

Mario Carneiro (Feb 08 2022 at 06:54):

The only functions that had to remain partial are the ones that are truly infinite lists, like iterate, cycle, repeat and (unfortunately) sieve and primes

Mario Carneiro (Feb 08 2022 at 06:55):

because lean can prove that every LazyList is finite

Mario Carneiro (Feb 08 2022 at 06:56):

It would be an interesting challenge to see if it's possible to get the same performance characteristics from an implementation that is provably coinductive

Gabriel Ebner (Feb 08 2022 at 10:41):

I would suggest to use implementedBy instead of extern. The extern produces an unconditional FFI call (and doesn't work in the same file), while the following implementedBy is optimized away completely.

private unsafe def List.toLazyUnsafe {α : Type u} (xs : List α) : LazyList α :=
  unsafeCast xs

@[implementedBy List.toLazyUnsafe]
def List.toLazy {α : Type u} : List α  LazyList α
  | []     => LazyList.nil
  | (h::t) => LazyList.cons h (toLazy t)

James Gallicchio (Feb 08 2022 at 17:32):

On a kinda similar note,

def take [S : Stream ρ τ] (s : ρ) : Nat  List τ × ρ
| 0 => ([], s)
| n+1 =>
  match S.next? s with
  | none => ([], s)
  | some (x,rest) =>
    let (L,rest) := take rest n
    (x::L, rest)

this definition (with the built-in Stream) compiles fine, but trying to simp on take fails with
failed to generate equational theorem for 'Stream.take'
is this another place where I should just manually prove the lemma here?

Leonardo de Moura (Feb 08 2022 at 23:12):

@James Gallicchio I pushed a fix for the issue above.

James Gallicchio (Feb 09 2022 at 03:08):

Awesome, thanks very much!

James Gallicchio (Feb 13 2022 at 09:25):

LazyList is working great :)

I implemented amortized O(1) queues with and without laziness. The ephemeral test uses the queues linearly, while the persistent test targets the worst case persistent access:

Ephemeral Test
Eager:  124ms
Lazy: 287ms

Persistent Test
Eager:  884ms
Lazy: 1ms

Some overhead with the lazy one, in exchange for worst-case O(1) even with persistent usage!


Last updated: Dec 20 2023 at 11:08 UTC