Generic Level Polymorphic N-ary Functions

10/12/2021
by   Guillaume Allais, et al.
0

Agda's standard library struggles in various places with n-ary functions and relations. It introduces congruence and substitution operators for functions of arities one and two, and provides users with convenient combinators for manipulating indexed families of arity exactly one. After a careful analysis of the kinds of problems the unifier can easily solve, we design a unifier-friendly representation of n-ary functions. This allows us to write generic programs acting on n-ary functions which automatically reconstruct the representation of their inputs' types by unification. In particular, we can define fully level polymorphic n-ary versions of congruence, substitution and the combinators for indexed families, all requiring minimal user input.

READ FULL TEXT VIEW PDF
POST COMMENT

Comments

There are no comments yet.

Authors

page 1

page 2

page 3

page 4

11/07/2021

Equivalences of biprojective almost perfect nonlinear functions

Two important problems on almost perfect nonlinear (APN) functions are t...
12/31/2018

Generic Programming in OCaml

We present a library for generic programming in OCaml, adapting some tec...
02/25/2022

Bounds on Determinantal Complexity of Two Types of Generalized Permanents

We define two new families of polynomials that generalize permanents and...
02/11/2022

An inductive-recursive universe generic for small families

We show that it is possible to construct a universe in all Grothendieck ...
04/15/2017

Generic LSH Families for the Angular Distance Based on Johnson-Lindenstrauss Projections and Feature Hashing LSH

In this paper we propose the creation of generic LSH families for the an...
10/13/2021

libdlr: Efficient imaginary time calculations using the discrete Lehmann representation

We introduce libdlr, a library implementing the recently introduced disc...
This week in AI

Get the week's most popular data science and artificial intelligence research sent straight to your inbox every Saturday.

1. Introduction

For user convenience, Agda’s standard library has accumulated a set of equality-manipulating combinators of varying arities (Section 2) as well as a type-level compositional Domain Specific Language to write clean types involving indexed families of arity exactly one (Section 3.1). None of these solutions scale well. By getting acquainted with the unifier (Section 5), we can design a good representation of n-ary function spaces (Section 6) which empowers us to write generalised combinators (Sections 7 and 8) usable with minimal user input. We then see how the notions introduced to tackle our original motivations can be mobilised for other efforts in generic programming from an arity-generic zipWith (Section 9.2) to a direct style definition of printf (Section 9.3). This paper is a literate Agda file111The source code is available at https://github.com/gallais/nary; we discuss some of the more esoteric aspects of the language in Appendix A.

2. N-ary Combinators… for N up to 2

Agda’s standard library relies on propositional equality defined as a level polymorphic inductive family. It has one constructor (refl) witnessing the fact any value is equal to itself.

[StateOfTheArt.tex]equality

As one would expect from a notion of equality, it is congruent (i.e. for any function equal inputs yield equal outputs) and substitutive (i.e. equals behave the same with respect to predicates). Concretely this means we can write the two following functions by dependent pattern-matching on the equality proof:

[StateOfTheArt.tex]cong [StateOfTheArt.tex]subst

However we quickly realise that it is convenient to be able to use congruence for functions that take more than one argument and substitution for at least binary relations. The standard library provides binary versions of both of these functions:

[StateOfTheArt.tex]cong2 [StateOfTheArt.tex]subst2

If we want to go beyond arity two we are left to either define our own ternary, quaternary, etc. versions of cong and subst, or to awkwardly chain the ones with a lower arity to slowly massage the expression at hand into the shape we want. Both of these solutions are unsatisfactory.

Wish

We would like to define once and for all two functions congₙ and substₙ of respective types (pseudocode):

congₙ : (f : A₁ → ⋯ → Aₙ → B) →
a₁ ≡ b₁ → ⋯ → aₙ ≡ bₙ →
f a₁ ⋯ aₙ ≡ f b₁ ⋯ bₙ
substₙ : (R : A₁ → ⋯ → Aₙ → Set r) →
a₁ ≡ b₁ → ⋯ → aₙ ≡ bₙ →
R a₁ ⋯ aₙ → R b₁ ⋯ bₙ

3. Invariant Respecting Programs

A key feature of dependently typed languages is the ability to enforce strong invariants. Inductive families (DBLP:journals/fac/Dybjer94) are essentially classic inductive types where one may additionally bake in these strong invariants. As soon as the programmer starts making these constraints explicit, they need to write constraints-respecting programs. Although a lot of programs are index-preserving, users need to be painfully explicit about things that stay the same (i.e. the index being threaded all across the function’s type) rather than being able to highlight the important changes.

3.1. Working With Indexed Families

The standard library defines a set of handy combinators to talk about indexed families without having to manipulate their index explicitly. These form a compositional type-level Domain Specific Language (DBLP:journals/csur/Hudak96) (DSL): each combinator has a precise semantics and putting them together builds an overall meaning.

A typical expression built using this DSL follows a fairly simple schema: a combinator acting as a quantifier for the index (Section 3.1.1) surrounds a combination of pointwise liftings of common type constructors (Section 3.1.2), index updates (Section 3.1.3), and base predicates. This empowers us to write lighter types, which hide away the bits that are constant, focusing instead on the key predicates and the changes made to the index.

Before we can even talk about concrete indexed families, describe these various combinators, and demonstrate their usefulness, we need to introduce the data the families in our running examples will be indexed over. We pick List the level polymorphic type of lists parameterised by the type of their elements: it is both well-known and complex enough to allow us to write interesting types.

