Skip to content

Saturate higher kinded types in Coercible constraints#3893

Merged
hdgarrood merged 10 commits intopurescript:masterfrom
kl0tl:polykinded-coercible
Sep 9, 2020
Merged

Saturate higher kinded types in Coercible constraints#3893
hdgarrood merged 10 commits intopurescript:masterfrom
kl0tl:polykinded-coercible

Conversation

@kl0tl
Copy link
Copy Markdown
Member

@kl0tl kl0tl commented May 30, 2020

This PR implements the type application rule mentioned by the Safe Zero-cost Coercions for Haskell paper in section 2.8 Supporting higher order polymorphism:

► If Coercible t1 t2, where t1, t2 :: k1 → k2, then Coercible (t1 x) (t2 x)

Fix #3889.

Comment on lines -410 to -416
TypeConstructor _ tyName -> do
-- If the first argument is a plain newtype (e.g. @newtype T = T U@ and
-- the constraint @Coercible T b@), look up the type of its wrapped
-- field and yield a new wanted constraint in terms of that type
-- (@Coercible U b@ in the example).
(_, wrappedTy, _) <- lookupNewtypeConstructor env tyName
pure [Constraint nullSourceAnn C.Coercible [] [wrappedTy, b] Nothing]
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took the liberty to simplify coercibleWanteds by removing the case expression because this first case is a simplification of when the newtype has parameters.

@kl0tl kl0tl force-pushed the polykinded-coercible branch 5 times, most recently from 9311cde to fdc2b40 Compare July 5, 2020 14:35
@hdgarrood
Copy link
Copy Markdown
Contributor

@kl0tl I think this is probably the next biggest showstopping Coercible bug to fix? What do you think about getting this merged next?

@kl0tl kl0tl force-pushed the polykinded-coercible branch from 8798f00 to a465784 Compare July 11, 2020 22:59
@kl0tl
Copy link
Copy Markdown
Member Author

kl0tl commented Jul 11, 2020

Agreed! This is the biggest one left to merge I think.

Would you then prefer that I update #3878 to solve Coercible constraints on rows instead of having an ad-hoc case for records or that I open another pull request?

@hdgarrood
Copy link
Copy Markdown
Contributor

Up to you, I don’t have a preference either way.

coercibleWanteds env a b
| (TypeConstructor _ aTyName, _, axs) <- unapplyTypes a
, (TypeConstructor _ bTyName, _, bxs) <- unapplyTypes b
, Just (aTyKind, _) <- M.lookup aTyName $ types env
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like if this lookup fails, an internal compiler error might be more appropriate than a generic "no instance found" error?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You‘re right, both type constructors should have been inserted into the environment at this point. I‘ve thrown "coercibleWanteds: type lookup failed" on failed lookups here and in the next guard.

