Skip to content

Instantly share code, notes, and snippets.

@lcnr
Last active April 16, 2025 08:23
Show Gist options
  • Save lcnr/c49d887bbd34f5d05c36d1cf7a1bf5a5 to your computer and use it in GitHub Desktop.
Save lcnr/c49d887bbd34f5d05c36d1cf7a1bf5a5 to your computer and use it in GitHub Desktop.

dictionary passing style

The core idea is that all traits are mapped to dependent types - their dictionary - with fields for their super predicates and their associated items. The dictionary for item bounds are stored together with the associated type itself. Let's look at a few such mappings by converting them into lean4 code.

trait Foo {}
/- The dictionary for `Foo` does not have any fields -/
inductive Foo (self : Type) : Type where
| mk : Foo self

trait Bar<T>: Foo {}
/- The dictionary has the dictionary for the super trait `Foo` as a field -/
inductive Bar (self : Type) (t : Type) : Type where
| mk : Foo self -> Bar self t 

trait Baz<T> {
    type Assoc<U: Bar<T>>: Foo;
}
/- We've got a separate dictionary for associated types, containing both
the underlying type and dictionaries for the item bounds of that type -/
inductive BazAssoc (self : Type) (t : Type) (u : Type) : Type where
| mk : (assoc_type: Type) -> Foo assoc_type -> BazAssoc self t u
/- The dictionary for `Baz` contains a function taking the type parameter
`U`, a proof that `U: Bar<T>` holds and then returns the dictionary for the
associated type -/
inductive Baz (self : Type) (t : Type) : Type where
| mk : (assoc: (u : Type) -> Bar u t -> BazAssoc self t u) -> Baz self t

Implementations of these traits then get mapped to functions taking their where-clauses as arguments and returning a dictionary:

impl Foo for u32 {}
def impl_Foo_for_u32 : Foo Nat := Foo.mk

impl<T: Foo> Bar<T> for T {}
def impl_Bar_T_for_T (t : Type) (dict : Foo t) : Bar t t := Bar.mk dict

impl Baz<u32> for u32 {
    type Assoc<U: Bar<u32>> = U;
}
def impl_Baz_u32_for_u32 : Baz Nat Nat :=
  let assoc := fun (u : Type) (dictB : Bar u Nat) => 
    let (Bar.mk dictF) := dictB /- access the super trait dictionary -/
    BazAssoc.mk u dictF
  Baz.mk assoc

We then lower the proof tree created by the trait solver to a function. Let's go through all the ways a trait goal can be proven by the trait solver:

  • we've already seen the way impls get lowered; applying an impl simply corresponds to calling the corresponding function
  • applying a where-bound simply uses the function argument representing this bound
  • applying an elaborated where-bound corresponds to a field access of a function argument
  • applying an item bound corresponds to a field access of the associated type dictionary
  • a coinductive cycle gets lowered to a recursive call

Corecursive functions

We do not need the resulting function to terminate. It only has to be a well-formed corecursive function. A corecursive function may not necessarily terminate itself, but matching on the returned value has to be guaranteed to terminate in finite amount of time. Lets look at a few examples:

// pseudo-Rust
enum Stream<T> {
   Nil,
   Cons(T, Stream<T>),
}
// Well-formed: guaranteed to fully terminate
fn repeat_n<T>(elem: T, count: usize) -> Stream<T> {
   match count {
       0 => Stream::Nil,
       n + 1 => Stream::Cons(elem, repeat_n(elem, n)), 
   }
}
// Well-formed: while it doesn't terminate, matching on its return value
// will, leaving an infinitely expanding lazily evaluated tail.
fn repeat<T>(elem: T) -> Stream<T> {
    Stream::Cons(elem, repeat(elem))
}
// Not well-formed: unlike `fn repeat` we never make progress while computing
// the return value of this function.
fn loop<T>() -> Stream<T> {
    loop()
}

The functions corresponding to proof trees are not guaranteed to be well-formed by construction. This is due to multiple reasons. Lets quickly go through issues which don't affect the changes in this PR them before looking at our requirements for coinductive cycles.

Type universes

We need to both introduce and check Type universes to avoid Girard's paradox. Even the above example doesn't actually compile in lean, we need to explicitly provide universe information at places, which may restrict the way the trait dictionaries can be used:

