Saturate higher kinded types in Coercible constraints#3893
Saturate higher kinded types in Coercible constraints#3893hdgarrood merged 10 commits intopurescript:masterfrom
Coercible constraints#3893Conversation
| 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] |
There was a problem hiding this comment.
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.
896006f to
0c42d95
Compare
0c42d95 to
11ff2ad
Compare
9311cde to
fdc2b40
Compare
|
@kl0tl I think this is probably the next biggest showstopping Coercible bug to fix? What do you think about getting this merged next? |
8798f00 to
a465784
Compare
|
Agreed! This is the biggest one left to merge I think. Would you then prefer that I update #3878 to solve |
|
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 |
There was a problem hiding this comment.
I feel like if this lookup fails, an internal compiler error might be more appropriate than a generic "no instance found" error?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Very well spotted! Thank you for taking the time to write such a detailed example 🙇
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Would you mind adding a test which would catch this too, when you get a chance?
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 😅
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Do you think that suggests that it might be worth exporting apply from TypeChecker.Kinds to use here?
There was a problem hiding this comment.
Ah, I suppose we should resolve the other discussion (#3893 (comment)) first.
There was a problem hiding this comment.
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.
|
Sorry, yes, I mean kind inference. Would you prefer that we investigate and address this first, then? |
|
(where "this" = "the issue that an unelaborated type is being constructed somewhere") |
|
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 = coercebut it fails with I don’t have figured out why yet but adding kind annotations to |
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. |
|
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 = coerceWhen unwrapping ((f :: Type -> Type) (Record ())) |
|
If you insert a call to trace So I think whatever source |
|
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. |
|
Here is where it does not rewrite the Here is where it checks the fields and returns a type for the constructor: You could have it package up new constructor fields as well as returning the type here. |
|
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: and tests/purs/passing/Coercible.purs fails with: |
| -> m (DataConstructorDeclaration, SourceType) | ||
| inferDataConstructor tyCtor DataConstructorDeclaration{..} = do | ||
| tyCtor' <- checkKind (foldr ((E.-:>) . snd) tyCtor dataCtorFields) E.kindType | ||
| let (_, _, tys) = unapplyTypes tyCtor' |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Oh that’s why I had those unwanted foralls! I’ve replaced unknowns without generalizing them.
068db0e to
bcdf8c8
Compare
|
@hdgarrood @kl0tl I'm happy with the changes to the kind checker and the use of |
|
The last unresolved issue should be about whether to apply the current substitution to 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? |
|
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. |
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. |
|
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 😅 |
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. |
|
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`.
bcdf8c8 to
7e8c171
Compare
|
Sounds good to me! I‘ve squashed the last commit and the one it fixes. |
|
Amazing - thanks for your work on this! |
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:
Fix #3889.