Bivariance inference

The algorithmic specification of variance inference has an interesting consequence when type parameters do not appear in the body of the declaration.

For example, in the following example, we will infer that C is covariant with respect to T:

class C[T]:
  pass

def test_co(x: C[int]) -> C[int|str]:
    return x # pass

def test_cn(x: C[int|str]) -> C[int]:
    return x # error

[Pyright] [Mypy]

Alternatively, we might choose to say that C is bivariant with respect to T and admit the entire program without error. Indeed, because the type parameter does not appear in the class body, it imposes no constraint on how C is used.

This behavior can also lead to a possibly undesirable behavior where variance inference can depend on the order of declarations. For example:

class A[T]:
    def m(self, b: 'B[T]'): ...
    
class B[U]:
    def m(self, a: A[U]): ...

def testA_co(x: A[int]) -> A[int|str]:
    return x # pass

def testA_cn(x: A[int|str]) -> A[int]:
    return x # error

def testB_co(x: B[int]) -> B[int|str]:
    return x # error

def testB_cn(x: B[int|str]) -> B[int]:
    return x # pass

[Pyright] [Mypy]

In the above example, we infer that A is covariant with respect to T and that B is contravariant with respect to U. However, if we swap the order of definitions, such that B comes before A, then we will instead infer that B is covariant with respect to U and that A is contravariant with respect to T.

This order sensitivity is fortunately the same between Pyright and Mypy today, but it depends on the order that these checkers visit the declarations, which is not currently specified.

To be clear, the inferences above are all correct. If we had explicitly annotated the variance of these parameters, those annotations would be valid. However, it would also be valid to consider these as bivariant. (In the mutually recursive case, the type parameters never appear in an infinite expansion of the types.)

This all leads me to a proposal: should we allow type checkers to infer bivariance in cases like this? Should we require it?

For context, this came up in Pyre while we worked on support for variance inference. Instead of implementing the algorithm outlined in the specification, we have built an alternative algorithm that infers variance based on the occurrences of type parameters in the structure of the type.

In the alternate formulation, we are able to infer bijectivity, but found this mismatch with the spec.

Thanks for your consideration,
Sam

8 Likes

Thanks for bringing this up, it’s an interesting issue.

Am I correct in thinking that this is primarily a theoretical issue, in that there aren’t a lot of realistic use cases where we’d infer bivariance? If so, I’d be hesitant to mandate that type checkers add support for bivariance, which might mean complicating their codebase for a feature that isn’t useful.

Your first example (a class that doesn’t use its generic parameter) doesn’t seem practically useful. The second example (two classes that each refer to the other in a parameter) could reasonably appear in practice. What do you think of specifying mypy/pyright’s behavior, which seems to be to process the classes in the order they appear in the source code?

Interesting observation. Thanks for sharing.

When I wrote the algorithmic specification in PEP 695, I was aware of the case where a class does not reference the type parameter. Treating the type parameter as covariant in this situation seems reasonable, so I wasn’t concerned about it. However, I didn’t consider the other case that you mentioned where evaluation order can result in different variance inference. I agree that’s bad.

Incidentally, this algorithm isn’t new for PEP 695. The same algorithm has been used by mypy and pyright (and perhaps other type checkers?) since the introduction of Protocols in PEP 544. It was used to verify that type variables used within a protocol had the correct variance. I think the soundness hole that you identified here therefore predates PEP 695. It allows for the creation of generic protocol classes that have incorrect variance.

I think we have several options:

Option 1: We could add the concept of bivariance to the type system, as you suggest. I would want to do this only if we identify compelling use cases for bivariance, and I’m not convinced there are. Adding bivariance as a general concept would be relatively costly, since it would require many changes within existing type checkers plus changes to the runtime to allow for explicit bivariance when calling the TypeVar constructor. It would also presumably create backward compatibility issues for some existing protocol definitions if we enforced this check for protocols. And it would add yet more complexity to the type system that would need to be taught to Python users.

Option 2: We could ignore the issue for now. I’m not aware of any problems this hole has caused in practice, so this may be reasonable. If we see evidence that it’s a problem, we can revisit it in the future.

Option 3: We could specify that type checkers, when inferring variance, should detect the situation where the variance calculation depends on order of declarations and report this as an error condition. We could also make it an error to not reference a type parameter anywhere within the class, but I think that would be more disruptive.

Among these three, I prefer options 2 or 3. Maybe there are other options I haven’t considered.

I just prototyped option 3 in pyright, and it was pretty straightforward to detect this case.

Thoughts?

1 Like

Is this a soundness hole? It leads to inference of more limited variance when bivariance would also be correct. This means we allow fewer valid programs, but unless I’m missing something, I don’t think it can cause us to wrongly admit invalid programs.