inductive Foo (self : Type 0) : Type 1 where
| mk : Foo self

def impl_Foo_for_u32 : Foo Nat := Foo.mk

inductive Bar (self : Type 0) (t : Type 0) : Type 1 where
| mk : Foo self -> Bar self t
def impl_Bar_u32_for_u32 : Bar Nat Nat := Bar.mk impl_Foo_for_u32

inductive BazAssoc (self : Type 0) (t : Type 0) (u : Type 0) : Type 1 where
| mk : (assoc_type: Type 0) -> Foo assoc_type -> BazAssoc self t u
inductive Baz (self : Type 0) (t : Type 0) : Type 1 where
| mk : (assoc: (u : Type) -> Bar u t -> BazAssoc self t u) -> Baz self t
def impl_Baz_u32_for_u32 : Baz Nat Nat :=
  let assoc := fun (u : Type) (dictB : Bar u Nat) => 
    let (Bar.mk dictF) := dictB
    BazAssoc.mk u dictF
  Baz.mk assoc

I am not convinced this is an actual be an issue as we're never generic over dictionary kinds; we do not have "associated traits".

Diverging associated types

We allow potentially diverging associated types. Using these definitely does not terminate, see rust-lang/rust#135011.

Recursive occurances in negative position

These are forbidden in lean and coq. They can be used to get non-terminating functions. I am unsure whether this is an issue affected us, given the fairly limited complexity of the functions representing proof trees.

trait Magic {
    type Assoc<T: Magic>: Copy;
}
inductive Copy (self : Type 0) : Type 1 where
| mk : Copy self
inductive MagicAssoc (self : Type 0) (t : Type 0) : Type 1 where
| mk : (assoc_type : Type 0) -> Copy assoc_type -> MagicAssoc self t
inductive Magic : (self : Type 0) -> Type 1 where
| mk : (assoc : (t : Type) -> Magic t -> MagicAssoc self t) -> Magic self

(kernel) arg #2 of 'Magic.mk' has a non positive occurrence of the datatypes being declared

Guarded recursion and requirements for coinductive cycles

For a corecursive function to be accepted, the recursive calls must be guarded. It must be inside of at least one constructor, as seen in the fn repeat example above. This guarantees that evaluating the function is always able to make at least one step progress per recursive call.

However, this is not yet sufficient as we may project out of this constructor again. This is what would be happening in the following example:

trait Magic: Copy {}
impl<T: Magic> Magic for T {}
fn magic_copy<T: Magic>(x: T) -> (T, T) { (x, x) }

We would map this to the following:

inductive Copy (self : Type) : Type where
| mk : Copy self

inductive Magic (self : Type) : Type where
| mk : Copy self -> Magic self

def impl_Magic_for_T (t : Type) (dict: Magic t) : Magic t :=
  let (Magic.mk dictC) := dict
  Magic.mk dictC

/- To call `fn magic_copy`, we need to prove `T: Magic` -/
def call_magic_copy (t: Type) : Magic t :=
  impl_Magic_for_T t (call_magic_copy t)

We could expand the body of call_magic_copy to only call itself inside of a constructor. However, the field access discards that constructor regardless.

def impl_Magic_for_T_inline (t : Type) (dict: Magic t) : Magic t :=
  Magic.mk (
    let (Magic.mk dictC) := dict
    dictC
  )

def call_magic_copy (t: Type) : Magic t :=
  Magic.mk (
    let (Magic.mk dictC) := (call_magic_copy t)
    dictC
  )

Citing from "Certified Programming with Dependent Types" by Adam Chlipala we can see how this is handled in Coq:

Coq’s complete rule for co-recursive definitions includes not just the basic guardedness condition, but also a requirement about where co-recursive calls may occur. In particular, a co-recursive call must be a direct argument to a constructor, nested only inside of other constructor calls or fun or match expressions.

Enforcing this (or a similar condition) for our proof trees is somewhat difficult to reason about. However, let's look at the ways proof trees get lowered to functions. The only ways to project out of a constructor are the following:

  • accessing an associated item, either its type or its item bounds
  • accessing super predicates

Both of these things are not possible for auto traits. We can therefore freely allow cycles guarded by an auto trait constructor, as this constructor can never be projected out of!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment