Typechecking for Classical Linear Logic sequents

This post contains a type checker for classical linear logic sequents, written in Haskell.

Lets start by encoding types.

data Ty
    = Terminal | Initial | TensorUnit | ParUnit
    | Prod Ty Ty | Sum Ty Ty | Tensor Ty Ty | Par Ty Ty
    | OfCourse Ty | WhyNot Ty
    | Pos Int | Neg Int
    deriving (Show, Eq)

These types can be grouped into additives, multiplicatives and exponentials. The main property of this type system is that every type has a de-morgan dual.

dual :: Ty -> Ty
dual Terminal = Initial
dual TensorUnit = ParUnit
dual (Prod x y) = Sum (dual x) (dual y)
dual (Sum x y) = Prod (dual x) (dual y)
dual (Tensor x y) = Par (dual x) (dual y)
dual (Par x y) = Tensor (dual x) (dual y)
dual (OfCourse x) = WhyNot (dual x)
dual (WhyNot x) = OfCourse (dual x)
dual (Pos i) = (Neg i)
dual (Neg i) = (Pos i)

The duality extends to sequents as well. If you have a sequent that is a proof for some proposition, then it's a refutation for the dual proposition.

polarity :: Ty -> Bool
polarity Initial      = True
polarity TensorUnit   = True
polarity (Prod _ _)   = True
polarity (Tensor _ _) = True
polarity (OfCourse _) = True
polarity (Pos _)      = True
polarity Terminal     = False
polarity ParUnit      = False
polarity (Sum _ _)    = False
polarity (Par _ _)    = False
polarity (WhyNot _)   = False
polarity (Neg _)      = False

Polarity isn't too important for typechecking, but it appears to be useful for evaluating terms.

Now we'd have to encode the CLL sequents as terms. It can help if you notice that these sequents have lot of redundant structure which means that some of them can slide past each other. This allows dividing inference steps to "positive" and "negative".

data Tm
    = Cut Ty Tm Tm
    | Pair Tm Tm
    | Opt Tm Tm
    | Exp Tm
    | Tt
    | Ax
    | Absurd Int
    | Sr [Tn] Tm
    deriving (Show, Eq)

data Tn
    = Drop
    | Dup Tn Tn
    | Inst Tn
    | Var Int
    | Ff
    | Inl Tn
    | Inr Tn
    | Split Tn Tn
    deriving (Show, Eq)

Positive terms introduce some structure or "control flow" whereas negative terms behave like signals.

I'm going to explain what these structures mean when introducing typechecking. For now notice that even although if you removed the "Cut" this structure would not represent a normal form because some terms here can still slide past each other.

Type checking

Every "Tm" represents a sequent with a context. Therefore these structures have an arity.

arity :: Tm -> Int
arity (Sr ctx _)  = length ctx
arity (Cut _ a b) = arity a + arity b - 2
arity (Pair a b)  = arity a + arity b - 1
arity (Opt a b)   = arity a
arity (Exp a)     = arity a
arity Tt = 1
arity Ax = 2
arity (Absurd n) = n

Arity is either fixed, or then it's a sum of the composed sequents. In Cut two variables are eliminated so the arity decreases by 2. In Pair two variables are combined, so arity decreases by 1. Opt arity is measured from left side and the result of this function is only correct if the term is valid.

Type checking takes a type for every variable in the context and decides whether a term forms a valid proof for the proposition that the type stands for.

check :: [Ty] -> Tm -> Bool
check env (Sr ctx a)
    = case infer_env env ctx (init_ctx (arity a)) >>= full of
        Just env' -> check env' a
        Nothing   -> False
check env (Cut ty a b) = let
    (env1, env2) = splitAt (arity a-1) env
    in check (ty:env1) a && check (dual ty:env2) b
check (Tensor x y:env) (Pair a b) = let
    (env1, env2) = splitAt (arity a-1) env
    in check (x:env1) a && check (y:env2) b
check (Prod x y:env) (Opt a b)  = check (x:env) a && check (y:env) b
check (OfCourse ty:env) (Exp a) = all is_whynot env && check (ty:env) a
check [TensorUnit] Tt           = True
check [t,v]        Ax = polarity v && (dual t == v)
check env (Absurd n)  = any terminal_present env && (length env == n)
check _   _           = False

infer_env :: [Ty] -> [Tn] -> [Maybe Ty] -> Maybe [Maybe Ty]
infer_env []       []     ctx = Just ctx
infer_env (ty:tys) (x:xs) ctx = infer ty x ctx >>= infer_env tys xs
infer_env _        _      _   = Nothing

