POPL 2024 Artifact for When Subtyping Constraints Liberate: A Novel Approach to First-Class-Polymorphic Type Inference
This document follows the structure prescribed in https://popl24.sigplan.org/track/POPL-2024-artifact-evaluation.
Most claims in the paper have to do with reproducing particular examples in the test suite. For each such claim, you can check that the inferred types conform with what is shown in the paper, and you can play with the examples by modifying them directly in the test suite or just trying them out individually in the online web demo available at https://hkust-taco.github.io/superf/, which also contains a syntax reference.
All examples shown inline throughout the paper and appendices are reproduced in the test suite.
Some are in the shared/src/test/diff/fcp/Demo.mls
file, and others are in shared/src/test/diff/fcp/Paper.mls
.
The tables in Appendices B.4 and B.5 are reproduced in shared/src/test/diff/fcp/PaperTable.mls
.
Other tests from previous systems, which were not necessarily part of the corresponding papers themselves but which we gleaned from various sources, live in the shared/src/test/diff/fcp-lit
directory.
We have obtained the original test suite of MLF, graciously provided to us by the MLF authors.
These tests have been faithfully ported to SuperF and all live in the shared/src/test/diff/mlf-examples
folder.
The tests in folders shared/src/test/diff/fcp/
, shared/src/test/diff/fcp-lit/
, and shared/src/test/diff/mlf-examples/
are specifically meant to test first-class polymorphism (FCP) and usually use :NoRecursiveTypes
(see description of the command below).
Most tests in other folders predate the introduction of FCP in MLscript. Yet FCP type inference is still performed in these tests,
showing that the addition of FCP was smooth and did not break most existing tests (the main exception being tested involving variations of the Y combinator through unannotated self applications).
We have manually transcribed the OCaml List module in file shared/src/test/diff/fcp/OCamlList.mls
,
showing that we can type check that file without the help of any intermediate type annotations.
The code is in three parts:
- Specification of general OCaml definition signatures.
- Specification from OCaml's
List.mli
signature file. - Impelemntations from OCaml's
List.ml
.
We have built Docker images containing all necessary dependencies
compiled for both amd64
and arm64
platforms and pushed them to
DockerHub.
One can pull the amd64
image and launch a container with the following command:
docker pull mlscript/superf-docker:v0-amd64
docker run -it --rm mlscript/superf-docker:v0-amd64
If one wants to use the arm64
image, the commands are:
docker pull mlscript/superf-docker:v0-arm64
docker run -it --rm mlscript/superf-docker:v0-arm64
The user will be attached to the shell of the container after the image gets
pulled and the container is launched.
Please cd
to mlscript/
and launch the SBT shell by typing sbt
.
You need JDK supported by Scala, sbt, and Node.js to compile the project and run the tests.
We recommend you to install JDK and sbt
via coursier. The versions of Node.js that passed our tests are from v16.14 to v16.17, v17 and v18.
After installing the prerequisites, change your working directory to the root of this repository and
launch the SBT shell by typing sbt
in the terminal.
-
The
shared/src/main/scala/mlscript
directory contains the sources of the MLscript compiler. -
The
shared/src/test/scala/mlscript
directory contains the sources of the testing infrastructure. -
The
shared/src/test/diff
directory contains the actual tests.
Running the main MLscript tests only requires the Scala Build Tool installed.
In the SBT shell, run mlscriptJVM/test
.
You can also run all tests simultaneously.
In the SBT shell, run test
.
Test output is inserted in place in the test file after each corresponding block, as comments beginning with //│
.
This makes it very easy and convenient to see the test results for each code block.
For this reason, we recommend using an editor that automatically reloads open files on changes. VSCode and Sublime Text work well for this.
To validate each of our claims, please refer to the corresponding MLscript test sources and check the inferred types with those shown in the paper. Note that the syntax of the inferred types of our implementation is slightly different than that in the paper. Please refer to the following "Notes on inferred type syntax".
The testing infrastructure is set up so that if there are any unstaged changes (as determined by git
) in any test file
(those in shared/src/test/diff
), only the corresponding files will be tested.
So you can make select modifications to some test files and run the test command again,
and it will only run your modified tests.
You can also run the tests continuously using the SBT command ~mlscriptJVM/testOnly mlscript.DiffTests
,
which will watch for any changes in the project and re-run the tests automatically.
anything
is the top typenothing
is the bottom type?
, as inList[?]
, is a shorthand forList['a]
for some fresh'a
quantified at the top-level- The notation
??x
, which sometimes appears in examples that leak skolems, is really just a way of keeping track of extrusions in order to better pretty-print infer type and to better report type errors, using type variable names that are more informative than ⊤ and ⊥. The syntax can be understood as attaching to those ⊤ and ⊥ introduced during extrusion the names of the corresponding extruded type variables as metadata.
When a type signature was declared before and an implementation is provided, our compiler infers the implementation's type first, and then checks that type against the signature, which leads to an output of the following form:
def idInt : int -> int
//│ idInt: int -> int
def idInt x = x
//│ 'a -> 'a
//│ <: idInt:
//│ int -> int
Above, one can see that the inferred type is 'a -> 'a
(i.e., forall 'a. a -> 'a
), and this is successfully checked against the provided type signature int -> int
.
Individual tests can be run with -z
.
For example, ~mlscriptJVM/testOnly mlscript.DiffTests -- -z parser
will watch for file changes and continuously run all parser tests (those that have "parser" in their name).
To run the demo on your computer, compile the project with SBT command fastOptJS
, then open the local_testing.html
file in your browser.
You can make changes to the type inference code
in shared/src/main/scala/mlscript
,
have it compile to JavaScript on file change with command
~fastOptJS
,
and immediately see the results in your browser by refreshing the page with F5
.
We now describe at a high level how to understand the MLscript codebase.
In this artifact, we provide a fully practical implementation of SuperF. The provided implementation is complex because it is intertwined with MLscript, a nascent functional programming language intended for real-world usage. While this has the advantage of demonstrating that our approach scales to realistic use cases and is practical for general-purpose programming, it means one needs to squint quite a bit to relate the provided implementation with the approach described in the paper.
We have reproduced here the main differences between the paper's formalism and our implementation, as stated in Appendix B:
- MLscriptF supports type-checking recursive functions, which is not shown in Section 4 for brevity, as it is very straightforward: we simply assign the recursive definition a fresh type variable, bind that type variable to the definition’s name while typing the definition’s body, and then constrain the variable to be a supertype of the inferred body type.
- MLscriptF does not generalize curried lambda abstractions: doing so is redundant since distributivity can always be used to push polymorphic types back inside arrow types when needed. A slightly more subtle point is that we do not want to incur too much polymorphism in the presence of recursive terms, as that could lead to spurious SRLC errors.22 Refraining from generalizing curried functions is a simple way to avoid this problem.
- MLscriptF uses explicit polymorphism levels to track extrusion and type avoidance, as described in Section 2.4. This can be seen as an optimization (to minimize the number of type traversals) that does not affect the functional properties of the system.
In addition to that, by default MLscript supports recursive types, but these are disabled in the tests relevant to this paper (because the system presented in our paper does not support them), using the :NoRecursiveTypes
top-level command in the corresponding test files.
Also take note of the :DistributeForalls
(on by default) and :DontDistributeForalls
commands which enable and disable distributivity of polymorphic types over function types.
The most important algorithms to look at which (loosely) mirror what happens in the paper are:
- the
typeTerm
function ofTyper.scala
, which shows how to type check terms; - the
constrain
function ofConstraintSolver.scala
, which shows how to resolve subtyping constraints (while also keeping track of a lot of meta information for better error reporting); freshenAbove
inConstraintSolver.scala
, which instantiates a polymorphic type by duplicating all its quantified type variables (those with a level higher than the quantification level).extrudeAndCheck
inConstraintSolver.scala
, which performs extrusion of types with mismatched polymorphism level, creating approximants and transforming leaky skolems into the equivalent of Top and Bottom (using theExtruded
data type).
A major concept that you will encounter repeatedly if you look into the MLscript's type checker implementation but which is absent from the paper is that of normal forms (defined in NormalForms.scala
).
These are used for two things:
- For type simplification before showing inferred types to users. Our submission does not give specific details on how we simplify types because it is by and large similar to how it was done in previous work
- For constraint-solving involving non-polar lattice types (ie unions in negative positions and intersections in positive positions) – subfunctions
goToWork
,constrainDNF
, andannoying
inConstraintSolver.scala
are the ones that are invoked in these cases. Such constraints never come up on their own if one restricts themselves to the polar type annotation syntax proposed in the paper. Indeed, only unions in positive positions and intersections in negative positions naturally arise from type inference. Therefore, normal-form constraint solving can be safely ignored while reviewing this artifact with SuperF in mind.
In the interest of transparency, and to maximize the usefulness and reusability of our artifact, we have tagged, in comments, some of the test cases with notable characteristics. The following can easily be grepped in the test folder:
[FCP-LIM]
is used to indicate a limitation of our FCP implementation relative to MLF. The only current uses of this tag are for (1) programs that require generalization at the level of arguments (which is disabled by default but can be enabled with:GeneralizeArguments
) – see fileshared/src/test/diff/mlf-examples/ex_predicative.mls
– and (2) programs where unions are used in annotations, which is not really supported by SuperF, as explained in the paper – see fileshared/src/test/diff/mlf-examples/ex_shallow.mls
.[FCP-IMPROV]
shows some notable places where we significantly improve on MLF – but these are by no means meant to be exhaustive nor representative (indeed, many improvements are not explicitly tagged).[FCP:patho]
is used to tag two known pathological cases exposed in the test suite – in both cases, these correspond to programs that are fully unannotated (and thus would simply not work in the existing approach) and involve complicated higher-order encodings not really representative of real-world programming patterns.
This codebase has all the basic components
of a static-typed programming language compiler: lexer, parser, typer, and code generator.
We now give a high-level introduction to each compiler component and how it corresponds to
what is described in the paper.
Note that the source files we describe here are all rooted in /shared/src/main/scala/mlscript
.
The lexer accepts source strings and returns tokens to be parsed. The corresponding files are:
Lexer.scala
contains the lexer class.Token.scala
contains the token data types.
The parser accepts tokens generated by the lexer and returns an abstract syntax tree of the input program in the surface syntax. The corresponding files are:
Parser.scala
contains the parser class.syntax.scala
contains the surface syntax data types of the language.
The typer accepts an abstract syntax tree of a program and performs type checking. MLscript's typer supports principal type inference with subtyping. For more information about the type system, please refer to MLstruct.
The corresponding files are:
Typer.scala
contains the typer class.TypeSimplifier.scala
contains type simplification algorithms to simplify inferred types.ucs/Desugarer.scala
contains classucs.Desugarer
which implements desugaring methods.TypeDefs.scala
andNuTypeDefs.scala
contain classTypeDef
and methods for declarations like classes, interfaces, and type aliases.ConstraitSolver.scala
contains classConstraintSolver
which solves subtyping constraints.NormalForms.scala
contains classNormalForms
which provides the infrastructure to solve tricky subtyping constraints with disjunct normal forms (DNF) on the left and conjunct normal forms (CNF) on the right.TyperDatatypes.scala
contains classTyperDatatypes
which includes data types to support internal representation of types with mutable states to support type inference with subtyping.TyperHelpers.scala
contains classTyperHelpers
that provides helper methods for the typer.
Note that the inheritance relationships between these typer classes do not have any actual semantics
- we are following Scala's Cake Pattern. Typer classes will be finally composed
into the
Typer
class by inheritance.
The code generator translates MLscript AST into JavaScript AST and generates the corresponding JavaScript code.
The corresponding files are:
codegen/Codegen.scala
contains definitions of JavaScript AST nodes and methods for JavaScript code generation.codegen/Scope.scala
contains classScope
which manages symbols and provides hygienic runtime name generation.codegen/Symbol.scala
contains classesNewClassSymbol
,MixinSymbol
, andModuleSymbol
which include information onclass
,mixin
andmodule
definitions.JSBackend.scala
contains classJSBackend
that translates an MLscript AST into a JavaScript AST. ClassesJSWebBackend
andJSTestBackend
inherit classJSBackend
and generate adapted code for the web demo and the test suite.
Testing of MLscript works as follows:
- the MLscript compiler reads the given test file one code block at a time (code blocks are separated by empty lines);
- after reading the code block, it outputs the inferred types as well as any type errors encountered;
- after that, it executes the code block in NodeJS (by shelling out to a
node
process) and outputs the results.
We have a web demo for users to test our implementation in any modern browser.
It has a textbox for MLscript source code input and it produces typing and running
results live. The implementation can be tried online at https://hkust-taco.github.io/superf/
and locally in /js/src/main/scala/Main.scala
.
We have a "diff
-based" test suite for our implementation.
It detects changes to MLscript test sources (using git),
generates typing and running results, and inserts those results
into test sources. The diff-based testing implementation is in
/shared/src/test/scala/mlscript/DiffTests.scala
.
MLscript test sources are in /shared/src/test/diff
.
We now introduce the implementation of each compiler component in more detail.
File syntax.scala
contains immutable surface syntax data types of MLscript,
which are different from the internal representations in the typer for later type inference.
Here we introduce several surface syntax data types:
- Classes
Decl
,TypeDef
,MethodDef
are deprecated. - Class
TypeDefKind
includes type definition kinds: classes and mixins, etc. - Class
Term
includes MLscript term data types. Case classBind
is no longer used. Case classSplc
is for the rest of a parameter list, similar to the rest parameter in JavaScript. Case classesForall
andInst
are for first-class polymorphism. - Class
IfBody
includes if-then-else structure data types. - Class
CaseBranches
includes case branch data types for MLscript pattern matching. - Class
TypeLike
includesType
,Signature
, andNuDecl
. - Class
Type
includes MLscript type data types. Case classRem
is for record member removal. Case classWithExtension
is for record type extension. For example,A with {x : int}
is equivalent toA\x & {x : int}
. - Class
TypeVar
represents the type variable. Its identifier can be anInt
generated internally by the compiler orStr
specified by the user. - Class
NuTypeDef
is aNuDecl
for type definitions. Note that it has optionalsuperAnnot
andthisAnnot
for precisely-typed open recursion. - Class
NuFunDef
is aNuDecl
for function and let-bindings.
The MLscript typer (class Typer
) works with a typing context (class Ctx
) which
mainly maintains all global and local bindings of names to their types.
The typer accepts a typing unit from the parser, types the typing unit, and returns a typed typing unit.
The typed typing unit
is sent to the type simplifier and is finally expanded, i.e., converted
back to types in the surface syntax for presentation.
The typer has internal representations of types
(defined in TyperDatatypes.scala
)
with mutable states for type inference with subtyping.
We first introduce several typer data types defined in TyperDatatypes.scala
:
- Class
TypeProvenance
stores the location where a type is introduced. - Class
LazyTypeInfo
is for type definitions including classes, mixins, modules. Its type is lazily computed to support mutual recursive type definitions. It has acomplete
method to complete typing lazily typed definitions. - Class
PolymorphicType
represents a type with universally quantified type variables. By convention, in the type body, type variables of levels greater than the polymorphic type's level are polymorphic. - Class
SimpleType
is a general type form of all types. It requires a methodlevel
for level-based polymorphism. - Class
BaseType
includes base types such as function, array, tuple, and class tag types. It can later be refined byRecordType
. - Class
RecordType
is a record type. It has a list of bindings from record fields to their types. - Class
SpliceType
is not used for now. - Class
ProxyType
is a derived type form to store more type provenance information. - Class
TypeRef
is a reference to named types such as type definitions like classes. It has a list of type arguments. A type reference with type arguments is expanded to a class tag type with the class's type parameters refined by the corresponding type arguments as type members. For example,Foo[Int]
is expanded to#Foo & {Foo.A: int..int}
. - Class
TypeTag
has different kinds of type tags including class tags and abstract tags, etc. - Class
FieldType
represents a term field type or a type member. When it represents a term field type,lb
represents if the type is mutable. Otherwise,lb
is the lower bound of the type member. - Class
TypeVariable
represents a type variable, which has upper and lower bounds for type inference with subtyping.
Method typeTypingUnit
in class NuTypeDefs
accepts the typing unit to type. It inspects each statement
in the typing unit. If the statement is a type definition, a DelayedTypeInfo
(which is a subclass of LazyTypeInfo
)
is produced and stored in the typing context (note the typing context only uses tyDefs2
to store
type definitions). Otherwise, it desugars the statement and calls typeTerms
to type
the desugared statements. For a single Term
, it is passed to typeTerm
to type.
Method typeTerm
in class Typer
types a term. If the term needs type information of a LazyTypeInfo
,
the typing of that lazily typed definition will be completed. Subtyping constraints are generated during typing
and sent to ConstraintSolver
to propagate constraints to type variables.
For more about type inference of subtyping, please refer to MLstruct.