Zulip Chat Archive
Stream: Is there code for X?
Topic: Divergence monad
Niels Voss (Dec 06 2024 at 06:42):
Since all Lean functions, excepting a few pathological ones, terminate, I was wondering if it was possible to represent partial functions with a type
where ⊥
represents a computation that loops forever. This would be classically equivalent to both Option
and Part
, but it would sit between Option
and Part
computationally. The reason why Part
and particularly PFun.fix
is not sufficient is that Part
encodes for a much wider range of computations, including those that could overflow the end of arrays or perform unsafe casts, which makes it too general.
I am asking this for my own curiosity rather than because I need it for a specific project. However, I could imagine it might have some uses, because it is possible to prove things about possibly-diverging computations, in contrast to partial
. So looping computations could be reasoned about, and only converted to partial
"total" functions when actually needed.
Daniel Weber (Dec 06 2024 at 07:50):
Part
encodes for a much wider range of computations, including those that could overflow the end of arrays or perform unsafe casts, which makes it too general.
could you explain this? I'm not sure I understand
Niels Voss (Dec 06 2024 at 08:06):
Right now, we can build potentially non-terminating functions without "meta-features" like partial
by using PFun.fix
. Then, when we actually want to run the function, we must first prove that it terminates for the specific input. Alternatively, we can use Part.unwrap
, which is an unsafe function. For Part
in general, this function can lead to crashes (or possibly even undefined behavior), because it basically is equivalent to running code with a sorry
. For example, the following crashes the Lean server:
import Mathlib.Control.Fix
def myList : List ℕ := [1,2,3]
def myVal : Part ℕ where
Dom := False
get h := myList[5]
#eval myVal.unwrap
However, I'm pretty sure that if you just run .unwrap
on values of the form PFun.fix f
(where the f
is a total function) then it can't actually crash. However, because Mathlib doesn't actually distinguish between general Part
values and the result of PFun.fix
, we don't have a way to run values of type PFun.fix
in a safe, opaque way.
Niels Voss (Dec 06 2024 at 08:11):
So the idea would be to have two separate concepts, Part
which represents computations that might perform undefined behavior (bad), and Diverge
, which represents computations that might loop forever (less bad):
Part
is what we currently have in mathlib. Unwrapping is anunsafe
operation that can crash Lean or worse.Diverge
would be a new type. Unwrapping is apartial
(and therefore opaque) operation that can enter an infinite loop. Also, properties ofDiverge
stuff could in theory be proven.Option
can be interpreted as computations that might crash, using the.get!
operation. This is less bad thanDiverge
because you would find out in finite time whether or not your program has crashed.
Niels Voss (Dec 06 2024 at 08:47):
I was originally thinking of something like
import Mathlib.Data.PFun
private def Comps.{u,v} (α : Type u) : Set (Part α) :=
{ PFun.fix f g | (γ : Type v) (f : γ →. α ⊕ γ) (g : γ) } -- Maybe f here should be total?
def DivM.{u,v} (α : Type u) : Type u := Comps.{u,v} α
But then I don't think you can actually get the source PFun back, making this useless for computation (Trunc
over Sigma
would need to be used instead of Exists
, I think). Plus, I'm not sure I like the way it handles universes.
Niels Voss (Dec 06 2024 at 08:50):
I did find docs#Fix, which seems to be of interest, but it's a class
and not a usable structure
Edward van de Meent (Dec 06 2024 at 11:28):
so iiuc, you'd like a version of Part
where you also prove that outside of the given domain, f
is not sensibly defined?
Edward van de Meent (Dec 06 2024 at 11:40):
or something like this?
import Mathlib
structure Loop (α β γ : Type*) where
start : α → β
step : β → β
cond : β → Prop -- maybe use Bool?
stop : (b:β) → cond b → γ
def Loop.dom (f:Loop α β γ) (a:α) : Prop := ∃ n, f.cond <| f.step ^[n] (f.start a)
def Loop.eval (f:Loop α β γ) [DecidablePred f.cond] : (a:α) → f.dom a → γ := fun a ha =>
f.stop (f.step^[Nat.find ha] (f.start a)) (Nat.find_spec ha)
nrs (Dec 06 2024 at 17:18):
might be worth looking at https://www.sciencedirect.com/science/article/abs/pii/S2352220824000531 and the corresponding coq code
Trebor Huang (Dec 06 2024 at 18:03):
One possible interpretation is restricting Part
to using propositions of the form (n : Nat) -> p n
where p
is decidable. Intuitively this does correspond to possibly non-terminating computations, where p n
means the calculation terminates after n
steps (obviously should be decidable).
Niels Voss (Dec 06 2024 at 18:06):
Or alternatively, just a Stream of Options, or a Stream'.Seq
. I think this would need to be quotiented to have the right notion of equality.
Niels Voss (Dec 06 2024 at 19:21):
Edward van de Meent said:
so iiuc, you'd like a version of
Part
where you also prove that outside of the given domain,f
is not sensibly defined?
I'm not really sure what you mean. Part
is just a single value, not a function.
Your Loop
example seems like the most general way to do it, which is probably good (though I would replace Prop
with Bool
, or at least a decidable proposition, as you considered). However, it seems that what would really be best is to have DivM
be Loop
quotiented by returning the same value, which probably requires a universe bump.
Edward van de Meent (Dec 06 2024 at 19:31):
Niels Voss said:
However, it seems that what would really be best is to have
DivM
beLoop
quotiented by returning the same value
that seems counterproductive? i thought the whole goal was to have a type for functions which may or may not loop? if you quotient out the intermediate steps, you miss out on the whole looping part...
Niels Voss (Dec 06 2024 at 19:33):
Not necessarily. It would still let you write infinite loops, it's just that equality wouldn't care about the specific way in which an infinite loop was obtained.
Edward van de Meent (Dec 06 2024 at 19:41):
then i don't see what this adds compared to functions to Partial
? since i suspect that these will then be very close to equivalent?
Edward van de Meent (Dec 06 2024 at 19:44):
since a proof saying "this program terminates on this input" should allow you to evaluate the program on the input and return the value, and in the other direction, you can make a program loop on "non-valid" inputs
Niels Voss (Dec 06 2024 at 19:49):
I don't think a quotient wrapper would remove your ability to evaluate the program on inputs you proved that terminate. A function would be represented as A -> DivM B
, and whether or not a function terminates in a given input is a predicate that respects the equivalence relation
Niels Voss (Dec 06 2024 at 19:53):
I do kind of see your point though, in that you could already do that with Part
. I still think it would be useful to have an operation with a partial
unwrap operation rather than an unsafe
unwrap operation
Edward van de Meent (Dec 06 2024 at 19:56):
unfortunately, i don't think there is a good way to do this in lean
Edward van de Meent (Dec 06 2024 at 19:57):
as in, afaik there is no way to "unwrap" a partial operation in a pure way
Asei Inoue (Jan 31 2025 at 03:37):
@Niels Voss
You could want to read this:
relevant monad appears in this paper.
https://arxiv.org/abs/1810.08380
Asei Inoue (Jan 31 2025 at 03:39):
679B84B8-C984-4278-8051-E33F6561667A.jpg
Niels Voss (Jan 31 2025 at 03:48):
Isn't this the Part
monad that already exists in Mathlib?
Matt Diamond (Jan 31 2025 at 05:05):
yes, I believe it is... that's @Mario Carneiro's paper that explains the design of mathlib's Computability library, which uses Part
to model partial recursive functions
Niels Voss (Jan 31 2025 at 07:24):
Funnily enough, I actually saw paper for the first time today a few hours before Asei Inoue suggested it because I was looking through Mathlib's computability API and it was linked.
Anyways, the reason that this doesn't work for my suggestion is that I was curious if there was a monad for functions that were safe to run with partial
without causing undefined behavior, but you could still reason about when they do terminate. The current Part
is too general for that.
Edward van de Meent (Jan 31 2025 at 07:26):
The WIP partial_fixpoint
feature might interest you
Edward van de Meent (Jan 31 2025 at 07:26):
Coming soon to a Lean release near you
Mario Carneiro (Jan 31 2025 at 07:29):
I don't see why Part
doesn't satisfy that requirement @Niels Voss , except that the partial
keyword is unrelated to mathematical modeling of partial functions
Mario Carneiro (Jan 31 2025 at 07:31):
I advocated for partial functions to get compiled to things returning Part
way back when it was introduced, but we got something that is not really sound to reason about instead, and partial_fixpoint
is as I understand it finally implementing something sound, although it still isn't using the Part
monad
Mario Carneiro (Jan 31 2025 at 07:31):
I don't know what the API of partial_fixpoint
actually looks like
Niels Voss (Jan 31 2025 at 07:32):
Running a general Part
function can cause undefined behavior, if I understand correctly (see the unwrap example above). However, a certain subset of Part
values are safe to run and might only cause infinite loops instead of undefined behavior.
Anyways, this was just something I was curious about rather than something I personally need.
Mario Carneiro (Jan 31 2025 at 07:32):
running a Part
function doesn't cause undefined behavior, ignoring proof obligations causes undefined behavior
Mario Carneiro (Jan 31 2025 at 07:32):
running a Part
function requires first proving that it will terminate
Niels Voss (Jan 31 2025 at 07:36):
Yeah, running a Part
by proving termination first is perfectly safe. But Part.unwrap
is unsafe because it ignores proof obligations. Whereas if we had a SafePart
then we could either run it normally by first proving termination, or without proving termination by using a partial
function. The difference is that running Part
without proving termination can only be done in an unsafe
, whereas running SafePart
without proving termination could be done in a partial
.
Mario Carneiro (Jan 31 2025 at 07:37):
neither one is safe, the only reason partial
functions are safe to run is because the compiler specifically turns off some optimizations around them and also proves independently that the target type is inhabited
Mario Carneiro (Jan 31 2025 at 07:37):
there is no way to do that kind of reasoning in the logic
Niels Voss (Jan 31 2025 at 07:38):
So you mean that running a SafePart
type in a partial def
could still cause undefined behavior?
Mario Carneiro (Jan 31 2025 at 07:39):
I don't think SafePart
can be defined at all in a way which is logically distinct from Part
or Id
Mario Carneiro (Jan 31 2025 at 07:40):
if it's an opaque wrapper, perhaps you can express those semantics (that's kind of what partial
is) but you can't reason about it
Niels Voss (Jan 31 2025 at 08:01):
I was thinking something like this:
import Mathlib.Data.PFun
set_option autoImplicit false
structure SafePart.{u,v} (α : Type u) where
σ : Type v -- The type of auxillary data used by the computation
current : σ
next : σ → α ⊕ σ
partial def SafePart.run {α : Type*} [Inhabited α] (v : SafePart α) : α :=
match v.next v.current with
| .inl a => a
| .inr s => SafePart.run ⟨v.σ, s, v.next⟩
def SafePart.toPart {α : Type*} (v : SafePart α) : Part α :=
PFun.fix (pure ∘ v.next) v.current
-- Number of steps for hailstones/Collatz conjecture
def countSteps (n : Nat) : SafePart Nat where
σ := Nat × Nat
current := (0, n)
next := fun (steps, k) =>
if k ≤ 1 then .inl steps
else if k % 2 = 0 then .inr (steps + 1, k / 2)
else .inr (steps + 1, 3 * k + 1)
#eval SafePart.run (countSteps 211) -- 39
Aaron Liu (Jan 31 2025 at 12:04):
This looks like docs#Computation.corec
Matt Diamond (Feb 01 2025 at 04:30):
that's a good point... and Computation.toPart
is very simple
import Mathlib
set_option autoImplicit false
def Computation.toPart {α : Type*} (c : Computation α) : Part α :=
{
Dom := c.Terminates
get _ := c.get
}
partial def Computation.run' {α : Type*} [Nonempty α] (c : Computation α) : α :=
match destruct c with
| Sum.inl a => a
| Sum.inr ca => run' ca
def collatz : Nat × Nat → Nat ⊕ (Nat × Nat) :=
fun (steps, k) =>
if k ≤ 1 then .inl steps
else if k % 2 = 0 then .inr (steps + 1, k / 2)
else .inr (steps + 1, 3 * k + 1)
-- Number of steps for Collatz conjecture
def countSteps (n : Nat) : Computation Nat :=
Computation.corec collatz (1, n)
#eval (countSteps 27).run'
Matt Diamond (Feb 01 2025 at 04:33):
though docs#Computation.run already exists in an unsafe
version
Matt Diamond (Feb 01 2025 at 04:38):
I think Computation
is basically the divergence monad that @Niels Voss was curious about... it allows you to prove things about intermediate steps in a potentially infinite loop
Mario Carneiro (Feb 01 2025 at 04:40):
I think you probably want to quotient the type by the relation of skipping a 'think' step, since otherwise Computation A
has classically A + Nat
values instead of A + 1
Niels Voss (Feb 01 2025 at 04:41):
Yes, this seems pretty much to fit what I was looking for. I was thinking of quotienting by something, as Mario Carneiro suggested, but then I'm not entirely sure if we can still use a partial
function to run it
Mario Carneiro (Feb 01 2025 at 04:42):
get
can still be defined on the quotient type, in fact it's basically the only thing you can define
Matt Diamond (Feb 01 2025 at 04:44):
oh right, because the result is the same across equivalent computations
Mario Carneiro (Feb 01 2025 at 04:45):
but you may indeed have troubles lifting it to satisfy the requirements of partial
without just cheating the proof
Niels Voss (Feb 01 2025 at 04:47):
Here's one way it could be defined:
import Mathlib.Data.Seq.Computation
def computationSetoid (α : Type*) : Setoid (Computation α) where
r := Computation.Equiv
iseqv := Computation.Equiv.equivalence
def Divergence {α : Type*} := Quotient (computationSetoid α)
Mario Carneiro (Feb 01 2025 at 04:48):
ah, thanks past me for having already thought of this :)
Matt Diamond (Feb 01 2025 at 04:57):
yeah I guess you would have trouble proving that Computation.run'
(as a partial function) respects the Quotient relation since you can't really prove anything about it
Mario Carneiro (Feb 01 2025 at 04:58):
you can make it an element of a singleton type
Matt Diamond (Feb 01 2025 at 04:59):
sorry, what does "it" refer to
Niels Voss (Feb 01 2025 at 04:59):
The return value of Computation.run'
, I think
Mario Carneiro (Feb 01 2025 at 04:59):
i.e. Computation.run' (c : Computation A) : { a // (\exists h, a = c.get h) \/ !c.Terminates /\ a = default }
Matt Diamond (Feb 01 2025 at 05:00):
ah, I see
Niels Voss (Feb 01 2025 at 05:02):
Sorry, what does a.get
refer to? Doesn't a
in the {}
have type A
?
Mario Carneiro (Feb 01 2025 at 05:02):
typo
Niels Voss (Feb 01 2025 at 05:02):
oh i see now. I think a ∈ c
is probably the cleaner way of saying it
Niels Voss (Feb 01 2025 at 07:31):
I got it working, although it took me much longer than I expected. Sorry for the messy code right now; this is mainly just a proof-of-concept:
import Mathlib.Data.PFun
import Mathlib.Data.Seq.Computation
set_option autoImplicit false
partial def Computation.run' {α : Type*} [Nonempty α] (c : Computation α) : α :=
match destruct c with
| Sum.inl a => a
| Sum.inr ca => run' ca
def Constrain {α : Type*} (c : Computation α) [Inhabited α] :=
{a : α // a ∈ c ∨ (¬c.Terminates ∧ a = default)}
def Constrain.mk {α : Type*} [Inhabited α] (c : Computation α) : Computation (Constrain c) where
val (n : ℕ) :=
(c.val.get n).attach.map fun ⟨a, ha⟩ =>
⟨a, Or.inl ⟨n, _⟩⟩ -- Sometimes need to replace _ with "by simp_all" when editing the below proof
property n a h := by
simp_all only [Option.map_eq_some', Option.attach_eq_some_iff, Subtype.exists, Option.mem_def,
exists_and_left]
rcases h with ⟨a, h₁, h₂, h₃⟩
have := c.property h₁
use a
apply And.intro this
use this
simp_all
def Constrain.val_eq {α : Type*} [Inhabited α] {a b : Computation α} (h : a.Equiv b)
(c : Constrain a) (d : Constrain b) : c.val = d.val := by
by_cases ha : a.Terminates
· have hb : b.Terminates := (Computation.terminates_congr h).mp ha
apply Computation.mem_unique
· simpa [ha] using c.property
· have : d.val ∈ b := by simpa [hb] using d.property
exact (h d.val).mpr this
· have hb : ¬b.Terminates := fun h' => absurd ((Computation.terminates_congr h).mpr h') ha
have h₁ : c.val ∉ a := fun t => absurd (Computation.terminates_of_mem t) ha
have h₂ : d.val ∉ b := fun t => absurd (Computation.terminates_of_mem t) hb
have := c.property
have := d.property
simp_all
instance {α : Type*} [Inhabited α] (c : Computation α) : Subsingleton (Constrain c) := by
apply Subsingleton.intro
intro a b
unfold Constrain
ext
by_cases h : c.Terminates
· apply Computation.mem_unique
· simpa [h] using a.property
· simpa [h] using b.property
· have h₁ : a.val ∉ c := fun t => absurd (Computation.terminates_of_mem t) h
have h₂ : b.val ∉ c := fun t => absurd (Computation.terminates_of_mem t) h
have := a.property
have := b.property
simp_all
-- For some reason defining this above the Subsingleton instance causes the simp_all to no longer
-- finish, so we define this down here.
def Constrain.val {α : Type*} [Inhabited α] {c : Computation α} : Constrain c → α := Subtype.val
open Classical in
instance {α : Type*} (c : Computation α) [Inhabited α] : Nonempty (Constrain c) :=
if h : c.Terminates then
h.term.elim (fun a ha ↦ Nonempty.intro (⟨a, Or.inl ha⟩ : Constrain c))
else
Nonempty.intro (⟨default, Or.inr ⟨h, rfl⟩⟩ : Constrain c)
noncomputable instance {α : Type*} (c : Computation α) [Inhabited α] : Inhabited (Constrain c) :=
Classical.inhabited_of_nonempty (by infer_instance)
def computationSetoid (α : Type*) : Setoid (Computation α) where
r := Computation.Equiv
iseqv := Computation.Equiv.equivalence
def Divergence (α : Type*) := Quotient (computationSetoid α)
lemma Constrain.eq {α : Type*} {a b : Computation α} [Inhabited α] (h : a.Equiv b)
: Constrain a = Constrain b := by
unfold Constrain
congr
funext x
congr 1
· exact propext (h x)
· congr 2
exact propext (Computation.terminates_congr h)
def Divergence.run {α : Type*} [Inhabited α] : Divergence α → α :=
Quotient.lift (fun c : Computation α => Constrain.val (Computation.run' (Constrain.mk c))) <| by
intro a b h
apply Constrain.val_eq
exact h
def collatz : Nat × Nat → Nat ⊕ (Nat × Nat) :=
fun (steps, k) =>
if k ≤ 1 then .inl steps
else if k % 2 = 0 then .inr (steps + 1, k / 2)
else .inr (steps + 1, 3 * k + 1)
-- Number of steps for Collatz conjecture
def countSteps (n : Nat) : Computation Nat :=
Computation.corec collatz (1, n)
def countStepsDiv (n : Nat) : Divergence Nat := ⟦countSteps n⟧
#eval Divergence.run (countStepsDiv 27) -- 112
Mario Carneiro (Feb 02 2025 at 16:13):
Here's some proof golfing applied to that:
import Mathlib.Data.PFun
import Mathlib.Data.Seq.Computation
def Constrain {α : Type*} (c : Computation α) [Nonempty α] :=
{a : α // a ∈ c ∨ (¬c.Terminates ∧ a = Classical.choice ‹_›)}
instance {α : Type*} (c : Computation α) [Nonempty α] : Nonempty (Constrain c) :=
match em c.Terminates with
| .inl _ => ⟨_, .inl c.get_mem⟩
| .inr h => ⟨_, .inr ⟨h, rfl⟩⟩
partial def Computation.run' {α : Type*} [Nonempty α] (c : Computation α) : Constrain c :=
match h : destruct c with
| .inl a => ⟨a, .inl (destruct_eq_pure h ▸ ret_mem _)⟩
| .inr ca => by
refine let ⟨a, h'⟩ := run' ca; ⟨a, ?_⟩
rw [destruct_eq_think h]
match h' with
| .inl h' => exact .inl (think_mem h')
| .inr ⟨h1, h2⟩ => exact .inr ⟨mt of_think_terminates h1, h2⟩
theorem Constrain.val_eq {α : Type*} [Nonempty α] {a b : Computation α} (h : a.Equiv b) :
∀ (c : Constrain a) (d : Constrain b), c.val = d.val
| ⟨_, .inl hc⟩, ⟨_, .inl hd⟩ => Computation.mem_unique ((h _).1 hc) hd
| ⟨_, .inr ⟨hc, _⟩⟩, ⟨_, .inl hd⟩ => nomatch hc ⟨_, (h _).2 hd⟩
| ⟨_, .inl hc⟩, ⟨_, .inr ⟨hd, _⟩⟩ => nomatch hd ⟨_, (h _).1 hc⟩
| ⟨_, .inr ⟨_, hc⟩⟩, ⟨_, .inr ⟨_, hd⟩⟩ => hc.trans hd.symm
def computationSetoid (α : Type*) : Setoid (Computation α) where
r := Computation.Equiv
iseqv := Computation.Equiv.equivalence
def Divergence (α : Type*) := Quotient (computationSetoid α)
def Divergence.run {α : Type*} [Nonempty α] : Divergence α → α :=
Quotient.lift (fun c : Computation α => (Computation.run' c).1)
fun _ _ h => Constrain.val_eq h _ _
def collatz : Nat × Nat → Nat ⊕ (Nat × Nat) :=
fun (steps, k) =>
if k ≤ 1 then .inl steps
else if k % 2 = 0 then .inr (steps + 1, k / 2)
else .inr (steps + 1, 3 * k + 1)
-- Number of steps for Collatz conjecture
def countSteps (n : Nat) : Computation Nat :=
Computation.corec collatz (1, n)
def countStepsDiv (n : Nat) : Divergence Nat := ⟦countSteps n⟧
#eval Divergence.run (countStepsDiv 27) -- 112
Niels Voss (Feb 03 2025 at 04:53):
I am going to try placing a Monad
instance of Divergence
once I can figure out how to get the bind operator working properly
Niels Voss (Feb 03 2025 at 05:01):
Do you know if it would be possible to implement similar to Haskell's fix
operator? If so, would such an operator be useful given that Lean isn't as "lazy" as Haskell?
Niels Voss (Feb 03 2025 at 07:32):
One disadvantage of the current approach is that because the single value of Constrain empty
(where empty
is the non-halting computation) is Classical.choice _
, we have no way to tell what it is. This means that if we have two computations c1, c2
where c1
converges and c2
diverges, it is impossible to determine whether or not c1.run = c2.run
. There are also some other problems, such as not being able to distinguish between a converging and diverging computation based on .run
alone.
Because of this, I think it's better to define Constrain
to be an Option
instead. This also makes things cleaner, and the old value can always be recovered with Divergence.run.getD default
, possibly replacing default
with an infinite loop if all you have is a Nonempty
instance and don't want to mark the definition as noncomputable
.
import Mathlib.Data.PFun
import Mathlib.Data.Seq.Computation
import Mathlib.Control.Fix
import Mathlib.Tactic
set_option autoImplicit false
def Option.toComputation {α : Type*} : Option α → Computation α
| .some a => .pure a
| .none => .empty α
theorem Option.eq_of_equiv_toComputation {α : Type*} (a b : Option α)
(h : a.toComputation.Equiv b.toComputation) : a = b :=
match a, b with
| .some x, .some y => by
congr
exact (Computation.eq_of_pure_mem <| (h x).mp (Computation.ret_mem x))
| .some x, .none => absurd (h x |>.mp (Computation.ret_mem x)) (Computation.not_mem_empty x)
| .none, .some y => absurd (h y |>.mpr (Computation.ret_mem y)) (Computation.not_mem_empty y)
| .none, .none => rfl
structure Computation.Result {α : Type*} (c : Computation α) where
val : Option α
constraint : c.Equiv val.toComputation
instance {α : Type*} (c : Computation α) : Nonempty c.Result :=
match em c.Terminates with
| .inl _ => ⟨c.get, Computation.equiv_pure_of_mem (Computation.get_mem c)⟩
| .inr h => ⟨none, fun s => iff_of_false (fun h' => h ⟨s, h'⟩) (Computation.not_mem_empty s)⟩
partial def Computation.run' {α : Type*} (c : Computation α) : c.Result :=
match h : destruct c with
| .inl a => ⟨some a, by rw [destruct_eq_pure h]; rfl⟩
| .inr ca => by
refine let ⟨a, h'⟩ := run' ca; ⟨a, ?_⟩
rw [destruct_eq_think h]
intro s
constructor <;> intro h
· simp [←h' s, Computation.of_think_mem h]
· simp [h' s, h, Computation.think_mem]
theorem Computation.Result.val_eq {α : Type*} {a b : Computation α} (h : a.Equiv b)
(r : a.Result) (s : b.Result) : r.val = s.val := by
apply Option.eq_of_equiv_toComputation
exact Computation.Equiv.trans (Computation.Equiv.trans r.constraint.symm h) s.constraint
def Computation.toOption {α : Type*} (c : Computation α) : Option α := c.run'.val
def computationSetoid (α : Type*) : Setoid (Computation α) where
r := Computation.Equiv
iseqv := Computation.Equiv.equivalence
def Divergence (α : Type*) := Quotient (computationSetoid α)
def Divergence.run {α : Type*} : Divergence α → Option α :=
Quotient.lift (fun c => (Computation.run' c).val) fun _ _ h => Computation.Result.val_eq h _ _
def collatz : Nat × Nat → Nat ⊕ (Nat × Nat) :=
fun (steps, k) =>
if k ≤ 1 then .inl steps
else if k % 2 = 0 then .inr (steps + 1, k / 2)
else .inr (steps + 1, 3 * k + 1)
-- Number of steps for Collatz conjecture
def countSteps (n : Nat) : Computation Nat :=
Computation.corec collatz (1, n)
def countStepsDiv (n : Nat) : Divergence Nat := ⟦countSteps n⟧
#eval Divergence.run (countStepsDiv 27) -- some 112
Niels Voss (Feb 03 2025 at 07:34):
In this code, I basically took your golfed versions of the proofs and rewrote them to use Option
. This code also avoids the need for a [Nonempty α]
instance
Mario Carneiro (Feb 03 2025 at 12:55):
Niels Voss said:
Do you know if it would be possible to implement similar to Haskell's
fix
operator? If so, would such an operator be useful given that Lean isn't as "lazy" as Haskell?
Yes, Computation
definitely supports fix
, as does Part
Mario Carneiro (Feb 03 2025 at 12:56):
All of these implementations are passable for demo purposes but not great from a performance standpoint. There are lean compiler changes needed to make it possible to implement better data representations for coinductive types
Miyahara Kō (Feb 04 2025 at 04:30):
You can see the example of coinductive override here.
Miyahara Kō (Feb 04 2025 at 04:33):
This example uses the not merged feature:opaque_repr
.
Niels Voss (Feb 04 2025 at 07:30):
So my understanding of the issue is that we want a different runtime representation of Computation
than how it is represented in math, but there's a bug in the compiler right now that prevents us from doing that. So, we need an opaque_ref
attribute, which is introduced in the PR, which fixes the bug?
How much worse is the performance of Computation
and Divergence
than partial
(is it a constant multiple different, or of worse time complexity?) and where does the slowdown come from? I could imagine how Stream'.tail
ends up building a bunch of 1 +
s that have to be tracked, but is there a more fundamental reason why Computations are slow?
Mario Carneiro (Feb 11 2025 at 20:50):
A Computation
is a function where you give it the number of steps to compute and it computes the output to that approximation. If it turns out you didn't give enough fuel, you call it again with more steps and it runs the computation from the beginning again with more fuel. This is very different from the destructor function approach, where the result of a partial computation is a value in an existentially quantified type, and you can just call the destructor function again to continue the computation if the partial computation was not enough
Mario Carneiro (Feb 11 2025 at 20:51):
If you use every step of computation, this is an O(n^2) computation instead of the ideal O(n). You can make it into a constant overhead by exponentially growing the step counts
Mario Carneiro (Feb 11 2025 at 20:54):
This is relevant for the recent Computable reals discussion, since computable reals are also morally a coinductive type and you don't want to have to redo the computation from the beginning again every time (although some real numbers need to be computed from scratch anyway since you have to adjust the bounds on various quantities)
Niels Voss (Feb 11 2025 at 21:07):
I think I ran into a similar problem with computational complexity when I tried to see if I could make a Semidecidable
typeclass for deciding things like <
on real numbers. But, what prevents us from just "storing" the result of running the computation for n
steps so that when you want to later run it for n+k
steps you can skip the first n
?
Mario Carneiro (Feb 11 2025 at 21:09):
I think we need mutable quotients for the storage part to work, unless you use a monadic representation
Niels Voss (Feb 11 2025 at 21:10):
Oh, so the problem is not with Computation
, but with Divergence
Mario Carneiro (Feb 11 2025 at 21:10):
no, this is something else
Mario Carneiro (Feb 11 2025 at 21:11):
the idea of mutable quotients is that you can modify values in place (not just returning the new value, actually changing the input and returning nothing) as long as there is a quotient that hides the fact that you did anything
Mario Carneiro (Feb 11 2025 at 21:12):
Lean supports only one specific mutable quotient, namely Thunk
Mario Carneiro (Feb 11 2025 at 21:12):
which is effectively Thunk A = MutQuot (A + (Unit -> A))
where the quotient relation is inl a ~ inr (fun _ => a)
Niels Voss (Feb 11 2025 at 21:13):
I was about to ask if it was related to Thunk
. So right now, Computation
doesn't use quotients, but if we wanted to speed it up to better time complexity, we would need a MutQuot
?
Mario Carneiro (Feb 11 2025 at 21:13):
right
Mario Carneiro (Feb 11 2025 at 21:13):
you might be able to do it with nested Thunk
s if you are sufficiently clever
Niels Voss (Feb 11 2025 at 21:14):
I feel like this is something which could be implemented within Lean code, probably by using something like IO.Ref
. But it would increase the trusted codebase for the compiler
Mario Carneiro (Feb 11 2025 at 21:14):
I think there is a haskell library that does something like that (although since it's haskell the thunks come for free)
Mario Carneiro (Feb 11 2025 at 21:15):
IO.Ref (or ST.Ref) would still need monads though
Mario Carneiro (Feb 11 2025 at 21:15):
and you would lose the ability to reason about the object
Niels Voss (Feb 11 2025 at 21:15):
I mean in theory I think there's' a way to run IO
code in an unsafe block that isn't monadic. I don't think this is a good idea though
Mario Carneiro (Feb 11 2025 at 21:15):
That's ST
, it's already in lean
Mario Carneiro (Feb 11 2025 at 21:16):
but it's still monadic at the point where you do the writes
Mario Carneiro (Feb 11 2025 at 21:16):
so it's not really possible to hide the refs away in an encapsulation in a library
Niels Voss (Feb 11 2025 at 21:17):
I guess what I don't understand is that if Computation
is a Nat -> (Option _)
behind the scenes, why can't we just run for n
steps, check if it has terminated, and then start our next run at n+1
Mario Carneiro (Feb 11 2025 at 21:18):
you can, the trouble is that the f : Nat -> Option A
in question also has its own state, and when you call f n
and later f (n+1)
, those two calls are independent and f
will have to run through n+1 steps of computation in the second call even if the first n steps are the same as the prior call to f n
which has been forgotten
Mario Carneiro (Feb 11 2025 at 21:19):
for example, imagine f
is running a turing machine for n steps, those two calls will independently build the machine and step through n or n+1 steps
Niels Voss (Feb 11 2025 at 21:20):
Ohhh that makes more sense. So it isn't so much that every Computation
suffers from this poor time complexity, but that most practically usable Computations
will be recursive and thus require recomputing, unless the intermediate results are memoized somehow
Mario Carneiro (Feb 11 2025 at 21:20):
right
Mario Carneiro (Feb 11 2025 at 21:21):
and since the intended interface of Computation
is the coinductive one, most Computation
s will be constructed using the corecursor, which is recursive
Niels Voss (Feb 11 2025 at 21:21):
How does this compare with the SafePart defined above (which I've repasted below):
import Mathlib.Data.PFun
set_option autoImplicit false
structure SafePart.{u,v} (α : Type u) where
σ : Type v -- The type of auxillary data used by the computation
current : σ
next : σ → α ⊕ σ
partial def SafePart.run {α : Type*} [Inhabited α] (v : SafePart α) : α :=
match v.next v.current with
| .inl a => a
| .inr s => SafePart.run ⟨v.σ, s, v.next⟩
def SafePart.toPart {α : Type*} (v : SafePart α) : Part α :=
PFun.fix (pure ∘ v.next) v.current
-- Number of steps for hailstones/Collatz conjecture
def countSteps (n : Nat) : SafePart Nat where
σ := Nat × Nat
current := (0, n)
next := fun (steps, k) =>
if k ≤ 1 then .inl steps
else if k % 2 = 0 then .inr (steps + 1, k / 2)
else .inr (steps + 1, 3 * k + 1)
#eval SafePart.run (countSteps 211) -- 39
Does the extra state stored in SafePart help in any way?
Mario Carneiro (Feb 11 2025 at 21:22):
yes it does
Niels Voss (Feb 11 2025 at 21:22):
Of course, it requires a universe bump, which could be a problem
Mario Carneiro (Feb 11 2025 at 21:22):
SafePart is the representation that Computation would prefer to have
Mario Carneiro (Feb 11 2025 at 21:22):
but there is a universe issue
Mario Carneiro (Feb 11 2025 at 21:23):
not just that there is a bump, there is a second level argument
Mario Carneiro (Feb 11 2025 at 21:23):
you can't collect up a list of computations from different sources if they use types at different levels
Niels Voss (Feb 11 2025 at 21:23):
right, because if that argument goes up, more functions become computable. So for any given v
, it will never encode all the computations
Mario Carneiro (Feb 11 2025 at 21:24):
The ideal coinductive interface lets you choose the level independently from the coinductive type itself, similar to how the recursor of an inductive type has an additional level argument
Mario Carneiro (Feb 11 2025 at 21:24):
we can accomplish that logically using this function of options representation, but we lose the good computational structure
Niels Voss (Feb 11 2025 at 21:24):
Maybe there's a way to memoize the intermediate results of Computation
? Like if we instead had Nat -> Thunk (Option _)
(not sure if that's sufficient)
Mario Carneiro (Feb 11 2025 at 21:27):
the fact that it's behind a function would make the thunk useless
Mario Carneiro (Feb 11 2025 at 21:28):
it would still get created anew even if you called it twice on the same n
Mario Carneiro (Feb 11 2025 at 21:28):
There is a HashMemo thingy in lean core IIRC but it's not suitable for reasoning
Niels Voss (Feb 11 2025 at 21:29):
Out of curiosity, how does https://github.com/alexkeizer/QpfTypes handle this problem?
Mario Carneiro (Feb 11 2025 at 21:29):
In the original QPF formalization, we did something similar to the Computation
representation (but generalized to arbitrary coinductives)
Mario Carneiro (Feb 11 2025 at 21:30):
it basically suffers from the same tradeoffs
Mario Carneiro (Feb 11 2025 at 21:30):
I'm not sure about Alex's work, I'll take a look
Mario Carneiro (Feb 11 2025 at 21:33):
It seems to make use of mathlib's QPF library, which is a direct port of the lean 3 library in our paper
Niels Voss (Feb 11 2025 at 21:37):
So, to be clear, if MutQuot
could be implemented (possibly requiring C++ code), then it would allow for optimal coinductive representations, and both fix the Computation and provide a tool that the ComputableReal project could use?
Mario Carneiro (Feb 11 2025 at 21:38):
the actual type definition is src#MvPFunctor.M, which hides some details in a coercion to function instance, but if you combine src#MvPFunctor.mp and src#MvPFunctor.Obj you see that it is a function from paths (the equivalent of Nat
in Computation
) to partial outputs
Mario Carneiro (Feb 11 2025 at 21:38):
yes
Mario Carneiro (Feb 11 2025 at 21:39):
and now you see why I've been frustrated that an easy PR which fixes the issue has been blocked for almost 2 years for no reason
Niels Voss (Feb 11 2025 at 21:40):
Is that https://github.com/leanprover/lean4/pull/2292 that you linked above?
Mario Carneiro (Feb 11 2025 at 21:40):
yes
Matt Diamond (Feb 11 2025 at 21:44):
were you given any rationale for why it was blocked? that sounds really annoying
Shreyas Srinivas (Feb 11 2025 at 21:44):
Can this divergence monad be used in the formalisation of the greedy method in the equational theories project?
Mario Carneiro (Feb 11 2025 at 21:44):
you can see everything I know on the issue itself
Mario Carneiro (Feb 11 2025 at 21:44):
I'm pretty sure it was killed by the P-low
Shreyas Srinivas (Feb 11 2025 at 21:45):
Shreyas Srinivas said:
Can this divergence monad be used in the formalisation of the greedy method in the equational theories project?
There we also have a semi decidable procedure where we backfill values in a function
Mario Carneiro (Feb 11 2025 at 21:45):
because the FRO priority mechanism not a fair scheduler, it will just starve anything which is not high prio
Niels Voss (Feb 11 2025 at 21:47):
This is going to sound like a stupid idea, but we just pass like a really, really large value into the fuel function?
Mario Carneiro (Feb 11 2025 at 21:48):
for real number computations, that's a bad idea, since various bits of the internal computation will make use of the fuel to decide how big to make the intermediates
Mario Carneiro (Feb 11 2025 at 21:49):
but for a plain corecursor application, that's usually a fine way to compute partial functions
Niels Voss (Feb 12 2025 at 01:10):
If the real number computations make use of the fuel to decide how large to make the intermediates, does that mean that even if we get a way to memoize intermediate results, it will have to recompute them anyways?
Johan Commelin (Feb 12 2025 at 05:58):
Yes:
Mario Carneiro said:
This is relevant for the recent Computable reals discussion, since computable reals are also morally a coinductive type and you don't want to have to redo the computation from the beginning again every time (although some real numbers need to be computed from scratch anyway since you have to adjust the bounds on various quantities)
Niels Voss (Feb 12 2025 at 06:30):
Would getting an efficient representation using the MutQuot
and other approaches suggested here even help for the ComputableReals project, given that it has to recompute with different bounds anyways?
Mario Carneiro (Feb 12 2025 at 11:14):
This is not always the case though. Some exact real computations are just stream transformers and can make use of prior information, while others actually need to do some math on the target precision to decide what subcomputations to run, and these may or may not gain an advantage from caching. The papers Bas mentioned seem to indicate that in general it's important for caching to be a component of an efficient exact real computation system, although I'm sure the details matter
Niels Voss (Feb 14 2025 at 19:59):
To confirm, all this discussion is mainly relevant for compiled code, not kernel reduction, right? Afaik Thunk
doesn't help in the kernel, so neither would MutQuot
?
Mario Carneiro (Feb 14 2025 at 21:08):
that's right
Mario Carneiro (Feb 14 2025 at 21:08):
the kernel has completely different performance characteristics
Last updated: May 02 2025 at 03:31 UTC