Review of "Simple Essence of Algebraic Subtyping"
MLsub has been picked up again. Lionel Parreaux wrote a paper that I'm examining in this post. There's also a blog, Considerations on Codecrafting, writing a blogpost series on subtype inference Parreaux's paper is [v1.8] at the time of the review.
Stephen Dolan's thesis
I talk a bit about Stephen Dolan's "Algebraic Subtyping" thesis first, to give some context.
- His thesis introduces type inference to an extensible subtyping system.
- You can introduce new types without turning previously typeable programs untypeable.
- The results are achieved by never relying on non-existence of weird types and putting the "algebra before syntax"
Find the simplest algebra of types, and some syntax for them. Reads in the paper.
Influence to my work
Back when Dolan published I was impressed by the online demo and the introduction of the thesis. MLsub's type system matched better to how I was used to thinking about types at the time when I was still writing Python.
I thought type inference would let me produce type information to the language and then use that to target platforms that are not available for a JIT-equipped interpreter, platforms such as graphics accelerators and microcontrollers.
I studied to get the compiling to work well and to build a better implementation for my programming language, learned more about logic programming, dependent type theory. It eventually made me to realise that what I was building with Lever programming language was not as useful as it could be. Realising this I put my programming language project to ice and moved on. At that point my interest to MLsub waned.
Lionel Parreaux's paper
I appreciate the effort to produce a simpler subtyping inference algorithm. In this post I'm going to try implement it in a different language and see if I understand how it works. The paper's language is Scala and I'd like to see the implementation in Haskell.
I need to comment about few things in the paper first.
Practical usefulness of extensibility
In the 2.1. "Background on Algebraic Subtyping" Parreaux mentions that the practical usefulness of extensibility is unclear. In the footnote he agrees it simplifies the subtyping but also says practical programming languages already have extensible type systems by their design.
I'd think the extensibility does matter. If you create a new type that combines properties of the previous type, then it'd be great to be certain that it doesn't mess up structures that are already there and unrelated to it. This is achieved by not assuming inexistence of any potential type, which is a good principle in such subtyped environment anyway.
Subtyping effects on correctness
Paper section 2.3.3. "Typing Surprises" cued me to try a thing and I found out this:
let pum = fun x -> x xlet kaput = pum pumval pum: 'a ∧ ('a -> 'b) -> 'bval kaput: ⊥
Algebraically subtyped lambda calculus would seem to correspond to inconsistent logic.
If we're thinking about what the types mean here,
- the
⊤is a type that can be constructed from anything because it is discarded and not used at all. - The
⊥is a type that cannot be constructed because it can be used in every way.
The above means that we can interpret a fixed-point combinator
as a ⊥ and therefore we've found a contradiction in the corresponding logic.
This should help understanding what you are working with here. If you need termination proofs/proof content, I'd believe there are workarounds to obtaining them.
Implementation in Haskell
{-# LANGUAGE TypeFamilies,TypeSynonymInstances,ScopedTypeVariables,DeriveFunctor,DeriveTraversable #-}module SimpleSub whereimport Control.Monad.ST (ST, runST)import Data.STRef (STRef, newSTRef, readSTRef, writeSTRef)
I'm implementing the terms with De-bruijn indexing.
data Term= Lit Integer| Var Integer| Lam Term| App Term Term| Rcd [(String, Term)]| Sel Term String| Let { isRec :: Bool, rhs :: Term, body :: Term }deriving (Show)
To implement the rest, I need to create a monad that captures memoization. The constraining function in the algorithm doesn't work without it. While I'm at it, lets use this monad to track state variables and catch errors from the computation.
class Monad m => MuscularMonad m wheretype PrimState mliftST :: ST (PrimState m) a -> m amemoize :: (SimpleType m -> SimpleType m -> m ())-> SimpleType m -> SimpleType m -> m ()err :: Error m -> m z
There are two kind of errors this system can produce.
type Error m = BareError (SimpleType m)data BareError ty= MissingField String| ConstraintViolation ty tyderiving (Functor, Traversable, Foldable, Show)
I see that this resembles a technique I remember. It separated constraint generation from constraint resolution. It was described in Bastiaan Heeren's paper "Generalizing Hindley-Milner Type Inference Algorithms" [pdf]. Though, instead of this we're separating simplification and constraint propagation. The first part propagates the constraints.
type SimpleType m = SimpleTypeST (PrimState m)data SimpleTypeST s= Variable (STRef s (VariableStateST s))| Primitive String| Function (SimpleTypeST s) (SimpleTypeST s)| Record [(String, SimpleTypeST s)]deriving (Eq)type VariableState m = VariableStateST (PrimState m)type VariableStateST s = ([SimpleTypeST s], [SimpleTypeST s])type Ctx m = [SimpleType m]
The weird-looking type aliases are there for convenience. There's a moment when the monad need to be "run" which requires that we "detach" the structure from that monad.
Next there's the basic type inference. This is precisely what you could expect in Hindley-Milner algorithm's implementation as well.
typeTerm :: (MuscularMonad m)=> Ctx m -> Term-> m (SimpleType m)typeTerm ctx (Lit n) = pure (Primitive "int")typeTerm ctx (Var i) = pure (ctx !! i)typeTerm ctx (Rcd fs) = Record <$> mapM (typeRecordTerm ctx) fstypeTerm ctx (Lam body) = doa <- freshVarFunction a <$> typeTerm (a:ctx) bodytypeTerm ctx (App f x) = doa <- freshVart' <- typeTerm ctx fu' <- Function <$> typeTerm ctx x <*> pure aconstrain t' u'pure atypeTerm ctx (Sel term name) = doa <- freshVart' <- typeTerm ctx termconstrain t' (Record [(name, a)])pure atypeRecordTerm :: (MuscularMonad m)=> Ctx m -> (t, Term) -> m (t, SimpleType m)typeRecordTerm ctx (name, term)= fmap (\t -> (name, t)) (typeTerm ctx term)
The constraining is a memoized function. This thing produces a constrain that ensures the thing on the left side passes as the thing on the right side.
constrain :: (MuscularMonad m)=> SimpleType m -> SimpleType m-> m ()constrain = memoize constrain'whereconstrain' (Primitive n0) (Primitive n1) | (n0 == n1) = pure ()constrain' (Function l0 r0) (Function l1 r1)= constrain l1 l0 >> constrain r0 r1constrain' (Record fs0) (Record fs1)= mapM_ (constrainRecord fs0) fs1constrain' (Variable ref) rhs = do(lob, upb) <- liftST (readSTRef ref)mapM_ (\a -> constrain a rhs) lobliftST (writeSTRef ref (lob, rhs : upb))constrain' lhs (Variable ref) = do(lob, upb) <- liftST (readSTRef ref)mapM_ (\a -> constrain lhs a) upbliftST (writeSTRef ref (lhs : lob, upb))constrain' x y = err (ConstraintViolation x y)constrainRecord :: (MuscularMonad m)=> [(String, SimpleType m)] -> (String, SimpleType m)-> m ()constrainRecord fs0 (n1, t1) = docase lookup n1 fs0 ofNothing -> err (MissingField n1)Just t0 -> constrain t0 t1freshVar :: MuscularMonad m => m (SimpleType m)freshVar = liftST (Variable <$> newSTRef ([], []))
To use this things, we need to flex some muscle and get ourselves an instance of the monad that we described. Plenty of type trickery that perhaps would have been better with monad transformers.
newtype Muscle s a = Muscle((STRef s [(SimpleType (Muscle s), SimpleType (Muscle s))])-> (ST s (Either (Error (Muscle s)) a)))unmusc :: Muscle s a-> ((STRef s [(SimpleType (Muscle s), SimpleType (Muscle s))])-> ST s (Either (Error (Muscle s)) a))unmusc (Muscle x) = xinstance Functor (Muscle s) wherefmap f (Muscle x) = Muscle (fmap (fmap (fmap f)) x)instance Applicative (Muscle s) wherepure x = Muscle (pure (pure (pure x)))Muscle f <*> Muscle x = Muscle ((fmap ((<*>) . fmap (<*>)) f) <*> x)instance Monad (Muscle s) whereMuscle m >>= f = Muscle (\q -> m q >>= crunch (\x -> (unmusc . f) x q))wherecrunch :: (t -> ST s (Either a b)) -> Either a t -> ST s (Either a b)crunch f (Left a) = pure (Left a)crunch f (Right a) = f ainstance MuscularMonad (Muscle s) wheretype PrimState (Muscle s) = sliftST st = Muscle (\q -> fmap pure st)memoize fn x y = Muscle (\q -> doqv <- readSTRef qif elem (x,y) qvthen pure (pure ())else dowriteSTRef q ((x,y):qv)unmusc (fn x y) q)err msg = Muscle (pure (pure (Left msg)))runMusc :: Muscle s a-> [(SimpleType (Muscle s), SimpleType (Muscle s))]-> ST s (Either (BareError (SimpleTypeST s)) a)runMusc m x = newSTRef x >>= unmusc m
Our memoization function here isn't particularly excellent. It's updating the ST-variable and compares each structure to see whether they're equal. This should be sufficient though. Now that we got some muscle, it's time to look at the user-facing type representation.
User-facing representations of types
The process of retrieving the type seems a bit taxing.
Although the author tested his code and ran an experimental evaluation with millions of randomly generate expressions, I'm a bit concerned, is the use of 'recursive' -map correct in this or the paper's algorithm?
The complexity of MLsub's implementation may have contributed as motivation for me to look into formal verification. Those difficult parts seem to have been distilled here.
data Type= Top| Bot| Meet Type Type| Join Type Type| FunctionType Type Type| RecordType [(String,Type)]| RecursiveType Int Type -- No longer de-bruijn indices| TypeVariable Int -- Just ordinary labels| PrimitiveType Stringderiving (Show)type PolarVariable s = (STRef s (VariableStateST s), Bool)coalesceType :: SimpleTypeST s -> ST s TypecoalesceType (ty :: SimpleTypeST s) = dolazyVariables <- newSTRef []recursive <- newSTRef []nextName <- newSTRef 0let freshName :: ST s IntfreshName = donn <- readSTRef nextNamewriteSTRef nextName (nn+1)pure nncheckRecursive :: PolarVariable s -> ST s (Maybe Int)checkRecursive vs_pol = dor <- readSTRef recursivepure (lookup vs_pol r)checkAndUpdateRecursive :: PolarVariable s -> ST s IntcheckAndUpdateRecursive vs_pol = dor <- readSTRef recursivecase lookup vs_pol r ofNothing -> doi <- freshNamewriteSTRef recursive ((vs_pol,i):r)pure iJust a -> pure a-- This is the part that I missed at first.getVariable :: STRef s (VariableStateST s) -> ST s TypegetVariable vs = dov <- readSTRef lazyVariablescase lookup vs v ofNothing -> dot <- TypeVariable <$> freshNamewriteSTRef lazyVariables ((vs,t):v)pure tJust t -> pure tgo :: SimpleTypeST s -> Bool -> [PolarVariable s] -> ST s Typego (Primitive n) _ _ = pure (PrimitiveType n)go (Function l r) polar inProcess = FunctionType<$> go l (not polar) inProcess<*> go r polar inProcessgo (Record fs) polar inProcess = doRecordType <$> mapM (\(n,t) -> dot' <- go t polar inProcesspure (n,t')) fsgo (Variable vs) polar inProcess = dolet vs_pol = (vs, polar)if elem vs_pol inProcessthen doTypeVariable <$> checkAndUpdateRecursive vs_polelse do(lb,ub) <- readSTRef vslet bounds = if polar then lb else ubboundTypes <- mapM(\m -> go m polar (vs_pol:inProcess))boundslet mrg = if polar then Join else Meetres <- (\t -> foldl mrg t boundTypes) <$> getVariable vsrc <- checkRecursive vs_polcase rc ofNothing -> dopure resJust a -> dopure (RecursiveType a res)go ty True []
Since the ST type variable need to be lugged around I had to enable scoped typed variables to get that variable up there. The annotations for those let-clauses make it a bit easier to read the whole program.
Ok, that should do it. I still give you the function to run the full algorithm.
runTypeTerm :: Term -> Either (BareError Type) TyperunTypeTerm term = runST $ doresult <- runMusc (typeTerm [] term) []case result ofLeft error -> Left <$> mapM coalesceType errorRight t -> Right <$> coalesceType t
Lets try it and see if we succeeded in implementing this
Parreaux demonstrates how his algorithm works with the church numeral 2. It's a nice thing to try so lets do the same here.
λf.λx.f (f x)I convert them into the corresponding structures.
church_numeral_2 :: Termchurch_numeral_2 = Lam (Lam (App (Var 1) (App (Var 1) (Var 0))))
I get some result.
> runTypeTerm church_numeral_2Right (FunctionType(Meet (FunctionType (TypeVariable 0) (TypeVariable 1))(FunctionType (TypeVariable 2) (TypeVariable 3)))(FunctionType (TypeVariable 4) (TypeVariable 5)))
2020-08-11: Corrected algorithm run:
> runTypeTerm church_numeral_2Right (FunctionType(Meet (Meet (TypeVariable 3)(FunctionType (TypeVariable 0) (TypeVariable 1)))(FunctionType (TypeVariable 2) (TypeVariable 0)))(FunctionType (TypeVariable 2) (TypeVariable 1)))
I was surprised by the duplicates at first but this seem to be near the result in the paper. The type is further compacted by observing where the variables appear in the structure. The paper tells how it's being done but does not provide the code.
I tried yet an another one.
> runTypeTerm (Lam (App (Var 0) (Var 0)))Right (FunctionType(FunctionType (TypeVariable 0) (TypeVariable 1))(TypeVariable 2))
2020-08-11: Corrected algorithm run:
> runTypeTerm (Lam (App (Var 0) (Var 0)))Right (FunctionType(Meet (TypeVariable 0)(FunctionType (TypeVariable 0) (TypeVariable 1)))(TypeVariable 1))
I'm a bit confused by that result. I'm not sure whether my implementation is correct.
The full program & conclusions
You can download the whole source code here: SimpleSub.hs
A whole lot of juggling with state is present in Scala implementation. It may be short code but holds a similar logical complexity that was present in Stephen Dolan's original algorithm. I remember the similar recursive implementation with a lot of mutable references.
2020-08-10: Parreaux answered that he liked the review. He told me that this:
res <- doif length boundTypes == 0then TypeVariable <$> freshNameelse pure (foldl1 mrg boundTypes)
Should likely be instead:
res <- (\t -> foldl mrg t boundTypes) <$> getVariable vsThe corresponding line in the scala implementation. I've fixed it into the implementation and re-ran the commands.
I thoughtlessly changed the program there without understanding it well enough. Parreaux explained me that this feature connects negative and positive occurrences of variables. That means the additional variable needs to be folded into the result.