I wouldn’t want to specify (including implicitly via the conformance suite) that a type checker should not implement support for bivariance, since that seems like the most principled way to handle this case. But I also think that in the absence of real-world use cases, we shouldn’t require support for bivariance either. So I think I favor option 2; if we go with option 3, I wouldn’t want to require this error from type checkers.

1 Like

Yeah, perhaps this isn’t a soundness hold but rather just an inconsistency. Currently, mypy and pyright detect and report cases where a protocol could use covariance or contravariance but instead use invariance. By your argument, the following code shouldn’t produce an error:

from typing import Protocol, TypeVar

T = TypeVar("T")

class Proto(Protocol[T]):  # Error: T should be contravariant
    def method(self, x: T) -> None:
        pass

Hmm, I’m not sure I agree. Bivariance introduces unsoundness. Inferring bivariance without telling the user that they now have a soundness hole in their program seems problematic.

Thanks for the great discussion!

Yeah, I spent a little while thinking about this, and the only possible use case is for a phantom type, but I am not certain if this pattern is worth supporting in Python. If we did want to support patterns using phantom types, we would likely also want to use declared variance instead of inferred.

I am open to this, but I might prefer to leave it unspecified. For definitions within a single file, the lexical order in the program is a reasonable choice. Could we also have mutual recursion across files? That might be more annoying to specify.

Sorry, I didn’t mean to suggest option 1 as the correct one. I agree with your assessment that there is too much complexity for an edge case to insist on it.

I am perfectly happy with option 2, assuming I am correct that it allows for type checkers to infer bivariance. My chief concern is that inferring bivariance would make us non-conformant.

I disprefer option 3, since it would complicate our algorithm for variance inference significantly. Our algorithm has some nice properties in general (we can infer variance for all type parameters at once, we don’t need to involve subtyping at all), but it also fits much better into the rest of the design of Pyre; implementing the spec algo directly would be very expensive for us.

Thank you for sharing this example. I was not aware of this check and I will think a bit more about variance in protocols, since I have thought deeply about this yet.

In general, the typing spec tries to avoid specifying how a type checker should be implemented and instead focuses only on its user-visible behaviors. I struggled (and ultimately failed) to honor this principle when writing this section of PEP 695. If you can think of a way to describe the behavior without describing the algorithm, I think that would be a welcome update to the spec.

Now I’m curious how your algorithm works. :slight_smile:

3 Likes

Certainly. A declarative specification could be nice. I’ll attempt provide a sketch of our algorithm here, and if that direction seems worthwhile I am happy to attempt a declarative specification based on it.

For what it’s worth, I think the current algorithmic specification is quite nice. It’s clearly stated and, because it relies on the existing subtyping rules, is quite concise. Readers do not need to understand that callable arguments have negative polarity, for example, because the existing subtyping rules are enough.

To hopefully make the algorithm more clear, I’ll use a smaller language, with only polymorphic type aliases, a single atomic type, arrows, and type application. Instead of giving a formal definition of the syntax, I’ll just work through examples. I think it should be relatively clear from the examples.

A[T] = T -> ()

In the above, we infer variance by visiting the structure of the definition. The arrow type T->U has the normal subtyping rules for functions, contravariant on the argument and covariant on the return. T occurs only in a contravariant position in the definition, so we infer that A is contravariant with respect to T.

B[T] = T -> T

Here, T occurs in both a contravariant and covariant position, so we infer that B is invariant w.r.t. T. We define an operation to combine the variance of each position, + where co + cn = inv, for example.

C[T,U] = T -> U

Since we are just looking at the occurrences of the type parameters, we can keep track for all type parameters at once.

D[T] = (T -> ()) -> ()

As we walk down the structure of a type, we need to keep the polarities straight. We start the traversal with a positive polarity. As we walk into the argument of the outer arrow, we flip to negative. As we walk into the argument of the inner arrow, we flip again to positive. By analogy, positive polarity corresponds to +1 and negative polarity corresponds to -1, and as we walk, we multiply. Here, -1 * -1 = +1. (We can extend the notion of polarity to include a neutral
element corresponding to 0).

E[T] = () -> T
F[U] = () -> E[U]

Here the definition of F depends on E, so we must infer the variance of E first. When inferring variance for U within F, we see that it occurs in the type application E[U], where that position is positive, because E is covariant w.r.t T.

G[T] = G[T] -> (T -> ())

Recursion creates an additional difficulty, which is that the positions where T appears is not immediately clear. We have not yet determined the variance of G, so we can not rely on that pre-existing knowledge to determine the polarity of T in G[T].

If we unfold the type one level, we can see that T appears in both a positive and negative position. In practice, unfolding isn’t practical. So instead we iterate to a fixed point. Here’s a worked example:

[Pre]                           [Post]
[T = Bi]  G[T] -> (T -> ()) [T = Cn]
[T = Cn]  G[T] -> (T -> ()) [T = Inv]
[T = Inv] G[T] -> (T -> ()) [T = Inv]

Because polarities form a lattice and our inference function is monotone, once we reach a fixed point we have a solution. Hopefully the analogy with expansion makes sense, and hopefully this motivates the inclusion of bivariance.

I haven’t done so here, but the same process extends to multiple type parameters.

H[T] = I[T] -> ()
I[U] = H[U] -> ()

We handle mutual recursion in the same way, by iterating to a fixed point. Both I and U start out bivariant. We visit all mutually recursive definitions together in a single iteration giving our postcondition, which we use as the precondition for the next iteration, and so on.

However, if we work through this example, we will infer that both T and U are invariant.

[Pre]                               [Post]
[T=Bi;U=Bi]   I[T] -> U; H[U] -> () [T=Cn;U=Cn]
[T=Cn;U=Cn]   I[T] -> U; H[U] -> () [T=Inv;U=Inv]
[T=Inv;U=Inv] I[T] -> U; H[U] -> () [T=Inv;U=Inv]

We solve this by also inferring injectivity. In other words, we detect the case where the type parameter does not appear in the right hand side. We do this in the same manner as inferring variance, and in practice we infer at the same time.

To extend the previous example, we will maintain state about both variance and injectivity. Injectivity is just a boolean, initially false, and becomes true when we see an the type parameter appear injectively. In this case, we infer false, corresponding to the fact that an infinite expansion of this type has no occurrences of T or U, and we can thus infer bivariance.

Here is a complete algorithm of the above, which might make some of the above more clear.

That’s the sketch of the algorithm. In practice, the algorithm is more complicated because it needs to understand the polarities of all typing forms, not just arrows. A declarative specification would be pretty verbose because of that, whereas the existing specification can rely on subtyping rules which encode the polarities indirectly.

And to be fully transparent, we have not actually extended this algorithm to the entire language in Pyre yet, so it’s not yet clear how many cases we would need to specify, but certainly at least unions, tuples, lists, dictionaries, and so on.

4 Likes

I edited the above to simplify the G example. In doing so, I observed a possible bug in Pyright, which I filed here.

I don’t think bivariance introduces unsoundness when it is correctly inferred in the cases discussed here: that is, when the type variable is not used in either covariant or contravariant position. Clearly it would introduce unsoundness if incorrectly inferred.

2 Likes

Does bivariance even make sense? I am really struggling to see even a hypothetical case, where this would be useful. Are there any positions where T could appear that wouldn’t be either co- or contra-variant?

It seems to me that “bivariant” type parameters in generics don’t provide any constraints. For example, continuing from your first example:

class C[T]:
  pass

def test_co(x: C[int]) -> C[int|str]:
    return x

def test_cn(x: C[int|str]) -> C[int]:
    return x

# Suppose, that we allow both of the above functions to pass
# type checking. Then what about the following functions?

def test_any1(x: C[int]) -> C[str]:
    y: C[int|str] = x   # pass, because C is covariant?
    z: C[str] = y       # pass, because C is contravariant?
    return z

def test_any2(x: C[int]) -> C[str]:
    # pass, because it's literally the same code as
    # test_any1, just without the extra type hints?
    return x

Doesn’t this mean that C[A] and C[B] are the exact same type for any A and B. If that is the case, are there any reasons why C couldn’t just be a regular class?

class C:
    pass
1 Like

You are right. I am not trying to describe a useful pattern here, but to show that the existing spec handles this not-useful pattern in a particular way with certain implications, which implementations might like to avoid.

I agree that definitions like this should not exist in practice, since type parameters that don’t appear in the body are not useful. However, in the unlikely event that such a definition exists, type checker behavior still needs to be defined. Whether that behavior is specified, it sounds like none of the implementors feel strongly that it should.

3 Likes
class A[T]:
  pass

class B[T]:
  def m(self, x: A[T]): ...

Here is a case where T “appears” in B but we could still safely infer bivariance.

1 Like

My preference (maybe not surprising since I’m also coming at this from Pyre’s point of view) would be to leave the exact behavior in this scenario unspecified.

I think there’s a reasonable case to be made for each of:

  • silently using bivariance
  • silently using invariance (has nice implications for forward compatibility of libraries)
  • doing either of the above and also throwing a type error somewhere to warn users about redundant type variables

Since the cases where it comes up are probably usually buggy code in the first place, it seems to me like type checkers should have some flexibility about how to handle this and whether to surface the problem.

1 Like