[StateOfTheArt.tex]list

The most straightforward non-trivial indexed family we can define over List is the predicate lifting All which ensures that a given predicate P holds of all the elements of a list. It has two constructors which each bear the same name as their counterparts in the underlying data: nil ([]) states that all the elements in the empty list satisfy P and cons (_::_) states that P holds of all the elements of a non-empty list if it holds of its head and of all the elements in its tail.

[StateOfTheArt.tex]all

Some of our examples require the introduction of Any, the other classic predicate lifting on list. It takes a predicate and ensures that it holds of at least one element of the list at hand. Either it holds of the first one and we are given a proof (here) or it holds of a value somewhere in the tail (there).

[StateOfTheArt.tex]any

3.1.1. Quantifiers

We have two types of quantifiers: existential and universal. As they are meant to surround the indexed expression they are acting upon, we define them as essentially pairs of matching opening and closing brackets. The opening one is systematically decorated with a mnemonic symbol: ∃ for existential quantification, Π for explicit dependent quantification and ∀ for implicit universal quantification. Additionally we use angle brackets for existential quantifiers and square brackets for universal ones, recalling the operators diamond and box of modal logic.

Existential Quantifier

In type theory, existential quantifiers are represented as dependent pairs. We introduce Σ, a dependent record parameterised by a type A and a type family P. It has two fields proj₁ for a value of type A and proj₂ for a proof of type (P proj₁). We can build and pattern-match against pairs using the constructor _,_ and we can project either of the pair’s components simply by using its field’s name.

[StateOfTheArt.tex]sigma

The existential quantifier for indexed families is defined as a special case of Σ; it takes the index Set implicitly.

[StateOfTheArt.tex]exists

Using ∃⟨_⟩ we can write our first statement about an indexed family: from the existence of a list such that P holds of all its elements, we can construct a list of pairs of elements and proofs that P holds for that value.

[StateOfTheArt.tex]toList

Universal Quantifiers

The natural counterpart of existential quantification is universal quantification. In type theory this corresponds to a dependent function space. Here we have room for variations and we can consider both the explicit (Π[_]) and the implicit (∀[_]) universal quantifiers.

[StateOfTheArt.tex]universal [StateOfTheArt.tex]iuniversal

Provided that a proposition holds of any value, we can prove it will hold of any list of values by induction on such a list. Because we perform induction on the list it is convenient to take it as an explicit argument whereas the proof itself can take its argument implicitly.

[StateOfTheArt.tex]replicate

3.1.2. Pointwise Liftings

Pointwise liftings for an index type I are operators turning a type constructor on Sets into one acting on I-indexed families by threading the index. They are meant to be used partially applied so that both their inputs and their output are I-indexed, hence the mismatch between their arity and the number of places for their arguments.

Implication

We start with the most used of all: implication i.e. functions from proofs of one predicate to proofs of another. [StateOfTheArt.tex]implies

The combinator _⇒_ associates to the right just like the type constructor for functions does. We can write the analogue of sequential application for applicative functors (DBLP:journals/jfp/McbrideP08) like so: [StateOfTheArt.tex]ap

Conjunction

To state that the conjunction of two predicates hold we can use the pointwise lifting of pairing.

[StateOfTheArt.tex]conjunction

This enables us to write functions which return more than one result. We can for instance write the type of unzip, the proof that if the conjunction of P and Q holds of all the elements of a given list then both P and Q in isolation hold of all of that list’s elements.

[StateOfTheArt.tex]unzip

Notice that we are using the conjunction combinator both on predicates ranging over values and on ones ranging over lists of values.

Disjunction

To formally describe the disjunction of two predicates, we need to define _⊎_ the type of disjoint sums first. It has two constructors each of which corresponds to a choice of one side of the sum or the other.

[StateOfTheArt.tex]sum

The disjunction of two predicates is then the pointwise lifting of _⊎_.

[StateOfTheArt.tex]disjunction

A typical use case for disjoint sums is the notion of decidability: either a predicate or its negation holds. We can formulate a general decidability result for All: if for any value either P or Q holds then for any list of values, either (Any P) or (All Q) holds.

[StateOfTheArt.tex]decide

Here we did not limit ourselves to either P or its negation but it is sometimes necessary to talk directly about negation.

Negation

Traditionally negation is defined as functions into the empty type ⊥. We start by defining it as the inductive type with not constructor together with its elimination principle (⊥-elim).

[StateOfTheArt.tex]bot

[StateOfTheArt.tex]botelim

Negation for a unary predicate P is then the unary predicate which maps i to (P i → ⊥).

[StateOfTheArt.tex]negation

The two predicate liftings All and Any interact in non-trivial ways. For instance if we know that the negation of P holds of any value in a given list then P can’t hold of all its elements. In other words: a single counter-example is enough to disprove a universal statement.

[StateOfTheArt.tex]anynotall

Notice that we are once more using the combinator we just defined both on a predicate on values and one on lists of values.

3.1.3. Adjustments To The Ambient Index

Threading the index is only the least invasive of the modes of action available to us. But we can also more actively interact with the ambient index either by ignoring it completely, adjusting it using a function or overwriting it entirely. We will not detail the last option as, as always, overwriting is adjusting with a constant function.

Constant

Although we have so far only manipulated indexed families, some of our function’s arguments or its result may not depend on the index. The constant indexed family is precisely what we need to represent these cases.

[StateOfTheArt.tex]const

