blog/posts-wip/2023-06-05-quox.md

12 KiB
Raw Blame History

title date tags show-toc bibliography link-citations
intro to quox 2023-06-05
computer
quox (language)
true quox.bib true

you might not want to have every property you will ever care about be always reflected in types. quox's expressions have their scope size in their type, because dealing with variables is ubiquitous and fiddly, but they don't have like, a flag for whether they're reducible. i do care about that sometimes, but it's easier to have it as a separate value:

-- in Data.So in the standard library
data Oh : Bool -> Type where
  Oh : So True

-- in Data.DPair (simplified for now)
data Subset : (a : Type) -> (p : a -> Type) -> Type where
  Element : (x : a) -> p x -> Subset a p

isRedex : Definitions -> Term d n -> Bool

whnf : (defs : Definitions) -> WhnfContext d n ->
       Term d n -> Subset (Term d n) (\t => So (not (isRedex defs t)))

a term is a redex (reducible expression) if the top level AST node is something that can be immediately reduced, like a function being applied to an argument, or a definition that can be unfolded. whnf (weak head normal form) reduces the top of the expression until there are no more reductions to do, and then returns the result, along with a proof that there are no more.

datatype arguments can be of any type, but also, data constructors can restrict the values of those arguments in their return types. (this is what makes them useful in the first place.) in this case, So only has one constructor, only usable when its argument is True, meaning that constructing a value of type So p is only possible if the expression p reduces to True.

:::aside

`So` considered harmful, or whatever

in a lot of cases you need to write the property inductively, i.e., as a datatype, like

data NotRedex : Definitions -> Term d n -> Type

-- DPair is similar to Subset
whnf : (defs : Definitions) -> WhnfContext d n ->
       Term d n -> DPair (Term d n) (\t => NotRedex defs t)

the reason for this is that it is often easier to define other functions by matching on the proof rather than the original term.

but in this case that is not necessary and writing a function into Bool is easier.

:::

other parts of the compiler, like equality checking, can similarly require a proof that their arguments are not redexes, so that they don't have to keep calling whnf over and over, or risk wrongly failing if one argument isn't reduced enough.

qtt (quantitative type theory)

:::note (idris (2) has this one too, so i can still use real examples for now) :::

having this extra safety is nice, but it would be even nicer if it we could be sure it wouldn't affect run time efficiency. for a long time, dependently typed languages have tried to use heuristics to elide constructor fields that were already determined by other fields, at least as far back as 2003 [@indices]. but these analyses are anti-modular, in that a constructor field can only be erased if it is not matched against anywhere in the whole program.

maybe we should try telling the computer what we actually want.

in qtt [@qtt; @nuttin], every local variable is annotated with a quantity, telling us how many times we can use it at run time. in quox [(and idris2)]{.note}, the possible choices are 0 (not at all; erased), 1 (exactly once; linear), and ω (any number of times; unrestricted, and the default in idris and not written). if a variable is marked with 0, then you can't do anything with it that would affect run time behaviour. for example,

  • you can only match on values if their type has one or zero cases. if you "have" a variable of the empty type v : {}, you're already in an unreachable branch, so it's fine to abort with case0 v return 〈whatever〉 of { }. if you have an erased pair, it's fine to split it up, but the two parts will still be erased. matching on something like Bool isn't possible, because the value is no longer there to look at.

  • type signatures only exist at compile time so you can do whatever you want there.

  • equality proofs don't have any computational behaviour (unlike in some other type theories), so coercion works with an erased proof

as well as erasure, there is also linearity. a linear variable must be used exactly once in a linear context (and any number of times in an erased context, like in types or proofs talking about it). this is useful for things like file handles and other kinds of resources that have strict usage requirements. it's similar to passing a variable by value in rust, where after you do so, you can't use it yourself any more.

:::aside there's no equivalent to borrowing inside the type system, but i think with a careful choice of builtins, it would be possible to do a similar thing in an external library.

[rust person voice] it would be less ergonomic as library, but having a borrow checker inside the language would immediately blow my complexity budget. 🦀 :::

i don't have much to say about this, honestly, but ask any rust user about the benefits of tracking resource ownership in types.


so where do these quantities come from? from the types, of course. a function type in quox, which looks like ρ.(x : A) → B, has a quantity ρ attached, which describes how a function value of that type can use its argument. an identity function λ x ⇒ x can have type 1.A → A or ω.A → A, but not 0.A → A. and a "diagonal" function λ x ⇒ (x, x) can only be ω.A → A × A.

a whole definition can be erased (and if it's a definition of a type, it has to be, since types don't exist at run time), like

def0 TwoOfThem : ★ =  × 

finally, you can mark a specific term with a quantity. say you want to write a function that returns some number, plus an erased proof that it's even. obviously you can't mark the whole definition as erased with def0, since you want the number itself. and giving the return type as (n : ) × Even n makes the proof appear at run time, which might be unwanted if it's something big. so you can erase the second half of the pair by writing (n : ℕ) × [0. Even n]. a value of a "boxed" type \[π. A] is written \[e] if e : A. for a slightly bigger example, you might want a decidable equality that gives you erased proofs, so you can use them in coercions, but they don't show up at run time.

def0 Not : ω.★ → ★ = λ A ⇒ ω.A → {}

def0 Either : ω.★ → ω.★ → ★ = ⋯  -- constructors Left and Right

def0 Dec : ω.★ → ★ = λ A ⇒ Either [0. A] [0. Not A]

def Yes : 0.(A : ★) → 0.A       → Dec A = λ A y ⇒ Left  [0. A] [0. Not A] [y]
def No  : 0.(A : ★) → 0.(Not A) → Dec A = λ A n ⇒ Right [0. A] [0. Not A] [n]

def0 DecEq : ω.★ → ★ = λ A ⇒ ω.(x y : A) → Dec (x ≡ y : A)

you can also use the same construction to have some unrestricted parts of an otherwise linear structure.

:::aside still missing from this story, in my opinion, is some form of compile-time irrelevance. a lot of the time, you don't care about the content of a proof, only that it is satisfied, so if division has a type like div : 1.ℚ → 1.(d : ℚ) → 0.(NonZero d) → ℚ, you want some way to get div x y p₁ and div x y p₂ to always be equal, without even having to look at p₁ and p₂. there's no way to do that yet, because it doesn't seem to fit into qtt cleanly. maybe a single squash type..? :::

xtt ("extensional" type theory)

:::aside but not that extensional type theory :::

[@xtt]

other stuff

  • crude but effective [@crude; @mugen]
  • bidirectional typechecking [@bidi]
  • ...

i still don't know how to actually write a program

i know. that's ok. i'm just trying to communicate why someone might, hypothetically, care.

did it work?

references