infer :: Ty -> Tn -> [Maybe Ty] -> Maybe [Maybe Ty]
infer ty            (Var n)     ctx = edit_ctx n ty ctx
infer (WhyNot _)    Drop        ctx = Just ctx
infer ty@(WhyNot _) (Dup x y)   ctx = infer ty x ctx >>= infer ty y
infer (WhyNot ty)   (Inst x)    ctx = infer ty x ctx
infer ParUnit       Ff          ctx = Just ctx
infer (Sum a _)     (Inl x)     ctx = infer a x ctx
infer (Sum _ b)     (Inr y)     ctx = infer b y ctx
infer (Par a b)     (Split x y) ctx = infer a x ctx >>= infer b y
infer _             _           _   = Nothing

I threw that all out at once since it ended up being fairly short. Now explaining these structures.

The Drop,Dup,Inst provide counterparts for the Exp, Likewise Inl,Inr counterpart the Opt. Ff forms a par unit to counterpart the tensor unit. Likewise Split is counterpart for the Pair.

The context-handling ensures that variables are used up exactly once.

init_ctx :: Int -> [Maybe Ty]
init_ctx n = replicate n Nothing

edit_ctx :: Int -> Ty -> [Maybe Ty] -> Maybe [Maybe Ty]
edit_ctx 0 ty (Nothing:xs) = Just (Just ty:xs)
edit_ctx n ty (x:xs) = (x:) `fmap` edit_ctx (n-1) ty xs
edit_ctx _ _ _ = Nothing

full :: [Maybe a] -> Maybe [a]
full []          = Just []
full (Just x:xs) = (x:) `fmap` full xs
full _           = Nothing

The Absurd and Exp requires that the context is constrained in a certain way.

is_whynot :: Ty -> Bool
is_whynot (WhyNot _) = True
is_whynot _ = False

terminal_present :: Ty -> Bool
terminal_present Terminal = True
terminal_present (Opt a b) = terminal_present a || terminal_present b
terminal_present (Par a b) = terminal_present a || terminal_present b
terminal_present (WhyNot a) = terminal_present a
terminal_present _ = False

Now we should be able to typecheck some sequents. Lets try the SKI combinators.

i_type = [Par (OfCourse (Neg 0)) (WhyNot (Pos 0))]
i_prog = [Split (Var 0) (Var 1)] `Sr` Exp ([Var 0, Inst (Var 1)] `Sr` Ax)

k_type = [Par (Par (OfCourse (Neg 0)) (WhyNot (Pos 1))) (WhyNot (Pos 0))]
k_prog = [Split (Split (Var 0) (Var 1)) (Var 2)] `Sr` Exp
    ([Var 0, Drop, Inst (Var 1)] `Sr` Ax)

s_type = [Par (Par (Par (OfCourse (Neg 2))
    (WhyNot (Pos 0)))
    (WhyNot (Tensor (Pos 1) (Neg 0))))
    (WhyNot (Tensor (Tensor (Pos 2) (Neg 1)) (Neg 0)))]