We can for instance prove that if the constantly false predicate (const ⊥) holds true of all the elements of a list then said list is the empty list. We use a section (i.e. a partially applied infix operator) of propositional equality to formulate that conclusion. In the proof we do not need to consider the _∷_ case: Agda automatically detects that it is impossible.

[StateOfTheArt.tex]empty

Note that we had to add a type annotation to []: the type of the index of the predicate defined using const is an implicit polymorphic argument and so is the type of elements in List’s nil constructor. Agda can infer that these two implicit arguments are equal but needs to be given enough information to figure out what it ought to be. In type theory, an identity function is a fine definition of a type annotation operator:

[StateOfTheArt.tex]annot

Update

On the other end of the spectrum, we have operations which update the ambient index using an arbitrary function. The notation _⊢_ is inspired by the convention in type theory to consider that proofs in sequent calculus are written in an ambient context and that we may use a turnstile to describe the addition of newly-bound variables to this context (see e.g. Martin Löf’s work (martin1982constructive)).

[StateOfTheArt.tex]update

Stating that a function operating on lists is compatible with All is a typical use case of such a combinator. If the function at hand is called f then the convention in the standard library is to call such a proof f⁺ as it makes f appear in the conclusion. We pick concat (whose classic definition is left out) in this concrete example.

[StateOfTheArt.tex]join

3.2. Working With Multiple Indices

We started by showing both the type and the implementation of each of our examples. Although convenient at first to build an understanding of which arguments are explicit and which ones are implicit, we are in the end only interested in the way combinators let us write types. In this section, we focus on the types and only the types of our examples.

The combinators presented earlier are all available in the standard library. As we have demonstrated, they work really well for unary predicates. Unfortunately they do not scale beyond that. Meaning that if we are manipulating binary relations for instance we have to explicitly introduce one of the indices and partially apply the relations in question before we can use our usual unary combinators. This leads to cluttered types which are not much better than their fully expanded counterparts.

Let us look at an example. We introduce Pw (for “pointwise”) the relational equivalent of the predicate lifting All we have been using as our running example so far. The inductive family Pw is parameterised by a relation R and ensures its two index list are compatible with R in a pointwise manner. If both lists are empty then they are trivially related ([]); otherwise we demand that their heads are related by R and their tails are related pointwise (_∷_).

[StateOfTheArt.tex]pointwise

To state the relational equivalent to All’s _¡⋆¿_ using our combinators for unary predicates, we need to partially apply Pw to xs to make it a predicate as well as explicitly use a λ-abstraction to build the relation corresponding to the fact that R implies S.

[StateOfTheArt.tex]appw

Ideally we could have instead used binary version of the combinators for unary predicates we saw earlier and have simply written:

[Examples.tex]appw

We could duplicate the definitions for unary predicates and have equivalent combinators for binary relations however this will create two new issues. First, the day we need a library for ternary relations we will have triplicated the initial work. Second, we would have two sets of definitions with identical names meaning they cannot be both imported in the same module without clashing thus forcing users to manually disambiguate each use site.

Wish

We would like to define once and for all n-ary quantifiers, pointwise lifting of common type constructors, and adjustment functions.

4. Plan

We can start to draw out the structure of our contribution now that we have a good idea of the current state of the art, its limitation, and the extensions we want to see. Here are the key points we deliver:

Reified Types

We come up with a representation of n-ary functions which is as general as possible: the domains should be allowed to be different types, even types defined at different universe levels.

Semantics

We give a semantics taking a reified type and computing its meaning as a Set at some universe level. This level also needs to be computed from the description.

Invertible

The representation and its semantics are unifier friendly. That is to say that if using a combinators yields a constraint of the form “this type is the result of evaluating the representation of an n-ary function type”, then Agda will be able to reconstruct the representation and discharge the constraints without any outside help.

Applications

Lastly we deliver the two wishes we formulated earlier by actually implementing the n-ary versions of cong, subst, and the various combinators for manipulating indexed families.

5. Getting Acquainted With the Unifier

Unification is the process by which Agda reconstructs the values of the implicit arguments the user was allowed to leave out (DBLP:conf/tlca/AbelP11). It is one of the mechanisms bridging the gap between the source program which should be convenient for humans to read, write, and modify and the fully explicit terms in the internal syntax.

It is important to build a good understanding of the problems the unifier can easily solve to be able to write combinators usable with minimal user input. Indeed if we can anticipate that an argument can be reconstructed, we may as well make it implicit and let Agda do the work.

Notations

We write ?a for a metavariable, e[?a₁, ⋯ ,?aₙ] for an expression e containing exactly the metavariables ?a₁ to ?aₙ, c e₁ ⋯ eₙ for the constructor c applied to n expressions and lhs ≈ rhs to state a unification problem between two expressions lhs and rhs.

Unification Tests

We can easily trigger the resolution of unification problems by writing unit tests in the source language. We can force Agda to introduce metavariables by using an underscore (_) as a placeholder for a subterm and use refl at the proof of a propositional equality to force it to unify the two expressions stated to be equal. For instance in the following test we force Agda to check that (?A → ?B) can be unified with (ℕ → ℕ).

[Unifier.tex]unifproblem

To express problems where a single metavariable is used multiple times, we can use a let binder. For instance, we can indeed unify (?A → ?A) with (ℕ → ℕ).

[Unifier.tex]sharedunifproblem

Whenever Agda cannot solve a metavariable by unification it is highlighted in yellow like so: _. Whenever Agda cannot satisfy a unification constraint raised by the use of refl, it will also highlight it in yellow like so: refl.

