Skip to content

Commit f67236b

Browse files
authored
[ty] Better handling of "derived information" in constraint sets (#21463)
This saga began with a regression in how we handle constraint sets where a typevar is constrained by another typevar, which #21068 first added support for: ```py def mutually_constrained[T, U](): # If [T = U ∧ U ≤ int], then [T ≤ int] must be true as well. given_int = ConstraintSet.range(U, T, U) & ConstraintSet.range(Never, U, int) static_assert(given_int.implies_subtype_of(T, int)) ``` While working on #21414, I saw a regression in this test, which was strange, since that PR has nothing to do with this logic! The issue is that something in that PR made us instantiate the typevars `T` and `U` in a different order, giving them differently ordered salsa IDs. And importantly, we use these salsa IDs to define the variable ordering that is used in our constraint set BDDs. This showed that our "mutually constrained" logic only worked for one of the two possible orderings. (We can — and now do — test this in a brute-force way by copy/pasting the test with both typevar orderings.) The underlying bug was in our `ConstraintSet::simplify_and_domain` method. It would correctly detect `(U ≤ T ≤ U) ∧ (U ≤ int)`, because those two constraints affect different typevars, and from that, infer `T ≤ int`. But it wouldn't detect the equivalent pattern in `(T ≤ U ≤ T) ∧ (U ≤ int)`, since those constraints affect the same typevar. At first I tried adding that as yet more pattern-match logic in the ever-growing `simplify_and_domain` method. But doing so caused other tests to start failing. At that point, I realized that `simplify_and_domain` had gotten to the point where it was trying to do too much, and for conflicting consumers. It was first written as part of our display logic, where the goal is to remove redundant information from a BDD to make its string rendering simpler. But we also started using it to add "derived facts" to a BDD. A derived fact is a constraint that doesn't appear in the BDD directly, but which we can still infer to be true. Our failing test relies on derived facts — being able to infer that `T ≤ int` even though that particular constraint doesn't appear in the original BDD. Before, `simplify_and_domain` would trace through all of the constraints in a BDD, figure out the full set of derived facts, and _add those derived facts_ to the BDD structure. This is brittle, because those derived facts are not universally true! In our example, `T ≤ int` only holds along the BDD paths where both `T = U` and `U ≤ int`. Other paths will test the negations of those constraints, and on those, we _shouldn't_ infer `T ≤ int`. In theory it's possible (and we were trying) to use BDD operators to express that dependency...but that runs afoul of how we were simultaneously trying to _remove_ information to make our displays simpler. So, I ripped off the band-aid. `simplify_and_domain` is now _only_ used for display purposes. I have not touched it at all, except to remove some logic that is definitely not used by our `Display` impl. Otherwise, I did not want to touch that house of cards for now, since the display logic is not load-bearing for any type inference logic. For all non-display callers, we have a new **_sequent map_** data type, which tracks exactly the same derived information. But it does so (a) without trying to remove anything from the BDD, and (b) lazily, without updating the BDD structure. So the end result is that all of the tests (including the new regressions) pass, via a more efficient (and hopefully better structured/documented) implementation, at the cost of hanging onto a pile of display-related tech debt that we'll want to clean up at some point.
1 parent cbc6863 commit f67236b

File tree

2 files changed

+784
-78
lines changed

2 files changed

+784
-78
lines changed

crates/ty_python_semantic/resources/mdtest/type_properties/implies_subtype_of.md

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,10 @@ def given_constraints[T]():
173173
static_assert(given_str.implies_subtype_of(T, str))
174174
```
175175

176-
This might require propagating constraints from other typevars.
176+
This might require propagating constraints from other typevars. (Note that we perform the test
177+
twice, with different variable orderings. Our BDD implementation uses the Salsa IDs of each typevar
178+
as part of the variable ordering. Reversing the typevar order helps us verify that we don't have any
179+
BDD logic that is dependent on which variable ordering we end up with.)
177180

178181
```py
179182
def mutually_constrained[T, U]():
@@ -183,6 +186,19 @@ def mutually_constrained[T, U]():
183186
static_assert(not given_int.implies_subtype_of(T, bool))
184187
static_assert(not given_int.implies_subtype_of(T, str))
185188

189+
# If [T ≤ U ∧ U ≤ int], then [T ≤ int] must be true as well.
190+
given_int = ConstraintSet.range(Never, T, U) & ConstraintSet.range(Never, U, int)
191+
static_assert(given_int.implies_subtype_of(T, int))
192+
static_assert(not given_int.implies_subtype_of(T, bool))
193+
static_assert(not given_int.implies_subtype_of(T, str))
194+
195+
def mutually_constrained[U, T]():
196+
# If [T = U ∧ U ≤ int], then [T ≤ int] must be true as well.
197+
given_int = ConstraintSet.range(U, T, U) & ConstraintSet.range(Never, U, int)
198+
static_assert(given_int.implies_subtype_of(T, int))
199+
static_assert(not given_int.implies_subtype_of(T, bool))
200+
static_assert(not given_int.implies_subtype_of(T, str))
201+
186202
# If [T ≤ U ∧ U ≤ int], then [T ≤ int] must be true as well.
187203
given_int = ConstraintSet.range(Never, T, U) & ConstraintSet.range(Never, U, int)
188204
static_assert(given_int.implies_subtype_of(T, int))
@@ -236,6 +252,22 @@ def mutually_constrained[T, U]():
236252
static_assert(not given_int.implies_subtype_of(Covariant[T], Covariant[bool]))
237253
static_assert(not given_int.implies_subtype_of(Covariant[T], Covariant[str]))
238254

255+
# If (T ≤ U ∧ U ≤ int), then (T ≤ int) must be true as well, and therefore
256+
# (Covariant[T] ≤ Covariant[int]).
257+
given_int = ConstraintSet.range(Never, T, U) & ConstraintSet.range(Never, U, int)
258+
static_assert(given_int.implies_subtype_of(Covariant[T], Covariant[int]))
259+
static_assert(not given_int.implies_subtype_of(Covariant[T], Covariant[bool]))
260+
static_assert(not given_int.implies_subtype_of(Covariant[T], Covariant[str]))
261+
262+
# Repeat the test with a different typevar ordering
263+
def mutually_constrained[U, T]():
264+
# If (T = U ∧ U ≤ int), then (T ≤ int) must be true as well, and therefore
265+
# (Covariant[T] ≤ Covariant[int]).
266+
given_int = ConstraintSet.range(U, T, U) & ConstraintSet.range(Never, U, int)
267+
static_assert(given_int.implies_subtype_of(Covariant[T], Covariant[int]))
268+
static_assert(not given_int.implies_subtype_of(Covariant[T], Covariant[bool]))
269+
static_assert(not given_int.implies_subtype_of(Covariant[T], Covariant[str]))
270+
239271
# If (T ≤ U ∧ U ≤ int), then (T ≤ int) must be true as well, and therefore
240272
# (Covariant[T] ≤ Covariant[int]).
241273
given_int = ConstraintSet.range(Never, T, U) & ConstraintSet.range(Never, U, int)
@@ -281,6 +313,22 @@ def mutually_constrained[T, U]():
281313
static_assert(not given_int.implies_subtype_of(Contravariant[bool], Contravariant[T]))
282314
static_assert(not given_int.implies_subtype_of(Contravariant[str], Contravariant[T]))
283315

316+
# If (T ≤ U ∧ U ≤ int), then (T ≤ int) must be true as well, and therefore
317+
# (Contravariant[int] ≤ Contravariant[T]).
318+
given_int = ConstraintSet.range(Never, T, U) & ConstraintSet.range(Never, U, int)
319+
static_assert(given_int.implies_subtype_of(Contravariant[int], Contravariant[T]))
320+
static_assert(not given_int.implies_subtype_of(Contravariant[bool], Contravariant[T]))
321+
static_assert(not given_int.implies_subtype_of(Contravariant[str], Contravariant[T]))
322+
323+
# Repeat the test with a different typevar ordering
324+
def mutually_constrained[U, T]():
325+
# If (T = U ∧ U ≤ int), then (T ≤ int) must be true as well, and therefore
326+
# (Contravariant[int] ≤ Contravariant[T]).
327+
given_int = ConstraintSet.range(U, T, U) & ConstraintSet.range(Never, U, int)
328+
static_assert(given_int.implies_subtype_of(Contravariant[int], Contravariant[T]))
329+
static_assert(not given_int.implies_subtype_of(Contravariant[bool], Contravariant[T]))
330+
static_assert(not given_int.implies_subtype_of(Contravariant[str], Contravariant[T]))
331+
284332
# If (T ≤ U ∧ U ≤ int), then (T ≤ int) must be true as well, and therefore
285333
# (Contravariant[int] ≤ Contravariant[T]).
286334
given_int = ConstraintSet.range(Never, T, U) & ConstraintSet.range(Never, U, int)
@@ -338,6 +386,25 @@ def mutually_constrained[T, U]():
338386
static_assert(not given_int.implies_subtype_of(Invariant[T], Invariant[bool]))
339387
static_assert(not given_int.implies_subtype_of(Invariant[T], Invariant[str]))
340388

389+
# If (T = U ∧ U = int), then (T = int) must be true as well. That is an equality constraint, so
390+
# even though T is invariant, it does imply that (Invariant[T] ≤ Invariant[int]).
391+
given_int = ConstraintSet.range(U, T, U) & ConstraintSet.range(int, U, int)
392+
static_assert(given_int.implies_subtype_of(Invariant[T], Invariant[int]))
393+
static_assert(given_int.implies_subtype_of(Invariant[int], Invariant[T]))
394+
static_assert(not given_int.implies_subtype_of(Invariant[T], Invariant[bool]))
395+
static_assert(not given_int.implies_subtype_of(Invariant[bool], Invariant[T]))
396+
static_assert(not given_int.implies_subtype_of(Invariant[T], Invariant[str]))
397+
static_assert(not given_int.implies_subtype_of(Invariant[str], Invariant[T]))
398+
399+
# Repeat the test with a different typevar ordering
400+
def mutually_constrained[U, T]():
401+
# If (T = U ∧ U ≤ int), then (T ≤ int) must be true as well. But because T is invariant, that
402+
# does _not_ imply that (Invariant[T] ≤ Invariant[int]).
403+
given_int = ConstraintSet.range(U, T, U) & ConstraintSet.range(Never, U, int)
404+
static_assert(not given_int.implies_subtype_of(Invariant[T], Invariant[int]))
405+
static_assert(not given_int.implies_subtype_of(Invariant[T], Invariant[bool]))
406+
static_assert(not given_int.implies_subtype_of(Invariant[T], Invariant[str]))
407+
341408
# If (T = U ∧ U = int), then (T = int) must be true as well. That is an equality constraint, so
342409
# even though T is invariant, it does imply that (Invariant[T] ≤ Invariant[int]).
343410
given_int = ConstraintSet.range(U, T, U) & ConstraintSet.range(int, U, int)

0 commit comments

Comments
 (0)