s_prog = [Split (Split (Split (Var 0) (Var 1)) (Var 2)) (Var 3)] `Sr` Exp
    ([Var 1, Dup (Inst (Var 3)) (Inst (Var 4)), Inst (Var 2), Inst (Var 0)] `Sr` 
            (Pair ([Var 1, Var 0] `Sr` Ax)
                  ([Var 1, Var 0, Var 2] `Sr` Pair
                      ([Var 1, Var 0] `Sr` Ax)

Next it'd be time to consider how to normalise these structures.

Evaluation with MVars

There's a paper that implements interaction nets with haskell's MVars.

The evaluation involves translating from types into semantic values. We are building this kind of a communication "protocol" that passes mutable variables around and labels them.

data Sem = Nil | Cons (MVar Sem) (MVar Sem) | X (MVar Sem) | Y (MVar Sem)

We pick sequences of empty variables and treat them as the execution environment.

blank_env :: Int -> IO [MVar Sem]
blank_env n = mapM id (replicate n newEmptyMVar)

Evaluation takes a term and interprets it in the given environment. Positive terms become "actors" that interface through mutable variables. They are activated through eval.

eval :: Tm -> [MVar Sem] -> IO ()

Ax is like a schoolboy, what goes from one ear in goes out from another.

eval Ax [dst,src] = do
    forkIO (readMVar src >>= putMVar dst)
    pure ()

Tt is also like a schoolboy, well, in an another way. It's doing nothing.

eval Tt [src] = do
    forkIO (readMVar src >>= const (pure ()))
    pure ()

Sr sends negative terms as "messages".

eval (Sr ctx a) env = do
    env' <- plug_ctx ctx env (init_box (arity a))
    eval a env'

Put two schoolboys ear-to-ear and you can route information around. Thats what Cut is for.

eval (Cut _ a b) env = do
    let (env1, env2) = splitAt (arity a) env
    k <- newEmptyMVar
    eval a (k:env1)
    eval b (k:env2)

Pair splits a cake.

eval (Pair a b) (p:env) = do
    let (env1, env2) = splitAt (arity a) env
    forkIO $ do
        Cons p1 p2 <- readMVar p
        eval a (p1:env1)
        eval b (p2:env2)
    pure ()

Option selects a branch.

eval (Opt a b) (p:env) = do
    forkIO $ do
        i <- readMVar p
        case i of
            X x -> eval a (x:env)
            Y y -> eval b (y:env)
    pure ()

Exponents are things that are cheap to copy around.

eval (Exp a) (p:env) = do
    forkIO (exponent_impl p env)
    pure ()
        n = arity a
        exponent_impl q e = do
            i <- readMVar q
            case i of
                X x -> eval a (x:env)
                Cons x y -> do
                    e1 <- blank_env n
                    e2 <- blank_env n
                    mapM_ id (zipWith putMVar e (zipWith Cons e1 e2))
                    exponent_impl x e1
                    exponent_impl y e2
                Nil -> mapM_ (\m -> putMVar m Nil) e

Finally we have things that aren't activated because valid environment cannot be built for them.

eval (Absurd _) env = undefined

Negative types manifest as different kind of messages that are sent around. The Drop becomes Nil, Inst n becomes X n. Likewise Inl x and Inr y become X x and Y y. Split x y can be thought of as a way to "open" a pair.

plug_ctx :: [Tn] -> [MVar Sem] -> [MVar Sem] -> IO [MVar Sem]
plug_ctx []     []     box = pure box
plug_ctx (x:xs) (v:vs) box = plug x v box >>= plug_ctx xs vs

plug :: Tn -> MVar Sem -> [MVar Sem] -> IO [MVar Sem]
plug (Var n)   v box = pure (edit_box n v box)
plug Drop      v box = putMVar v Nil >> pure box
plug (Dup x y) v box = do
    a <- newEmptyMVar
    b <- newEmptyMVar
    putMVar v (Cons a b)
    plug x a box >>= plug y b
plug (Inst x)  v box = do
    a <- newEmptyMVar
    putMVar v (X a)
    plug x a box
plug Ff v box      = putMVar v Nil >> pure box
plug (Inl x) v box = do
    a <- newEmptyMVar
    putMVar v (X a)
    plug x a box
plug (Inr y) v box = do
    b <- newEmptyMVar
    putMVar v (Y b)
    plug y b box
plug (Split x y) v box = do
    a <- newEmptyMVar
    b <- newEmptyMVar
    putMVar v (Cons a b) 
    plug x a box >>= plug y b

When "plugging" values into a context, we construct a structure that isn't filled and would cause the program to crash if it was accessed before it was filled.

init_box :: Int -> [a]
init_box n = replicate n undefined

edit_box :: Int -> a -> [a] -> [a]
edit_box 0 v (_:xs) = (v:xs)
edit_box n v (x:xs) = (x:edit_box (n-1) v xs)
edit_box n v []     = [v]

So that's an evaluator.

I think it'd be possible to turn this evaluator into a normalizer, but it'd be fairly unappealing and would correspond to running the program with every variation of input it can react to.

Serving suggestion

Another thing I didn't do, I didn't try this out. But I think it can be a fun thing. It's like a functional programming environment but you can give types for interactions.

For example, echo -program would be:

echo :: Stream `Par` ~Stream

You can model the communication by stacking up interactions. Eg. the Stream would be:

Stream = Sum TensorUnit (Opt TensorUnit (TextBlock `Tensor` Stream))

To implement a "Stream", you'd send signal of whether you're closed or not, then prepare for either closing or then continue producing results.

Note that such notion of a stream could be entirely denotational. The type isn't saying which stream it is, and if you get something to implement the type, you can treat it as a stream.

I'd think you can implement the evaluator in Javascript. Build the "communication protocol" from promises.

Also it's a fairly fun program representation and you don't need to leave traditional functional programming style behind, typed lambda calculus translates in several ways into classical linear logic.

Download link

You can download CLL.hs and try it out. It's embedded in this blogpost.

The type checker is about 130 lines.

Concluding remarks

Optimal reduction could be a nice algorithm for evaluating and normalizing classical linear logic sequents but it has the problem of accumulating control operators, Garbage nodes, croissants and brackets, that eventually clog up the reduction. The algorithm likely removes something it should keep along during the reduction.

Building models like these maybe reveals what optimal reduction would need in order to collect its garbage nodes.