Let us now look at the various scenarios in which it is easy for the unifier to decide whether a constraint is satisfiable.

5.1. Instantiation

The simplest case the unifier can encounter is a problem of the form ?a ≈ e[?a₁ ⋯ ?aₙ] where ?a does not appear in the list [?a₁, ⋯ ,?aₙ]. The unifier can simply instantiate the metavariable to the candidate expression.

For instance in the following test you can see that neither the underscore on the left-hand side nor the refl constructor is highlighted in yellow. Meaning that the metavariable on the left was indeed solved (by instantiating it to the expression on the right-hand side) and that the constraint induced by the use of refl was thus satisfied. The problem itself is under-constrained so it is not surprising that the right-hand side lights up.

[Unifier.tex]instantiation

5.2. Constructor Headed

The second case where the unifier can easily make progress is a unification problem between to constructor-headed expression c e₁ ⋯ eₘ ≈ d f₁ ⋯ fₙ.

Success

Either the constructors c and d match up, we learn that m equals n and we can reduce the problem to unifying the constructors’ respective arguments by forming the new unification problems (e₁ ≈ f₁) ⋯ (eₘ ≈ fₙ).

In the following example, Agda sees that both expressions have _→_ as their head constructor, proceeds to unify ℕ with itself on the one hand (which succeeds because both have the same head constructor and they do not have any arguments) and ℕ with ?A on the other (which succeeds by instantiation).

[Unifier.tex]unifconstr

Failure

Or c and d are distinct and we can immediately conclude that unification is impossible. We cannot write an expression in Agda demonstrating this case as it leads to a type error in the language. Trying to form the unification problem ℕ ≈ (?A → ?B) would raise such an error because ℕ and _→_ are distinct head constructors.

5.3. Avoid Computations…

In general unification problems involving computations are undecidable. We can easily construct a total simulation function sim for Turing machines which takes in as arguments the code for an arbitrary program prg and a natural number n and returns 0 if and only if the program runs for exactly n steps before stopping and 1 otherwise. Forming a constraint like sim prg _ ≈ 0 is effectively asking whether the program prg terminates. It is clearly impossible to write a unifier solving all problems of this form.

5.4. … In Most Cases

Although unification problems involving computations will in general fail to produce solutions, there are exceptions.

Disappearing Problem

The first favourable case is a Lapalissade: stuck function applications which are guaranteed to go away in all cases of interest to us are never a problem. This is true whenever we know that in all use cases the concrete values at hand will allow evaluation to reveal enough constructors for unification to succeed.

To demonstrate this phenomenon we introduce a type nary of n-ary functions on natural numbers. It is parameterised by the return type of the n-ary function and defined by induction on n.

[Unifier.tex]nary

In general, it is impossible to solve the unification constraint nary ?n ?A ≈ (ℕ → A). If the natural number is not specified then nary is stuck. And there is no hope to solve this problem; indeed there are two solutions (?n could be either 0 or 1) because every unary function is also a nullary symbol whose type is a function type. As explained earlier, Agda communicates to us this failure to solve the two metavariables passed to nary as arguments by highlighting them in yellow. The constructor refl is also highlighted as the source of the unification constraint that could not be satisfied.

[Unifier.tex]unsolved

If the natural number argument is however a concrete value then nary evaluates fully and Agda is able to reconstruct A by unification. In the following two examples we unify (ℕ → ℕ) with (ℕ → ?A) on the one hand and ?A on the other. Both unification problems succeed without any issue.

[Unifier.tex]normalised1

[Unifier.tex]normalised0

This observation is language independent. It will directly influence our encoding: we expect our users to only ever use our generic congruence combinator with concrete arities. A representation defined by induction on such a natural number would therefore work well with the unifier.

Invertible Problem

The second case in which we may encounter unification problems involving stuck computations and still see Agda find a solution is more language dependent but just as principled. Whenever the stuck function is defined by a set of equations whose right-hand sides are clearly anti-unifiable, we can invert it.

For instance if the Set parameter to nary is known to be ℕ then the right-hand side of the first equation is ℕ and the second’s one has the shape (ℕ → _). These two are clearly disjoint and so Agda can invert nary and figure out that the arity we left out in the following example is 1.

[Unifier.tex]inverted

If we had passed (ℕ → ℕ) instead as the second argument to nary then the two right-hand sides would not have been obviously disjoint and Agda would have given up on trying to invert nary.

[Unifier.tex]notinverted

These two examples tell us that we can hope to leave out a function’s arity entirely if we statically know its codomain and it has a shape clearly anti-unifiable with the right-hand sides of our semantics of reified function types. Note in particular that combinators acting on relations (cf. Section 3.1) are manipulating functions whose codomain is always of the shape (Set _) which is clearly disjoint from (_ → _). We ought to be able to define their n-ary counterparts without having to mention n explicitly.

6. Representing N-ary Function Types

Now that we understand how the unifier works, we can design a generic representation and its semantics (called ⟦_⟧ here for convenience) so that whenever we have a constraint of the form ⟦ ?r ⟧ ≈ (ℕ → Set), it can easily lead to the reconstruction of the representation ?r.

User Input

As we have just seen, a binary function type (A → B → C) with codomain C can also be seen as a unary function type with codomain (B → C). As a consequence in the general case there is no hope to get Agda to reconstruct the representation we have in mind without passing it at least a little bit of information. The least we can do is tell Agda the arity of the function. From this single natural number we will compute the shape of the whole representation.