, (aks, kind) <- unapplyKinds aTyKind
, (bks, _) <- unapplyKinds bTyKind
, length axs < length aks
, length bxs < length bks = do
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I run my understanding of what's going on here past you to check it's accurate? So axs is the list of type arguments that a has, and aks is the list of kinds of those arguments (and likewise for b). We know that length axs <= length aks, because otherwise we would have had a kind unification error by now, so the possibilities are that length axs might be less than length aks (if a isn't fully applied), or otherwise it will be equal to length aks (if it is fully applied). And again, same for b.

I don't think it should be possible that only one of the two arguments is fully applied, right? Otherwise we would have had a kind unification error on line 400? Does it follow that one of these length checks is redundant?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is, I feel like it should follow from a and b having the same kind that length aks - length axs = length bks - length bxs

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You‘re absolutely right and the length bxs < length bks check is indeed redundant now that kinds are checked in solveCoercible. I‘ve removed it and also another redundant not (null bxs) check in the next guard (if both type constructors have the same name and the first one has no arguments then it would be ill-kinded for the second one to have arguments).

-- in the constraint @Coercible (D a) (D a')@), yield a new wanted
-- constraint in terms of the types saturated with the same variables
-- (e.g. @Coercible (D a t0) (D a' t0)@ in the exemple).
tys <- traverse freshTypeWithKind aks
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this work if the type constructors in use in a and b have different numbers of arguments? I'm wondering if this should instead be something like replicate (length aks - length axs) freshTypeWithKind, and then get rid of the drop on the following lines. For example, if we have, say

MkX :: X -> Type -> Type
MkYY :: Y -> Y -> Type -> Type
SomeX :: X
SomeY :: Y

and the two arguments a and b we're dealing with are MkX SomeX and MkYY SomeY SomeY, then I think we will have

axs = [ SomeX ]
bxs = [ SomeY, SomeY ]
aks = [ X, Type ]
bks = [ Y, Y, Type ]

In this case, tys will have the same number of elements as aks, i.e. 2, and so we'll have

drop (length axs) tys = [ t0 ]
drop (length bxs) tys = []

so we end up recursing with

a = MkX SomeX t0
b = MkYY SomeY Some Y

which then leads to a kind unification error, even though the user might not have written anything ill-kinded? Let me know if this makes any sense.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very well spotted! Thank you for taking the time to write such a detailed example 🙇

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure! I was mainly doing it for my own benefit initially, to try to check I had understood it properly (and because this was too much to keep in my head at once) haha

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind adding a test which would catch this too, when you get a chance?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added tests/purs/failing/CoercibleHigherKindedData.purs and a case inspired by #3893 (comment) to tests/purs/passing/Coercible.purs.

replaceTySyns = replaceAllTypeSynonymsM tySynMap kindMap
(a', kind) <- lift $ replaceTySyns a >>= kindOf
(b', kind') <- lift $ replaceTySyns b >>= kindOf
lift $ unifyKinds kind kind'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure this is a gap in my own understanding, but it feels a little bit strange to me that we aren't capturing any information as a result of unifying kinds here. Is it possible that we learn something about either kind or kind' that we didn't know until we reached this line? If so, should that information be carried forward?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unifyKinds returns a (MonadError MultipleErrors m, MonadState CheckState m) => m () so I’m not sure how to extract any information from a successful unification 🤔

If unifying kinds extends the current substitution perhaps we should apply it to the kinds we inferred and then refer to [kind, kind'] rather than kinds in the returned type class dictionary? Or perhaps we should rather apply it to the types rewritten by kindOf earlier? I’m completely out of my depth here 😅

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I’m a bit out of my depth too. @natefaubion I’d be interested to hear your thoughts on this if you have a moment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unifyKinds does extend the substitution and at some point it must be applied, yes. I believe it's always applied at the top of the solver loop.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think that suggests that it might be worth exporting apply from TypeChecker.Kinds to use here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I suppose we should resolve the other discussion (#3893 (comment)) first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applySubstitution as is used everywhere else is fine. I think there's only one in the kind-checker for convenience or module dependencies or something.

@hdgarrood
Copy link
Copy Markdown
Contributor

Sorry, yes, I mean kind inference. Would you prefer that we investigate and address this first, then?

@hdgarrood
Copy link
Copy Markdown
Contributor

hdgarrood commented Sep 5, 2020

(where "this" = "the issue that an unelaborated type is being constructed somewhere")

@kl0tl
Copy link
Copy Markdown
Member Author

kl0tl commented Sep 5, 2020

I wanted to add the following test

module Main where

import Safe.Coerce (coerce)

data Unary a
data Binary a b

data Proxy a = Proxy
type role Proxy representational

data Unit

unaryToBinary :: Proxy Unary -> Proxy (Binary Unit)
unaryToBinary = coerce

but it fails with

Error found:
in module Example
at Example.purs:14:17 - 14:23 (line 14, column 17 - line 14, column 23)

  Type variable k is undefined.

while checking that type t2
  has kind t3
while inferring the kind of Unary @t3 t2
while solving type class constraint
                                                  
  Prim.Coerce.Coercible (Unary @t0 t2)            
                        (Binary @Type @t1 Unit t2)
                                                  
while checking that expression coerce
  has type Proxy @(t0 -> Type) (Unary @t0) -> Proxy @(t1 -> Type) (... @t1 Unit)
in value declaration unaryToBinary

where t0 is an unknown type
      t1 is an unknown type
      t3 is an unknown type
      t2 is an unknown type

See https://github.com/purescript/documentation/blob/master/errors/UndefinedTypeVariable.md for more information,
or to contribute content related to this error.

I don’t have figured out why yet but adding kind annotations to Unary and Binary parameters fixes the issue.

@natefaubion
Copy link
Copy Markdown
Contributor

Sorry, yes, I mean kind inference. Would you prefer that we investigate and address this first, then?

I would like to at least know where they are coming from. For example, are the subgoals we are constructing for Coercible lacking kind applications? If that's the only source, it's probably not worrisome, it's just inefficient. I have not looked at the code extensively for how we are constructing our sub goals, but that's something to investigate.

@kl0tl
Copy link
Copy Markdown
Member Author

kl0tl commented Sep 5, 2020

The kind inference issue can be observed with

module Example where

import Safe.Coerce (coerce)

newtype N f = N (f {})

example :: forall f. N f -> f {}
example = coerce

When unwrapping N we extract the wrapped type from the environment, which lacks a kind application on the empty row:

((f :: Type -> Type) (Record ()))

@natefaubion
Copy link
Copy Markdown
Contributor

If you insert a call to trace debugDataConstructors, I get:

coercibleWanteds
Example.N :: forall (f :: Type -> Type). f (Record (() @Type)) -> N f

Qualified (Just (ModuleName "Example")) (ProperName {runProperName = "N"})
["f"]
f (Record ())

So I think whatever source lookupNewtypeConstructor is using is just not an accurate source.

@natefaubion
Copy link
Copy Markdown
Contributor

natefaubion commented Sep 5, 2020

The kind checker doesn't elaborate the type in the data declaration spine, which appears to be what lookupNewtypeConstructor is using. It yields elaborated types of constructors.

@natefaubion
Copy link
Copy Markdown
Contributor

natefaubion commented Sep 5, 2020

Here is where it does not rewrite the ctor

fmap ((ctor,) . mkForAll ctorBinders) $ inferDataConstructor tyCtor' ctor

Here is where it checks the fields and returns a type for the constructor:

flip checkKind E.kindType . foldr ((E.-:>) . snd) tyCtor . dataCtorFields

You could have it package up new constructor fields as well as returning the type here.

@kl0tl
Copy link
Copy Markdown
Member Author

kl0tl commented Sep 6, 2020

Thank you for the pointers Nate! I think I managed to do as you suggested and unifying elaborated kinds happens to also fix #3893 (comment) 🎉 There’s two new issues with this though, tests/purs/passing/LetPattern.purs now fails with:

    var v1 = Y.create(25252)("hello, world")(false);
                                            ^

TypeError: Y.create(...)(...) is not a function

and tests/purs/passing/Coercible.purs fails with:

Error found:
in module Main
at tests/purs/passing/Coercible.purs:71:18 - 71:24 (line 71, column 18 - line 71, column 24)

  No type class instance was found for
                                                                                
    Prim.Coerce.Coercible (forall (k1 :: Type) (k2 :: Type). Phantom1 @k2 s0 t4)
                          s0                                                    
                                                                                

while checking that type forall (a :: Type) (b :: Type). Coercible @Type a b => a -> b
  is at least as general as type s0 -> Roles1 @t1 @t2 r3 s0 t4
while checking that expression coerce
  has type s0 -> Roles1 @t1 @t2 r3 s0 t4
in value declaration roles1ToSecond

where r3 is a rigid type variable
        bound at (line 71, column 18 - line 71, column 24)
      s0 is a rigid type variable
        bound at (line 71, column 18 - line 71, column 24)
      t4 is a rigid type variable
        bound at (line 71, column 18 - line 71, column 24)
      t1 is an unknown type
      t2 is an unknown type

See https://github.com/purescript/documentation/blob/master/errors/NoInstanceFound.md for more information,
or to contribute content related to this error.

-> m (DataConstructorDeclaration, SourceType)
inferDataConstructor tyCtor DataConstructorDeclaration{..} = do
tyCtor' <- checkKind (foldr ((E.-:>) . snd) tyCtor dataCtorFields) E.kindType
let (_, _, tys) = unapplyTypes tyCtor'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think unapplyTypes is correct here. unapplyTypes deconstructs applications, but tyCtor' is the signature for the data constructor. That is, given data Foo a = Foo Int a String, then tyCtor' will be Int -> a -> String -> Foo a. unapplyTypes called on this will yield an application to -> with two arguments. Maybe we should do a traversal over dataCtorFields, checking each field against E.kindType, zip this with dataCtorFields, and then assemble the constructor signature after.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course 🤦 I‘ve checked each field independently. I attempted to build the constructor type from the checked fields types but this led to issues with rank-n kinds so I built it from the unchecked field types and then checked the whole.

Is there a way to reuse some of the work done checking the fields when building the constructor type?

Copy link
Copy Markdown
Contributor

@natefaubion natefaubion Sep 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why you would have rank-n issues. Maybe you constructed the associativity of -> incorrectly? Maybe something like:

inferDataConstructor tyCtor DataConstructorDeclaration{..} = do
  dataCtorFields' <- traverse (traverse (flip checkKind E.kindType)) dataCtorFields
  dataCtor <- flip (foldr ((E.-:>) . snd)) dataCtorFields' =<< checkKind tyCtor E.kindType
  pure (DataConstructorDeclaration { dataCtorFields = dataCtorFields', .. }, dataCtor)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I just forgot to check the constructor return type 😅 I applied your suggestion!

let tyUnks = snd . fromJust $ lookup (mkQualified datName moduleName) tySubs
ctors' = fmap (fmap (generalizeUnknowns tyUnks . replaceTypeCtors)) ctors
replaceTypeCtorsAndGeneralizeUnknowns = generalizeUnknowns tyUnks . replaceTypeCtors
ctors' = fmap (mapDataCtorFields (fmap (fmap replaceTypeCtorsAndGeneralizeUnknowns)) *** replaceTypeCtorsAndGeneralizeUnknowns) ctors
Copy link
Copy Markdown
Contributor

@natefaubion natefaubion Sep 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear to me that you want to generalize unknowns in the data constructor spine like this, as this will introduce foralls inside the data constructor spine for any field with an unknown. These unknowns should be quantified as part of the data declaration, not in each field. That is

data Foo f a = Foo (f a)

This should be quantified as

data Foo :: forall k. (k -> Type) -> k -> Type
data Foo f a = Foo (f @k a)

Not as

data Foo f a = Foo (forall k. f @k a)

We should generalize the data declaration as a whole, not just each field type individually.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh that’s why I had those unwanted foralls! I’ve replaced unknowns without generalizing them.

@kl0tl kl0tl force-pushed the polykinded-coercible branch 2 times, most recently from 068db0e to bcdf8c8 Compare September 6, 2020 19:03
@natefaubion
Copy link
Copy Markdown
Contributor

natefaubion commented Sep 6, 2020

@hdgarrood @kl0tl I'm happy with the changes to the kind checker and the use of elaborateKind. As long as everything passes and y'all are happy with the Coercible changes, I think this is OK to merge.

@kl0tl
Copy link
Copy Markdown
Member Author

kl0tl commented Sep 6, 2020

The last unresolved issue should be about whether to apply the current substitution to Coercible constraints parameters and/or their kinds before computing subgoals (#3893 (comment)).

As you said, the current substitution is already applied at the top of the solver recursive worker so I think the only risk is to fail to equate the parameters of identical constructors at role nominal here?

We’re unifying elaborated kinds now though, are unknowns still expected in them?

@natefaubion
Copy link
Copy Markdown
Contributor

You need to apply a substitution if you want to observe anything about the type after unifying. You do not want to observe a unification variable that is otherwise solved.

@natefaubion
Copy link
Copy Markdown
Contributor

As you said, the current substitution is already applied at the top of the solver recursive worker so I think the only risk is to fail to equate the parameters of identical constructors at role nominal here?

We’re unifying elaborated kinds now though, are unknowns still expected in them?

I think you said you kept unification because it yielded a better error? Is unification necessary for soundness? If not, then maybe it isn't necessary to apply a substitution.

@kl0tl
Copy link
Copy Markdown
Member Author

kl0tl commented Sep 7, 2020

The unified kinds are thrown away afterwards but we rely on the constraint arguments to have the same kind when computing subgoals to avoid redundant checks (for instance we only check if the first argument is unsaturated or has a non empty arguments list).

I’d like to find a case where applying the substitution makes a difference. If my understanding is correct this should only happen when one of the constraint arguments has an unknown kind application that would be solved by the unification of both arguments kinds? I’m struggling to observe this though 😅

@natefaubion
Copy link
Copy Markdown
Contributor

If my understanding is correct this should only happen when one of the constraint arguments has an unknown kind application that would be solved by the unification of both arguments kinds? I’m struggling to observe this though

I'm not sure how a kind like this (though it sounds like it's in paradox territory 😆) would be unknown at this point since we are past type-checking.

@hdgarrood
Copy link
Copy Markdown
Contributor

Since this issue is difficult (potentially impossible) to observe, shall we merge this now and come back to it later if it comes back up?

Given the following declaration

```purs
newtype N f = N (f {})
```

solving a `Coercible (N f) (f {})` constraints yields a `Coercible (f {}) (f {})` subgoal by the unwraping rule. This constraint seems trivial but if constructors fields are not elaborated the actual subgoal is `Coercible (f (Record ())) (f (Record (() @type)))`, which isn’t solvable because of the missing kind application on the left!

Inferring kinds and comparing the rewritten terms fixes the issue at the expense of redundant work (kinds are already inferred during type checking) but then invalid coercions between unsaturated higher kinded types with polymorphic parameters fail to type check with an `UndefinedTypeVariable` error instead of the expected `NoInstanceFound`.
@kl0tl kl0tl force-pushed the polykinded-coercible branch from bcdf8c8 to 7e8c171 Compare September 8, 2020 16:56
@kl0tl
Copy link
Copy Markdown
Member Author

kl0tl commented Sep 8, 2020

Sounds good to me! I‘ve squashed the last commit and the one it fixes.

@hdgarrood
Copy link
Copy Markdown
Contributor

Amazing - thanks for your work on this!

@hdgarrood hdgarrood merged commit 3a1ac10 into purescript:master Sep 9, 2020
@kl0tl kl0tl deleted the polykinded-coercible branch September 18, 2020 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hygiene issue in Coercible solving

3 participants