[ty] Clarify behavior of constraint sets for gradual upper bounds and constraints (#21287)

When checking whether a constraint set is satisfied, if a typevar has a
non-fully-static upper bound or constraint, we are free to choose any
materialization that makes the check succeed.

In non-inferable positions, we have to show that the constraint set is
satisfied for all valid specializations, so it's best to choose the most
restrictive materialization, since that minimizes the set of valid
specializations that have to pass.

In inferable positions, we only have to show that the constraint set is
satisfied for _some_ valid specializations, so it's best to choose the
most permissive materialization, since that maximizes our chances of
finding a specialization that passes.
This commit is contained in:
Douglas Creager 2025-11-07 14:01:39 -05:00 committed by GitHub
parent 276f1d0d88
commit faae72b836
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 388 additions and 35 deletions

View file

@ -141,6 +141,97 @@ def bounded[T: Base]():
static_assert(not constraints.satisfied_by_all_typevars())
```
If the upper bound is a gradual type, we are free to choose any materialization of the upper bound
that makes the test succeed. In non-inferable positions, it is most helpful to choose the bottom
materialization as the upper bound. That is the most restrictive possible choice, which minimizes
the number of valid specializations that must satisfy the constraint set. In inferable positions,
the opposite is true: it is most helpful to choose the top materialization. That is the most
permissive possible choice, which maximizes the number of valid specializations that might satisfy
the constraint set.
```py
from typing import Any
def bounded_by_gradual[T: Any]():
static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(ConstraintSet.always().satisfied_by_all_typevars())
static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(not ConstraintSet.never().satisfied_by_all_typevars())
# If we choose Base as the materialization for the upper bound, then (T = Base) is a valid
# specialization, which satisfies (T ≤ Base).
static_assert(ConstraintSet.range(Never, T, Base).satisfied_by_all_typevars(inferable=tuple[T]))
# We are free to choose any materialization of the upper bound, and only have to show that the
# constraint set holds for that one materialization. Having chosen one materialization, we then
# have to show that the constraint set holds for all valid specializations of that
# materialization. If we choose Never as the materialization, then all valid specializations
# must satisfy (T ≤ Never). That means there is only one valid specialization, (T = Never),
# which satisfies (T ≤ Base).
static_assert(ConstraintSet.range(Never, T, Base).satisfied_by_all_typevars())
# If we choose Unrelated as the materialization, then (T = Unrelated) is a valid specialization,
# which satisfies (T ≤ Unrelated).
constraints = ConstraintSet.range(Never, T, Unrelated)
static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T]))
# If we choose Never as the materialization, then (T = Never) is the only valid specialization,
# which satisfies (T ≤ Unrelated).
static_assert(constraints.satisfied_by_all_typevars())
# If we choose Unrelated as the materialization, then (T = Unrelated) is a valid specialization,
# which satisfies (T ≤ Unrelated ∧ T ≠ Never).
constraints = constraints & ~ConstraintSet.range(Never, T, Never)
static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T]))
# There is no upper bound that we can choose to satisfy this constraint set in non-inferable
# position. (T = Never) will be a valid assignment no matter what, and that does not satisfy
# (T ≤ Unrelated ∧ T ≠ Never).
static_assert(not constraints.satisfied_by_all_typevars())
```
When the upper bound is a more complex gradual type, we are still free to choose any materialization
that causes the check to succeed, and we will still choose the bottom materialization in
non-inferable position, and the top materialization in inferable position. The variance of the
typevar does not affect whether there is a materialization we can choose. Below, we test the most
restrictive variance (i.e., invariance), but we get the same results for other variances as well.
```py
def bounded_by_gradual[T: list[Any]]():
static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(ConstraintSet.always().satisfied_by_all_typevars())
static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(not ConstraintSet.never().satisfied_by_all_typevars())
# If we choose list[Base] as the materialization of the upper bound, then (T = list[Base]) is a
# valid specialization, which satisfies (T ≤ list[Base]).
static_assert(ConstraintSet.range(Never, T, list[Base]).satisfied_by_all_typevars(inferable=tuple[T]))
# If we choose Base as the materialization, then all valid specializations must satisfy
# (T ≤ list[Base]).
# We are free to choose any materialization of the upper bound, and only have to show that the
# constraint set holds for that one materialization. Having chosen one materialization, we then
# have to show that the constraint set holds for all valid specializations of that
# materialization. If we choose list[Base] as the materialization, then all valid specializations
# must satisfy (T ≤ list[Base]), which is exactly the constraint set that we need to satisfy.
static_assert(ConstraintSet.range(Never, T, list[Base]).satisfied_by_all_typevars())
# If we choose Unrelated as the materialization, then (T = list[Unrelated]) is a valid
# specialization, which satisfies (T ≤ list[Unrelated]).
constraints = ConstraintSet.range(Never, T, list[Unrelated])
static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T]))
# If we choose Unrelated as the materialization, then all valid specializations must satisfy
# (T ≤ list[Unrelated]).
static_assert(constraints.satisfied_by_all_typevars())
# If we choose Unrelated as the materialization, then (T = list[Unrelated]) is a valid
# specialization, which satisfies (T ≤ list[Unrelated] ∧ T ≠ Never).
constraints = constraints & ~ConstraintSet.range(Never, T, Never)
static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T]))
# There is no upper bound that we can choose to satisfy this constraint set in non-inferable
# position. (T = Never) will be a valid assignment no matter what, and that does not satisfy
# (T ≤ list[Unrelated] ∧ T ≠ Never).
static_assert(not constraints.satisfied_by_all_typevars())
```
## Constrained typevar
If a typevar has constraints, then it must specialize to one of those specific types. (Not to a
@ -218,3 +309,174 @@ def constrained[T: (Base, Unrelated)]():
# (T = Base) is a valid specialization, which does not satisfy (T = Sub T = Unrelated).
static_assert(not constraints.satisfied_by_all_typevars())
```
If any of the constraints is a gradual type, we are free to choose any materialization of that
constraint that makes the test succeed. In non-inferable positions, it is most helpful to choose the
bottom materialization as the constraint. That is the most restrictive possible choice, which
minimizes the number of valid specializations that must satisfy the constraint set. In inferable
positions, the opposite is true: it is most helpful to choose the top materialization. That is the
most permissive possible choice, which maximizes the number of valid specializations that might
satisfy the constraint set.
```py
from typing import Any
def constrained_by_gradual[T: (Base, Any)]():
static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(ConstraintSet.always().satisfied_by_all_typevars())
static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(not ConstraintSet.never().satisfied_by_all_typevars())
# If we choose Unrelated as the materialization of the gradual constraint, then (T = Unrelated)
# is a valid specialization, which satisfies (T ≤ Unrelated).
static_assert(ConstraintSet.range(Never, T, Unrelated).satisfied_by_all_typevars(inferable=tuple[T]))
# No matter which materialization we choose, (T = Base) is a valid specialization, which does
# not satisfy (T ≤ Unrelated).
static_assert(not ConstraintSet.range(Never, T, Unrelated).satisfied_by_all_typevars())
# If we choose Super as the materialization, then (T = Super) is a valid specialization, which
# satisfies (T ≤ Super).
static_assert(ConstraintSet.range(Never, T, Super).satisfied_by_all_typevars(inferable=tuple[T]))
# If we choose Never as the materialization, then (T = Base) and (T = Never) are the only valid
# specializations, both of which satisfy (T ≤ Super).
static_assert(ConstraintSet.range(Never, T, Super).satisfied_by_all_typevars())
# If we choose Base as the materialization, then (T = Base) is a valid specialization, which
# satisfies (T ≤ Base).
static_assert(ConstraintSet.range(Never, T, Base).satisfied_by_all_typevars(inferable=tuple[T]))
# If we choose Never as the materialization, then (T = Base) and (T = Never) are the only valid
# specializations, both of which satisfy (T ≤ Base).
static_assert(ConstraintSet.range(Never, T, Base).satisfied_by_all_typevars())
def constrained_by_two_gradual[T: (Any, Any)]():
static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(ConstraintSet.always().satisfied_by_all_typevars())
static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(not ConstraintSet.never().satisfied_by_all_typevars())
# If we choose Unrelated as the materialization of either constraint, then (T = Unrelated) is a
# valid specialization, which satisfies (T ≤ Unrelated).
static_assert(ConstraintSet.range(Never, T, Unrelated).satisfied_by_all_typevars(inferable=tuple[T]))
# If we choose Unrelated as the materialization of both constraints, then (T = Unrelated) is the
# only valid specialization, which satisfies (T ≤ Unrelated).
static_assert(ConstraintSet.range(Never, T, Unrelated).satisfied_by_all_typevars())
# If we choose Base as the materialization of either constraint, then (T = Base) is a valid
# specialization, which satisfies (T ≤ Base).
static_assert(ConstraintSet.range(Never, T, Base).satisfied_by_all_typevars(inferable=tuple[T]))
# If we choose Never as the materialization of both constraints, then (T = Never) is the only
# valid specialization, which satisfies (T ≤ Base).
static_assert(ConstraintSet.range(Never, T, Base).satisfied_by_all_typevars())
```
When a constraint is a more complex gradual type, we are still free to choose any materialization
that causes the check to succeed, and we will still choose the bottom materialization in
non-inferable position, and the top materialization in inferable position. The variance of the
typevar does not affect whether there is a materialization we can choose. Below, we test the most
restrictive variance (i.e., invariance), but we get the same results for other variances as well.
```py
def constrained_by_gradual[T: (list[Base], list[Any])]():
static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(ConstraintSet.always().satisfied_by_all_typevars())
static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(not ConstraintSet.never().satisfied_by_all_typevars())
# No matter which materialization we choose, every valid specialization will be of the form
# (T = list[X]). Because Unrelated is final, it is disjoint from all lists. There is therefore
# no materialization or specialization that satisfies (T ≤ Unrelated).
static_assert(not ConstraintSet.range(Never, T, Unrelated).satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(not ConstraintSet.range(Never, T, Unrelated).satisfied_by_all_typevars())
# If we choose list[Super] as the materialization, then (T = list[Super]) is a valid
# specialization, which satisfies (T ≤ list[Super]).
static_assert(ConstraintSet.range(Never, T, list[Super]).satisfied_by_all_typevars(inferable=tuple[T]))
# No matter which materialization we choose, (T = list[Base]) is a valid specialization, which
# does not satisfy (T ≤ list[Super]).
static_assert(not ConstraintSet.range(Never, T, list[Super]).satisfied_by_all_typevars())
# If we choose list[Base] as the materialization, then (T = list[Base]) is a valid
# specialization, which satisfies (T ≤ list[Base]).
static_assert(ConstraintSet.range(Never, T, list[Base]).satisfied_by_all_typevars(inferable=tuple[T]))
# If we choose list[Base] as the materialization, then all valid specializations must satisfy
# (T ≤ list[Base]).
static_assert(ConstraintSet.range(Never, T, list[Base]).satisfied_by_all_typevars())
# If we choose list[Sub] as the materialization, then (T = list[Sub]) is a valid specialization,
# which # satisfies (T ≤ list[Sub]).
static_assert(ConstraintSet.range(Never, T, list[Sub]).satisfied_by_all_typevars(inferable=tuple[T]))
# No matter which materialization we choose, (T = list[Base]) is a valid specialization, which
# does not satisfy (T ≤ list[Sub]).
static_assert(not ConstraintSet.range(Never, T, list[Sub]).satisfied_by_all_typevars())
# If we choose list[Unrelated] as the materialization, then (T = list[Unrelated]) is a valid
# specialization, which satisfies (T ≤ list[Unrelated]).
constraints = ConstraintSet.range(Never, T, list[Unrelated])
static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T]))
# No matter which materialization we choose, (T = list[Base]) is a valid specialization, which
# does not satisfy (T ≤ list[Unrelated]).
static_assert(not constraints.satisfied_by_all_typevars())
# If we choose list[Unrelated] as the materialization, then (T = list[Unrelated]) is a valid
# specialization, which satisfies (T ≤ list[Unrelated] ∧ T ≠ Never).
constraints = constraints & ~ConstraintSet.range(Never, T, Never)
static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T]))
# There is no materialization that we can choose to satisfy this constraint set in non-inferable
# position. (T = Never) will be a valid assignment no matter what, and that does not satisfy
# (T ≤ list[Unrelated] ∧ T ≠ Never).
static_assert(not constraints.satisfied_by_all_typevars())
def constrained_by_two_gradual[T: (list[Any], list[Any])]():
static_assert(ConstraintSet.always().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(ConstraintSet.always().satisfied_by_all_typevars())
static_assert(not ConstraintSet.never().satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(not ConstraintSet.never().satisfied_by_all_typevars())
# No matter which materialization we choose, every valid specialization will be of the form
# (T = list[X]). Because Unrelated is final, it is disjoint from all lists. There is therefore
# no materialization or specialization that satisfies (T ≤ Unrelated).
static_assert(not ConstraintSet.range(Never, T, Unrelated).satisfied_by_all_typevars(inferable=tuple[T]))
static_assert(not ConstraintSet.range(Never, T, Unrelated).satisfied_by_all_typevars())
# If we choose list[Super] as the materialization, then (T = list[Super]) is a valid
# specialization, which satisfies (T ≤ list[Super]).
static_assert(ConstraintSet.range(Never, T, list[Super]).satisfied_by_all_typevars(inferable=tuple[T]))
# No matter which materialization we choose, (T = list[Base]) is a valid specialization, which
# does not satisfy (T ≤ list[Super]).
static_assert(ConstraintSet.range(Never, T, list[Super]).satisfied_by_all_typevars())
# If we choose list[Base] as the materialization, then (T = list[Base]) is a valid
# specialization, which satisfies (T ≤ list[Base]).
static_assert(ConstraintSet.range(Never, T, list[Base]).satisfied_by_all_typevars(inferable=tuple[T]))
# If we choose Base as the materialization, then all valid specializations must satisfy
# (T ≤ list[Base]).
static_assert(ConstraintSet.range(Never, T, list[Base]).satisfied_by_all_typevars())
# If we choose list[Sub] as the materialization, then (T = list[Sub]) is a valid specialization,
# which satisfies (T ≤ list[Sub]).
static_assert(ConstraintSet.range(Never, T, list[Sub]).satisfied_by_all_typevars(inferable=tuple[T]))
# No matter which materialization we choose, (T = list[Base]) is a valid specialization, which
# does not satisfy (T ≤ list[Sub]).
static_assert(ConstraintSet.range(Never, T, list[Sub]).satisfied_by_all_typevars())
# If we choose list[Unrelated] as the materialization, then (T = list[Unrelated]) is a valid
# specialization, which satisfies (T ≤ list[Unrelated]).
constraints = ConstraintSet.range(Never, T, list[Unrelated])
static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T]))
# No matter which materialization we choose, (T = list[Base]) is a valid specialization, which
# does not satisfy (T ≤ list[Unrelated]).
static_assert(constraints.satisfied_by_all_typevars())
# If we choose list[Unrelated] as the materialization, then (T = list[Unrelated]) is a valid
# specialization, which satisfies (T ≤ list[Unrelated] ∧ T ≠ Never).
constraints = constraints & ~ConstraintSet.range(Never, T, Never)
static_assert(constraints.satisfied_by_all_typevars(inferable=tuple[T]))
# There is no constraint that we can choose to satisfy this constraint set in non-inferable
# position. (T = Never) will be a valid assignment no matter what, and that does not satisfy
# (T ≤ list[Unrelated] ∧ T ≠ Never).
static_assert(constraints.satisfied_by_all_typevars())
```