Unification

As we have just seen, if we want Agda to reconstruct the representation from a unification constraint then our best hope is that the semantics function evaluates fully and simply disappears. This means in particular that it should not get stuck on a pattern-matching analysing the representation. This can be achieved with certainty by constraining our representation to only be built up from things we either will not pattern-match against (e.g. Sets) or type constructors which enjoy η-equality (i.e. for which values can always be made to look like they are in canonical form). Just like the representation, the semantics will have to be computed entirely from the natural number corresponding to the function’s arity.

From these two observations, we decide that our representation will be parameterised by a natural number which we will use to compute a number of right-nested products.

Right-Nested Products

The two basic building blocks of right-nested products are a binary product _×_ and the unit type ⊤.

We obtain the binary product as the non-dependent special case of Σ we introduced in Section 3.1.1. We did not mention it at the time but record types in Agda enjoy η-rules. That is to say that any value p of type (Σ A P) is definitionally equal to (proj₁ p , proj₂ p).

The unit type is defined as a record with no field. Every value of type ⊤ is equal to the canonical value tt.

[StateOfTheArt.tex]unit

Even though ⊤ is defined as a Set, we will sometimes need to use it at a higher level. The usual solution is to manually lift it to the appropriate level. Because Lift is also a record, it will not get in the way of reconstruction.

[StateOfTheArt.tex]lift

Level Polymorphism

To achieve fully general level polymorphism, we need all the domains of our function type to be potentially at different levels. Luckily the notion of Level in Agda is a primitive Set and we can thus manipulate them just like any other values. In particular we can define containers storing them. Our first definition called Levels defines an n-tuple of Levels by induction on n.

[N-ary.tex]levels

Heterogeneous Domains

Before we can generate the big right-nested n-tuple packaging the function’s domains, we need to compute the level at which it is going to live. The definition of Σ makes clear that the product of two types living respectively at level a and b sits at level (a ⊔ b) i.e. the least upper bound of a and b. We define ⨆ as the generalisation of the least upper bound operator to (Levels n) by induction on n.

[N-ary.tex]tolevel

Knowing that (Set a) sits at level (suc a), it is natural to declare that our n-tuple of sets defined at various Levels will be defined at the successor of the generalised least upper bound of these Levels.

[N-ary.tex]sets

We can now encode an n-ary function space as essentially a collection ls of (Levels n) together with a corresponding n-tuple of type (Sets n ls) for the domains, and a level r and a (Set r) for the codomain.

Semantics

This encoding has a straightforward semantics by induction on n and case analysis on the (Sets n ls) argument. A zero-ary function type is simply the codomain whilst a (suc n)-ary one is a unary function type whose codomain is the n-ary function type obtained by induction hypothesis.

[N-ary.tex]arrows

If we look carefully at this definition we can notice that the function Arrows may only ever get stuck if the natural number is not concrete. Even though we do take the Sets argument apart, it is a product type and thus enjoys η-rules. We have achieved the degree of unifier-friendliness we were aiming for.

Our first example is a 2-ary function: our favourite indexed family All. The last element of the telescope, a value whose type is a lifted version of the unit type, can be inferred by Agda so we leave it out.

[Examples.tex]all

7. Combinators for Indexed Families

Now that we have our generic representation of n-ary function types, we can finally start building the n-nary counterparts of the combinators we discussed at length in Section 3.1.

7.1. Quantifiers

If we already know how to quantify over one variable, we can easily describe how to quantify over n variables by induction over n. This is what quantₙ does. Provided a (level polymorphic) quantifier Q and a Set-valued n-ary function f, we distinguish two cases: if n is 0 then the function is already a Set and we can return it directly; otherwise we use Q to quantify over the outer variable which we call x and proceed to quantify over the remaining variables in (f x) by using the induction hypothesis.

[N-ary.tex]quantify

We can define the specific instances of n-ary quantification we are interested in by partially applying quantₙ with the appropriate concrete quantifiers. Because we are dealing with Set-valued functions, we can leave their arity as an implicit argument and let Agda infer it at use site. In all cases we give them the same name as their unary counterparts as they can be used as drop-in replacements for them.

We start with the n-ary existential quantifier defined using the unary quantifier we introduced in Section 3.1.1.

[N-ary.tex]ex

Similarly we can define the explicit and implicit universal quantifiers.

[N-ary.tex]all [N-ary.tex]iall

7.2. Pointwise Liftings

Pointwise lifting of a binary function can be defined uniformly for any operation of type (A → B → C) and any pair of n-ary functions whose domains match and codomains are respectively A and B. It is defined by induction on the arity n of the input functions.

[N-ary.tex]lift2

From this very general definition we can recover the combinators we are used to. For each one of them we are able to leave out the arity argument thanks to the observation we made in Section 5.4: Set and (?A → ?B) are anti-unifiable and Agda is therefore able to reconstruct the arity for us!

Implication is the lifting of the function space.

[N-ary.tex]implication

Conjunction is the lifting of pairing.

[N-ary.tex]conjunction

Disjunction is the lifting of the sum type.

[N-ary.tex]disjunction

Negation is obviously not a binary operation. In practice, rather than having multiple ad-hoc lifting functions for various arities we have a fully generic liftₙ functional which lifts a k-ary operator to work with k n-ary functions whose respective codomains match the domains of the operator. Its type could be summarised as:

liftₙ : ∀ k n.
(B₁ → ⋯ → Bₖ → B) →
(A₁ → ⋯ → Aₙ → B₁) →
(A₁ → ⋯ → Aₙ → Bₖ) →
(A₁ → ⋯ → Aₙ → B)

The thus generalised definition has a fairly unreadable type so we leave this formal definition out of the paper. Curious readers can consult the accompanying code. We can evidently use liftₙ with k equal to 1 to lift negation from an operation on Set to an operation on Arrows.

[N-ary.tex]negation

7.3. Adjustments To The Ambient Indices

We now have obtained the generalised versions of the index-threading combinators we wanted. We can similarly define a number of index-altering combinators. The first two are the n-ary versions of the two operators we described in Section 3.1.3.

Lifting a mere value to a constant n-ary function is a matter of composing const with itself n times.

[N-ary.tex]const

Updates are a bit more subtle: now that we are not limited to a single index, we can choose which index should be updated. We expect the user to provide a natural number n to target a specific index, the type of the combinator then clearly states that n sets are skipped, the target is updated and the rest of the type is unchanged.

[N-ary.tex]compose

The added complexity of working with n-ary relations means that we have more interesting operators than simply the generalised version of the ones we had introduced for unary predicates.

We may for instance want to map a unary function on the result of an n-ary one. Note that this empowers us to partially apply any n-ary function to a value x in its k-th argument by choosing to see it as a k-ary function and mapping (_$ x) on it.

[N-ary.tex]map

8. Congruence and Substitution

So far the types we have ascribed our combinators for n-ary relations were fairly tame. Things get a bit more complicated when dealing with congruence and substitution: we will not be able to write these functions’ types directly. Both definitions follow the same structure: we start by computing the operation’s type by induction and we can then implement the operation itself.

8.1. Congruence

The type of congruence mentions only one function. However it is applied to two distinct lists of values to form the left-hand side and the right-hand side of the conclusion. As a consequence when we compute the type we take two functions as inputs and use one to apply to the arguments meant for the left-hand side and the other for the ones meant for the right-hand side of the equation.

Congruence for two 0-ary functions collapses to simply propositional equality of the two constant values.

Congruence for two (suc n)-ary functions f and g amounts to stating that for any pair of equal values x and y we expect that (f x) and (g y) are congruent.

[Applications.tex]Cong

The congruence lemma is then obtained by stating that the n-ary function f is congruent with itself. We prove it by induction on n, pattern-matching on the proofs of equality as we go along.

[Applications.tex]cong

8.2. Substitution

The definition of Substₙ is identical to that of Congₙ except that we now consider predicates rather than arbitrary functions. The base case is therefore dealing with P and Q being two Sets rather than two values at a given type. As a consequence we demand a function transporting proofs of P to proofs of Q rather than a proof of equality.

[Applications.tex]Subst

Substitution acts on n-ary relations. Recalling our observation made in Section 5.4 that Agda can easily reconstruct the arity of Set-valued functions, we can make n an implicit argument.

[Applications.tex]subst

9. Further Generic Programming Efforts

The small language we have developed to talk about n-ary functions can be used beyond our first few motivating examples of congruence, substitution, and combinators to define types involving relations. We detail in this section various results that fall out naturally from this work. We start with generic currying and uncurrying, and then use these to define an n-ary zipWith and revisit printf in direct style.

9.1. Product and (Un)Currying

We gave in Section 6 a semantics to our reified types as proper n-ary function types. We can alternatively interpret a Sets as a big right-nested and ⊤-terminated product containing one value for each Set. We once more proceed by induction on n.

[N-ary.tex]product

We can convert back and forth between a unary function whose domain is a Product of Sets and an n-ary function whose domains are the same sets. These conversion functions correspond to currying and uncurrying. Both curryₙ and uncurryₙ are implemented by structural induction on n and in terms of their binary counterparts. In the base case, the function is either applied to tt or uses const to throw away a value of type ⊤; this is an artefact of the fact our definition of Product is ⊤-terminated

[N-ary.tex]curry [N-ary.tex]uncurry

⊤-free Variant

In practice users do not tend to write ⊤-terminated right-nested products. As a consequence it is convenient to have a definition of Product which has a special case for Sets of size exactly 1 returning the Set without pairing it with ⊤. This makes curryₙ and uncurryₙ more useful overall. Most generic functions however are easier to implement using the ⊤-terminated version of Product. In our library we provide both as well as conversion functions between the two interpretations.

9.2. N-ary Zipping Functions

Some functions are easier to write curried but nicer to use uncurried. This is the case with zipWithₙ, the n-ary version of the higher-order function which takes a function and two lists as inputs and produces a list by processing both lists in lockstep and using the function it was passed to combine their elements. Using ellipses, we would write its type as:

zipWithₙ : ∀ n. (A₁ → ⋯ → Aₙ → B) →
List A₁ → ⋯ → List Aₙ → List B

To formally write this type, we need to explain how to map a level polymorphic endofunctor on Set (here: List) over a (Sets n ls). We proceed by induction on n.

[N-ary.tex]smap

As explained earlier it is vastly easier to implement the function using the uncurried type, and to then recover the desired type by invoking generic (un)currying in the appropriate places. The function we want is therefore implemented in term of an auxiliary definition called zw-aux.

[N-ary.tex]zipWith

Implementation

The auxiliary definition is still a bit involved so we detail each equation of its definition below. We start with its type first.

[Applications.tex]zw-aux-type

