Replies: 1 comment 1 reply
-
"Dependent types" is a term I shy away from because (a) there are many flavours of thing and not everyone agrees on which flavours do count as "dependent types" or don't; (b) some of those flavours are "truly scary" in the sense of introducing undecidability, etc. etc. That said, I would agree that the Hugr type system is in the space of things that some people sometimes call "dependent types" ;-), but avoids any of the scary issues around decidability etc. by, as you note, all "evaluation" being pure substitution with strong normalization (no infinite loops etc.); and then we have specific, well-constrained, "backdoors" (e.g. binary Indeed, I think all of your examples written in hypothetical syntax, can be written in Rust using structs such as PolyFuncType.
That the system can be represented by a single thing called So if the question here is, shall we use this simpler combined enum for hugr-model, I'm neutral on that (which is to say, ok if you prefer!). If you are arguing we should switch over to something more like the flat representation in Tierkreis, then so far I'm not keen myself - to me the rules seem much less obvious. This might be overcome with documentation, but then, confusion with the present system might be overcome with documentation, too. (FWIW I suspect that the difference is that for the "simple single struct" realization, documentation is a description of what is allowed, whereas for the explicit-in-Rust-type-system realization, documentation is "where do I find X"...) |
Beta Was this translation helpful? Give feedback.
-
I have been meditating on the type system and the role of
hugr-core
'sType
,TypeArg
andTypeParam
.We are essentially implementing two programming languages:
one for runtime, which is described by the computation graph,
and a static meta language that is encoded into types, type args, operation definitions and type definitions.
We are essentially using a very restricted form of dependent types.
This sounds scary at first, but the language is restricted enough that the result is quite simple.
In this note I want to explore how hugr would look like from that perspective.
Operation Parameters via Dependent Functions
An operation without static parameters or child nodes is a value in the meta language and has type
(op INS OUTS)
whereINS
andOUTS
are lists of types for the operation's input and output ports.When the operation has a parameter, it is described by a function in the meta language that takes the parameter as an argument and produces a value of type
(op INS OUTS)
.Here the type of the input and output ports may depend on the value of the parameter;
therefore the function in the meta language is a dependent function.
For example, the type of an operation which takes a statically sized vector of qubits and reverses it, could be expressed in some hypothetical syntax as follows:
Here
!n
is the first argument to the dependent function which represents the statically known size of the qubit vector.If we wanted to make this reverse operation generic in the type of elements in the vector,
we can take the element type as another parameter of type
type
:Nesting is Parameterisation over Operations
We could therefore give a conditional operation the following type:
The
!then
and!else
parameters are the two branches of the conditional, which are passed in as child nodes of the conditional operation.The input and output ports of the two branches have the same types, given by the type lists
!ins
and!outs
.The result is an operation with the same input and output ports, except for an additional boolean input that controls which branch is taken by the conditional.
Note that in this example the type of the
!then
and!else
parameters depends on the value of the earlier!ins
and!outs
parameters.Another example of a nested operation would be an operation that prepares a vector of statically known size by running a subgraph multiple times:
Data Types are Values
The type
qubit
of qubits is itself a value of typetype
.This allows us to pass in
qubit
as a type parameter for the repeated preparation operation above.Type constructors such as
vec
become meta language functions that return a type:We interpret this type as follows: given any type
!a
and any natural!n
we obtain a type(vec !a !n)
of vectors that contain!n
many values of type!a
.As another example, an extension might want to define a tensor type with explicit dimensions such as
(tensor f32 [512 128 128])
. This tensor type would be described via the type:We therefore reuse the same mechanism for extensions defining custom types and operations.
Aren't Dependent Types hard?
The meta language functions are uninterpreted functions and so type checking does not need any normalisation step:
You do not need to "run" programs in the meta language, apart from unwrapping aliases.
In particular, a value of type
u64
really is just an integer and not a compile time program that evaluates to an integer.The Data Type
Dependent types mix the value and type language.
We can therefore consolidate meta language values and types into a single Rust data type:
Terms do not contain any binders. Instead we have a non-recursive type of meta language functions:
A Case against More Sophisticated Encodings
There is something weird about the
Term
type above: We know that aListType
must alwaystake a term that is a type. So
Term::ListType(Box::new(Term::NatType))
is valid, whileTerm::ListType(Box::new(Term::Nat(42)))
is not. Similarly, we know thatProduct
must alwayscontain a list:
In the spirit of making invalid terms unrepresentable, it is very tempting to make this visible in
the Rust type for
Term
, as is done in the current implementation of types inhugr-core
. I wantto argue against doing this, at least on the boundary of the system, for the sake of simplicity.
It took me quite some time to understand the type system of
hugr-core
and my attempts to createa serialisation format for it have been bogged down by frustration with the sheer number of
structs, enums and indirections involved. I am really sympathetic with the motivation behind this,
but think it's ultimately not pragmatic.
In the absence of structural invariants, we need type checking in order to verify that terms are well-formed.
However, type checking is required anyway: we need to check that the items of a list have the same type,
that
Term::Apply
nodes are passed arguments of the correct type, that a variable is not used in two positionswith a different type, etc.
For the sake of this discussion, I want to mostly ignore efficiency concerns in the encoding, such as
deduplication, hash consing, sharing, tabling, etc. Those things are important, but should
be discussed separately. However I want to note that a simple encoding of terms like the one above
makes it much easier to experiment with different implementation strategies. Do we want sharing
with reference counting? Just replace
Box<...>
withArc<...>
andVec<...>
withArc<[...]>
.Do we want to implement sharing via a table? Just replace each occurance of
Term
with an index intothe table. Then cached information can be attached to the indices as well (compilers are databases),
instead of weaving it into the types themselves.
Inferred Parameters
Since types are just values, we have been able to blur the line between a type parameter of a polymorphic operation
and a value parameter of a parametric operation. As a consequence, type parameters are explicit.
For a compiler IR this might be okay; for example MLIR needs all types of polymorphic operations to be specified
explicitly at use. If we want to avoid this, we could provide a way to designate some parameters as implicit,
allowing use sites to omit them.
Constraints
Work in Progress
Type constraints can be integrated very nicely into this system, and can replace the current
TypeBound
system.I will expand on this shortly, but wanted to get this idea out early to gather some comments.
Beta Was this translation helpful? Give feedback.
All reactions