When n is 0, a Haskeller would typically return an infinite list containing the value f repeated. However this is not possible in Agda, a total language (DBLP:journals/jucs/ATurner04): all the lists have to be finite. Our only principled option is to return the empty list.

[Applications.tex]zw-aux0

Because the behaviour of the 0 case is less than ideal, we bypass it every time except if zipWithₙ is explicitly called on 0. This is done by having a special case for n equals 1. In this situation, we can get our hands on a function f of type ((A × ⊤) → R) and a List A and we need to return a List R. We map a tweaked version of the function on the list.

[Applications.tex]zw-aux1

The meat of the definition is in the last case: we are given a function ((A × A₀ × ⋯ × Aₙ) → R), a list of As and a product of lists (List A₀ × ⋯ × List Aₙ). We massage the function to obtain another one of type ((A₀ × ⋯ × Aₙ) → (A → R)) which we can combine with the product of lists thanks to our induction hypothesis. This gives us back a list of functions of type (A → R). We can conclude thanks to the usual binary zipWith to combine this list of functions with the list of arguments we already had.

[Applications.tex]zw-auxn

9.3. Printf

The combinators we have introduced also make it easy to implement printf in direct style as opposed to the classic accumulator-based definition (DBLP:conf/icfp/Augustsson98; DBLP:journals/jfp/Danvy98). We effectively produce a well typed version of the ill typed intermediate function Asai, Kiselyov, and Shan consider in their derivation of a direct-style implementation using delimited control (DBLP:journals/lisp/AsaiKS11).

We work in a simplified setting which allows us to focus on the contribution our n-ary combinators bring to the table. Our printf will only take natural numbers as arguments and we will not worry about defining the lexer transforming a raw String into a Format, that is to say a list of Chunks each being either a Nat corresponding to a “%u” directive (i.e. unsigned decimal integer) or a Raw string.

[Printf.tex]chunk

[Printf.tex]chunks

Our notion of Format is not intrinsically sized but we do need to know how many arguments our printf function is going to take if we want to use the machinery for n-ary functions. We assume the existence of a size function counting the number of Nat in a Format. We also assume the existence of 0ℓs, a (Levels n) equal to 0ℓ everywhere. Using these we can give Format a semantics as a Sets of arguments printf will expect. To each Nat we associate a ℕ constraint, the other Chunks do not give rise to the need for an input.

[Printf.tex]format

The essence of printf is then given by a function assemble which collects a list of strings from various sources. Whenever the format expects a natural number, we know we got one as an input and can show it. Otherwise the raw string to use is specified in the Format itself as an argument to Raw.

[Printf.tex]assemble

The toplevel function is obtained by currying the composition of concat and assemble.

[Printf.tex]printf

We can check on an example that we do get a function with the appropriate type when we use a concrete Format (here the one we would obtain from the string ”%u ¡ %u”).

[Printf.tex]example

And that it does produce the expected string when run on arguments.

[Printf.tex]test

10. Conclusion, Related and Future Work

We have seen that Agda’s standard library defines a useful couple of functions to produce proofs of equality as well as a type-level domain specific language to manipulate unary predicates. We then got acquainted with the unifier and the process by which a unification constraint can lead to the reconstruction of a function’s implicit arguments. Based on this knowledge we have designed a representation of n-ary function types particularly amenable to such reconstructions. This allowed us to define n-ary versions of congruence, substitution as well as the basic building blocks of the type-level DSL for relations we longed for. The notions introduced to set the stage for these definitions were already powerful enough to allow us to revisit classic dependently typed traversals such as an n-ary version of zipWith, or direct-style printf. This work has now been merged in the Agda standard library and will be part of the released version 1.1 (see modules Data.Product.Nary.NonDependent, Function.Nary.NonDependent, and Relation.Nary for library code and Text.Printf for one application).

Limitations

We are relying heavily on two key features of Agda that are not implemented in other dependently typed languages as far as we know.

First, Levels are a first class notion: they can be stored in data structures, passed around in functions and computed with just like any other primitive type. Unlike other primitive types (e.g. floating point numbers), it is not possible to perform case analysis on them. Other dependently typed languages may not be too keen on adopting this extension given that its meta-theoretical consequences are currently unknown. We recommend that they use meta-programming instead to duplicate this work: programs written in MetaCoq (DBLP:conf/itp/AnandBCST18; draf/metacoq18) can for instance explicitly manipulate universe levels.

Second, Agda’s unifier has a heuristics that attempts to invert stuck functions when solving constraints. As we have explained in Section 

5.4 this heuristics is principled: if it succeeds, the generated solution is guaranteed to be unique. We hope that our detailed use-case incites other languages to consider adopting it.

Codes for N-ary Function Types

We can find in the literature various deep (DBLP:conf/icfp/VerbruggenVH08) and shallow (DBLP:journals/jfp/McBride02; DBLP:conf/plpv/WeirichC10) embeddings of polymorphic types and a fortiori of n-ary function types in a dependently typed language. However none of them are fully level polymorphic and most only consider the representation as a secondary requirement, their focus being on certifying equivalent programs in Generic Haskell (DBLP:conf/popl/Hinze00). We however care deeply about level polymorphism as well as being unification-friendly to minimise the reification work the user needs to do.

Telescopes

The lack of dependencies between the various domains and the codomain of our Arrows is flagrant. A natural question to ask is how much of this machinery can be generalised to telescopes rather than mere Sets without incurring any additional burden on the user. From experience we know that it is sometimes wise to explicitly use the non-dependent version of an operator (e.g. function composition) to inform Agda’s unifier that it is only looking for a solution in a restricted subset.

Datatype genericity

Our implementation of an n-ary version of zipWith started as an example of the types and accompanying generic programs one can write with our library. It demonstrates that the notions introduced for our purposes can be useful in a more general context. This result is not new either with or without dependent types (DBLP:journals/jfp/FridlenderI00; DBLP:journals/jfp/McBride02). It can however be extended as previous efforts in dependently typed programming have demonstrated: Weirich and Casinghino’s work (DBLP:conf/plpv/WeirichC10) on arity-generic but also data-generic programming suggests we should be able to push this further. Their development predates the addition of universe polymorphism to Agda and although the traversals are adequately heterogeneous, their approach would not scale to universe polymorphic functions.

Parametricity as a derivation principle

Some of the examples we have used could have been obtained “for free” by parametricity: All and Pw are respectively the predicate and the relational inductive-style translations of the definition of List. The function replicate defined in Section 3.1.1 is a consequence of the abstraction theorem corresponding to the predicate interpretation and a similar free theorem stating that if a relation is reflexive then so is its pointwise lifting could have been derived for Pw. Bernardy, Jansson, and Paterson’s work on parametricity for dependent types (DBLP:journals/jfp/BernardyJP12) makes these observations formal and generalises these constructions to all inductive types and n-ary relational liftings.

Appendix A Agda-Specific Features

We provide here a description of some of the more esoteric Agda features used in this paper. Readers interested in a more thorough introduction to the language may consider reading Ulf Norell’s lecture notes (DBLP:conf/afp/Norell08).

a.1. Syntax Highlighting in the Text

The colours used in this paper all have a meaning: keywords are highlighted in orange; blue denotes function and type definitions; green marks constructors; pink is associated to record fields and corresponding projections.

a.2. Universe Levels

Agda avoids Russell-style paradoxes by introducing a tower of universes Set₀ (usually written Set), Set₁, Set₂, etc. Each Setₙ does not itself have type Setₙ but rather Setₙ₊₁ thus preventing circularity.

We can form function types from a domain type in Setₘ to a codomain type in Setₙ. Such a function type lives at the level corresponding to the maximum of m and n. This maximum is denoted (m ⊔ n).

An inductive type or a record type storing values of type Setₙ needs to be defined at universe level n or higher. We can combine multiple constraints of this form by using the maximum operator. The respective definitions of propositional equality in Section 2 and dependent pairs in Section 3.1.1 are examples of such data and record types.

Without support for a mechanism to define level polymorphic functions, we would need to duplicate a lot of code. Luckily Agda has a primitive notion of universe levels called Level. We can write level polymorphic code by quantifying over such a level l and form the universe at level l by writing (Set l). The prototypical example of such a level polymorphic function is the identity function id defined as follows.

[Appendix.tex]identity

a.3. Meaning of Underscore

Underscores have different meanings in different contexts. They can either stand for argument positions when defining identifiers, trivial values Agda should be able to reconstruct, or discarded values.

a.3.1. Argument Position in a Mixfix Identifier

Users can define arbitrary mixfix identifiers as names for both functions and constructors. Mixfix identifiers are a generalisation of infix identifiers which turns any alternating list of name parts and argument positions into a valid identifier (DBLP:conf/ifl/DanielssonN08). These argument positions are denoted using an underscore. For instance ∀[_] is a unary operator, (_::_) corresponds to a binary infix identifier and (_%=_⊢_) is a ternary operator.

a.3.2. Trivial Value

Programmers can leave out trivial parts of a definition by using an underscore instead of spelling out the tedious details. This will be accepted by Agda as long as it is able to reconstruct the missing value by unification. We discuss these use cases further in Section 5.

a.3.3. Ignored Binder

An underscore used in place of an identifier in a binder means that the binding should be discarded. For instance (λ _ → a) defines a constant function. Toplevel bindings can similarly be discarded which is a convenient way of writing unit tests (in type theory programs can be run at typechecking time) without polluting the namespace. The following unnamed definition checks for instance the result of applying addition defined on natural numbers to 2 and 3.

[Appendix.tex]unittest

a.4. Implicit Variable Generalisation

Agda supports the implicit generalisation of variables appearing in type signatures. Every time a seemingly unbound variable is used, the reader can safely assume that it was declared by us as being a variable Agda should automatically introduce. These variables are bound using an implicit prenex universal quantifier. Haskell, OCaml, and Idris behave similarly with respect to unbound type variables.

In the type of the following definition for instance, A and B are two Sets of respective universe levels a and b (see Appendix A.2) and x and y are two values of type A. All of these variables have been introduced using this implicit generalisation mechanism.

[StateOfTheArt.tex]cong

If we had not relied on the implicit generalisation mechanism, we would have needed to write the following verbose type declaration.

[Appendix.tex]congtype

This mechanism can also be used when defining an inductive family. In Section 3.1, we introduced the predicate lifting All in the following manner. The careful reader will have noticed a number of unbound names: a, A, p in the declaration of the type constructor and x and xs in the declaration of the data constructor _::_.

[StateOfTheArt.tex]all

This definition corresponds internally to the following expanded version (modulo the order in which the variables have been generalised over).

[Appendix.tex]all

Acknowledgements

We would like to thank the reviewers for their helpful comments and their suggestions to discuss parametricity as a derivation principle, and to add an appendix to make the paper accessible to a wider audience.

The research leading to these results has received funding from EPSRC grant EP/M016951/1.

References