-
Notifications
You must be signed in to change notification settings - Fork 1.6k
[ty] handle recursive type inference properly #20566
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
What are the salsa features that are required for this PR to land? |
|
I put up a PR that implements the extensions that I think are needed for this to work:
I didn't understand this part or the semantics of the join operation. Specifically, how the join operation is different from With those features in place, is this PR something you could drive forward? I'm asking because we're most likely going to land #20988 as is because it unblocks type of self with a minimal regression but it would be great to have a proper fix in place. |
|
One thing I wonder. Should queries be allowed to customize So maybe,
|
Thanks, it'll be helpful, but I'll have to elaborate on my thoughts.
@carljm had a similar question, and my answer is given here.
For the reason stated above, I believe that simply providing a "last provisional value" in the cycle_recovery function will not achieve fully-converged type inference.
In my opinion, there is no need to set a "threshold" at which to give up on further type inference and switch to |
I think that leaving the decision of convergence to the user risks making inference unstable: that is, salsa users may force (or erroneously) declare convergence when it has not actually converged, leading to non-determinism. |
Yeah, agree. But I think we can enable this functionality by comparing if the value returned by
I read that conversation and it's the part I don't understand. It also seems specific to return type inference because the lattice isn't monotonic. Which seems different from diverging cases that continue going forever. Edit: Instead of having specific handling in many places. Could the cycle function instead: If the previous value contains Replace all instance of This is a no-op for The change I proposed in Salsa upstream to consider a cycle head as converged when the |
Consider inferring the return type of the following function: # 0th: Divergent (cycle_initial)
# 1st: None | tuple[Divergent]
# 2nd: None | tuple[None | tuple[Divergent]]
# ...
def div(x: int):
if x == 0:
return None
else:
return (div(x-1),)If the values from the previous and current cycles do not match, then the from typing import overload
class A: ...
class B(A): ...
@overload
def flip(x: B) -> A: ...
@overload
def flip(x: A) -> B: ...
def flip(x): ...
class C:
def __init__(self, x: B):
self.x = x
def flip(self):
# 0th: Divergent
# 1st: B
# 2nd: A
# 3rd: B
# ...
self.x = flip(self.x)
reveal_type(C(B()).x)Therefore, I believe the correct approach here is to allow |
I think the new salsa version supports this now. The |
Ah, so you've made it so that the equality test is performed again after the fallback. I overlooked that. So, I think there's no problem and and I can move this PR forward. |
Diagnostic diff on typing conformance testsChanges were detected when running ty on typing conformance tests--- old-output.txt 2025-11-26 16:08:51.526490508 +0000
+++ new-output.txt 2025-11-26 16:08:54.894507078 +0000
@@ -1,4 +1,3 @@
-fatal[panic] Panicked at /home/runner/.cargo/git/checkouts/salsa-e6f3bb7c2a062968/17bc55d/src/function/execute.rs:469:17 when checking `/home/runner/work/ruff/ruff/typing/conformance/tests/aliases_typealiastype.py`: `infer_definition_types(Id(1a6a3)): execute: too many cycle iterations`
_directives_deprecated_library.py:15:31: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `int`
_directives_deprecated_library.py:30:26: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `str`
_directives_deprecated_library.py:36:41: error[invalid-return-type] Function always implicitly returns `None`, which is not assignable to return type `Self@__add__`
@@ -72,6 +71,27 @@
aliases_type_statement.py:79:7: error[too-many-positional-arguments] Too many positional arguments: expected 2, got 3
aliases_type_statement.py:80:7: error[too-many-positional-arguments] Too many positional arguments: expected 2, got 3
aliases_type_statement.py:80:37: error[invalid-type-form] List literals are not allowed in this context in a type expression: Did you mean `tuple[int, str]`?
+aliases_type_statement.py:82:1: error[cyclic-type-alias-definition] Cyclic definition of `RecursiveTypeAlias3`
+aliases_type_statement.py:88:1: error[cyclic-type-alias-definition] Cyclic definition of `RecursiveTypeAlias6`
+aliases_type_statement.py:89:1: error[cyclic-type-alias-definition] Cyclic definition of `RecursiveTypeAlias7`
+aliases_typealiastype.py:32:7: error[unresolved-attribute] Object of type `typing.TypeAliasType` has no attribute `other_attrib`
+aliases_typealiastype.py:39:26: error[invalid-type-form] List literals are not allowed in this context in a type expression: Did you mean `tuple[int, str]`?
+aliases_typealiastype.py:52:40: error[invalid-type-form] Function calls are not allowed in type expressions
+aliases_typealiastype.py:53:40: error[invalid-type-form] List literals are not allowed in this context in a type expression: Did you mean `tuple[int, str]`?
+aliases_typealiastype.py:54:42: error[invalid-type-form] Tuple literals are not allowed in this context in a type expression
+aliases_typealiastype.py:54:43: error[invalid-type-form] Tuple literals are not allowed in this context in a type expression: Did you mean `tuple[int, str]`?
+aliases_typealiastype.py:55:42: error[invalid-type-form] List comprehensions are not allowed in type expressions
+aliases_typealiastype.py:56:42: error[invalid-type-form] Dict literals are not allowed in type expressions
+aliases_typealiastype.py:57:42: error[invalid-type-form] Function calls are not allowed in type expressions
+aliases_typealiastype.py:58:42: error[invalid-type-form] Invalid subscript of object of type `list[Unknown | <class 'int'>]` in type expression
+aliases_typealiastype.py:58:48: error[invalid-type-form] Int literals are not allowed in this context in a type expression
+aliases_typealiastype.py:59:42: error[invalid-type-form] `if` expressions are not allowed in type expressions
+aliases_typealiastype.py:60:42: error[invalid-type-form] Variable of type `Literal[3]` is not allowed in a type expression
+aliases_typealiastype.py:61:42: error[invalid-type-form] Boolean literals are not allowed in this context in a type expression
+aliases_typealiastype.py:62:42: error[invalid-type-form] Int literals are not allowed in this context in a type expression
+aliases_typealiastype.py:63:42: error[invalid-type-form] Boolean operations are not allowed in type expressions
+aliases_typealiastype.py:64:42: error[invalid-type-form] F-strings are not allowed in type expressions
+aliases_typealiastype.py:66:47: error[unresolved-reference] Name `BadAlias21` used when not defined
aliases_variance.py:18:24: error[non-subscriptable] Cannot subscript object of type `<class 'ClassA[typing.TypeVar]'>` with no `__class_getitem__` method
aliases_variance.py:28:16: error[non-subscriptable] Cannot subscript object of type `<class 'ClassA[typing.TypeVar]'>` with no `__class_getitem__` method
aliases_variance.py:44:16: error[non-subscriptable] Cannot subscript object of type `<class 'ClassB[typing.TypeVar, typing.TypeVar]'>` with no `__class_getitem__` method
@@ -1011,5 +1031,4 @@
typeddicts_usage.py:28:17: error[missing-typed-dict-key] Missing required key 'name' in TypedDict `Movie` constructor
typeddicts_usage.py:28:18: error[invalid-key] Unknown key "title" for TypedDict `Movie`: Unknown key "title"
typeddicts_usage.py:40:24: error[invalid-type-form] The special form `typing.TypedDict` is not allowed in type expressions
-Found 1013 diagnostics
-WARN A fatal error occurred while checking some files. Not all project files were analyzed. See the diagnostics list above for details.
+Found 1033 diagnostics
|
82d9580 to
d9eac53
Compare
CodSpeed Performance ReportMerging #20566 will improve performances by 6.95%Comparing Summary
Benchmarks breakdown
Footnotes
|
|
Ohh... do we also need to pass the id to the initial function? |
d9eac53 to
041bb24
Compare
Yes. For now I'm using https://github.com/mtshiba/salsa/tree/input_id?rev=9ea5289bc6a87943b8a8620df8ff429062c56af0, but is there a better way? There will be a lot of changes. |
No, I don't think there's a better way. It's a breaking change that requires updating all cycle initial functions. Do you want to put up a Salsa PR (Claude's really good at updating the function signatures, but not very fast :)) |
|
OK, I've already had some early reviews, but I'm declaring this PR ready for everyone's review now. |
| // type IntOrStr = int | StrOrInt # It's redundant, but OK | ||
| // type StrOrInt = str | IntOrStr # It's redundant, but OK | ||
| // ``` | ||
| let expanded = value_ty.inner_type().expand_eagerly(self.db()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This addresses the bug identified at #21434.
| // For stringified TypeAlias; remove once properly supported | ||
| todo_type!("string literal subscripted in type expression") | ||
| } | ||
| Type::Union(union) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I implemented inference for union subscript type expressions because I noticed that it could suppress the following error:
from typing import TypeVar, TypeAlias, Union
K = TypeVar("K")
V = TypeVar("V")
# TODO: no error
# error: [invalid-type-form] "Invalid subscript of object of type `<class 'dict[typing.TypeVar, typing.TypeVar | Unknown]'> | <class 'dict[typing.TypeVar, typing.TypeVar | @Todo(specialized generic alias in type expression)]'> | <class 'dict[typing.TypeVar, Divergent]'>` in type expression"
NestedDict: TypeAlias = dict[K, Union[V, "NestedDict[K, V]"]]However, this may not be an essential solution to the error. There's also the question of whether union subscript type expressions should be allowed in the first place.
type ListOrSet[T] = list[T] | set[T]
type Tuple1[T] = tuple[T]
def _(cond: bool):
Generic = ListOrSet if cond else Tuple1
def _(x: Generic[int]):
reveal_type(x) # revealed: list[int] | set[int] | tuple[int]Pyrefly allows it, while Pyright rejects it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think according to the type spec this should not be allowed, and we may want to forbid it in future.
I feel like this issue and the "unsupported-class-base" issue discussed elsewhere are both symptoms of the same problem, which is that creating a union type in fixpoint iteration where a union would otherwise be unexpected can cause undesirable side effects. I wonder if we can address this as follow-up with more intelligent unioning -- like perhaps when the outer generic type is the same we should union the type arguments instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's a more realistic example.
try:
class Foo[T]:
x: T
def foo(self) -> T:
return self.x
...
...
except Exception:
class Foo[T]:
x: T
def foo(self) -> T:
return self.x
...
def f(x: Foo[int]):
reveal_type(x.foo()) # revealed: int?Currently, ty infers x.foo() as Unknown (and claims that Foo[int] is an invalid type expression), while mypy and pyright can infer it as int.
Perhaps some kind of warning should be reported for such hacky code, but since the user's intent is clear and we have the ability to infer it, we can consider that we should do so.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
like perhaps when the outer generic type is the same we should union the type arguments instead?
Yeah, this is discussed at astral-sh/ty#1308.
* main: [ty] Extend Liskov checks to also cover classmethods and staticmethods (astral-sh#21598) Dogfood ty on the `scripts` directory (astral-sh#21617) [ty] support generic aliases in `type[...]`, like `type[C[int]]` (astral-sh#21552) [ty] Retain the function-like-ness of `Callable` types when binding `self` (astral-sh#21614) [ty] Distinguish "unconstrained" from "constrained to any type" (astral-sh#21539) Disable ty workspace diagnostics for VSCode users (astral-sh#21620) [ty] Double click to insert inlay hint (astral-sh#21600) [ty] Switch the error code from `unresolved-attribute` to `possibly-missing-attribute` for submodules that may not be available (astral-sh#21618) [ty] Substitute for `typing.Self` when checking protocol members (astral-sh#21569) [ty] Don't suggest things that aren't subclasses of `BaseException` after `raise` [ty] Add hint about resolved Python version when a user attempts to import a member added on a newer version (astral-sh#21615)
|
| Lint rule | Added | Removed | Changed |
|---|---|---|---|
invalid-type-form |
0 | 67 | 16 |
invalid-argument-type |
24 | 1 | 49 |
invalid-assignment |
22 | 4 | 3 |
invalid-return-type |
6 | 0 | 10 |
possibly-missing-attribute |
2 | 6 | 7 |
no-matching-overload |
11 | 0 | 0 |
type-assertion-failure |
0 | 0 | 10 |
unsupported-operator |
0 | 0 | 5 |
unused-ignore-comment |
0 | 5 | 0 |
unsupported-base |
4 | 0 | 0 |
not-iterable |
0 | 0 | 2 |
unresolved-attribute |
1 | 0 | 0 |
| Total | 70 | 83 | 102 |
* main: [ty] Implement `typing.override` (astral-sh#21627) [ty] Avoid expression reinference for diagnostics (astral-sh#21267) [ty] Improve autocomplete suppressions of keywords in variable bindings [ty] Only suggest completions based on text before the cursor Implement goto-definition and find-references for global/nonlocal statements (astral-sh#21616) [ty] Inlay Hint edit follow up (astral-sh#21621) [ty] Implement lsp support for string annotations (astral-sh#21577) [ty] Add 'remove unused ignore comment' code action (astral-sh#21582) [ty] Refactor `CheckSuppressionContext` to use `DiagnosticGuard` (astral-sh#21587) [ty] Improve several "Did you mean?" suggestions (astral-sh#21597) [ty] Add more and update existing projects in `ty_benchmark` (astral-sh#21536) [ty] fix ty playground initialization and vite optimization issues (astral-sh#21471)
carljm
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @mtshiba, this is really impressive work! You've solved a difficult problem, relatively cleanly, and improved performance in the process.
I would like to get this merged tomorrow if possible. I've left some comments and would love your responses to them, and any updates to this PR that you would like to make / have time to make in order to address those comments. If there are comments that you agree with but don't have time to address, feel free to just comment and I will try to address them tomorrow before merge. Some can certainly also be follow-ups in a separate PR after we land this one.
Thank you!
| class Base2(Generic[T, U]): ... | ||
|
|
||
| # TODO: no error | ||
| # error: [unsupported-base] "Unsupported class base with type `<class 'Base2[Sub2, U@Sub2]'> | <class 'Base2[Sub2[Unknown], U@Sub2]'>`" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see this occurring in ecosystem projects, so we will want to follow up on this soon; users may experience it as a regression.
cycle normalization takes a union with the type from the previous cycle
It seems like maybe we could address this with a targeted fix in the cycle recovery for explicit_bases query? For class literal types we could skip unioning, or union the type parameters instead if the outer type is the same and the outer type is covariant in its type parameters? Not sure on details, would need to experiment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've already confirmed that unioning the type parameters eliminates this error: 058f8ed
However, I've reverted the commit because I found the operation to be unsound (Base2 is not covariant with T).
A sound solution would be to improve the type inference of legacy generic classes so that we somehow recognize that Sub2 is a generic class from the beginning of the cycle.
| // For stringified TypeAlias; remove once properly supported | ||
| todo_type!("string literal subscripted in type expression") | ||
| } | ||
| Type::Union(union) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think according to the type spec this should not be allowed, and we may want to forbid it in future.
I feel like this issue and the "unsupported-class-base" issue discussed elsewhere are both symptoms of the same problem, which is that creating a union type in fixpoint iteration where a union would otherwise be unexpected can cause undesirable side effects. I wonder if we can address this as follow-up with more intelligent unioning -- like perhaps when the outer generic type is the same we should union the type arguments instead?
| // `@overload`ed functions without a body in unreachable code. | ||
| true | ||
| } | ||
| Type::Dynamic(DynamicType::Divergent(_)) => true, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering if you have a better sense of why we need this. There is one test in overloads.md that fails without it, but in that that test overload resolves to Never, and I don't see where there would be a cycle at all. I'm wondering if you already debugged this and understand why we see a Divergent type here in that test?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's the rough outline that leads to the cycle:
infer_definition_types
-> infer_function_definition
-> try_upcast_to_callable (is_input_function_like)
-> FunctionType::signature
-> definition_expression_type (Signature::from_function)
-> infer_definition_types
A basic principle of fixed-point iteration calculations is that errors should not be reported unnecessarily early in the cycle when sufficient information has not yet been gathered.
If in_function_overload_or_abstractmethod is false, additional checks will be performed and an error may be reported.
A natural implementation would therefore be to set in_function_overload_or_abstractmethod to true early in the cycle, and defer the decision to subsequent cycles until more detailed function information has been gathered.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I think the principle makes sense, I was just curious why we hit a cycle at all in this code. It looks like we may have a cycle for all overloaded function definitions, thanks to is_input_function_like checking. This doesn't seem ideal, but it's a separate issue unrelated to this PR.
| // type IntOrStr = int | StrOrInt # It's redundant, but OK | ||
| // type StrOrInt = str | IntOrStr # It's redundant, but OK | ||
| // ``` | ||
| let expanded = value_ty.inner_type().expand_eagerly(self.db()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like we don't use the fully-expanded type here at all, unless it is Divergent (i.e. we found the type alias to be cyclic). Do we need a type-mapping (type transformation) and diverging Salsa fixpoint iteration in order to achieve this detection? Could we instead use a a simple type-visit (with cycle detection) which expands aliases, and track in the visitor whether we ever encountered a cycle?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also think this method is a bit over-engineered if we just want to check the divergence of type aliases.
One future direction I consider is that expand_eagerly would allow us to infer recursive type aliases a bit more intelligently, like the following code (although this type alias itself should probably be reported as invalid):
# TODO: this should probably be a cyclic-type-alias-definition error
type Foo[T] = list[T] | Bar[T]
type Bar[T] = int | Foo[T]
def _(x: Bar[int]):
# currently: int | list[int] | Any
reveal_type(x) # revealed: int | list[int]This means, when expanding a type alias, first expand recursively, and if it diverges, call value_type as a fallback.
| Self::Literal(ty) => ty | ||
| .recursive_type_normalized_impl(db, div, true, visitor) | ||
| .map(Self::Literal), | ||
| Self::Annotated(ty) => ty | ||
| .recursive_type_normalized_impl(db, div, true, visitor) | ||
| .map(Self::Annotated), | ||
| Self::TypeGenericAlias(ty) => ty | ||
| .recursive_type_normalized_impl(db, div, true, visitor) | ||
| .map(Self::TypeGenericAlias), | ||
| Self::LiteralStringAlias(ty) => ty | ||
| .recursive_type_normalized_impl(db, div, true, visitor) | ||
| .map(Self::LiteralStringAlias), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These types send nested=true downwards instead of simply nested -- but in this case they propagate None upwards regardless of the outer nested value. So that's now three different behaviors I've seen:
- Send
nesteddownward as-is, always propagateNoneupward. - Send
nested=truedownward, always propagateNoneupward. - Send
nested=truedownward, sendNoneupward if called nested, otherwise replaceNonewithdiv.
I think we need a clearer description of why these different behaviors exist, and how we know which one to use in any given case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nominal types such as list[T] and tuple[T] should send nested=true for T. This is absolutely necessary for normalization.
Structural types such as union and intersection do not need to send nested=true; that is, types that are "flat" from the perspective of recursive types. T | U should send nested for T, U.
For other types, the decision depends on whether they are interpreted as nominal or structural.
For example, KnownInstanceType::UnionType should simply send nested. You can confirm that mdtest fails when you send nested=true for value_expr_types.
I haven't found any problematic cases for other types, but they can be interpreted as nominal, so nested=true should be sent.
At the very least, sending nested=true ensures that Divergent remains, so it's safe operation. With nested=false, Divergent may be eliminated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, this makes sense, thanks for adding the comment.
It's also not entirely clear to me why some types that send nested=True downward will unconditionally propagate None upward (as in these cases here), but others will replace None with div. Is there a way to summarize the logic behind this difference?
b1369cf to
deb919a
Compare
fa68786 to
7f43950
Compare
| Self::Literal(ty) => ty | ||
| .recursive_type_normalized_impl(db, div, true, visitor) | ||
| .map(Self::Literal), | ||
| Self::Annotated(ty) => ty | ||
| .recursive_type_normalized_impl(db, div, true, visitor) | ||
| .map(Self::Annotated), | ||
| Self::TypeGenericAlias(ty) => ty | ||
| .recursive_type_normalized_impl(db, div, true, visitor) | ||
| .map(Self::TypeGenericAlias), | ||
| Self::LiteralStringAlias(ty) => ty | ||
| .recursive_type_normalized_impl(db, div, true, visitor) | ||
| .map(Self::LiteralStringAlias), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, this makes sense, thanks for adding the comment.
It's also not entirely clear to me why some types that send nested=True downward will unconditionally propagate None upward (as in these cases here), but others will replace None with div. Is there a way to summarize the logic behind this difference?
| // `@overload`ed functions without a body in unreachable code. | ||
| true | ||
| } | ||
| Type::Dynamic(DynamicType::Divergent(_)) => true, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I think the principle makes sense, I was just curious why we hit a cycle at all in this code. It looks like we may have a cycle for all overloaded function definitions, thanks to is_input_function_like checking. This doesn't seem ideal, but it's a separate issue unrelated to this PR.
Summary
Derived from #17371
Fixes astral-sh/ty#256
Fixes astral-sh/ty#1415
Fixes astral-sh/ty#1433
Fixes astral-sh/ty#1524
Properly handles any kind of recursive inference and prevents panics.
Let me explain techniques for converging fixed-point iterations during recursive type inference.
There are two types of type inference that naively don't converge (causing salsa to panic): divergent type inference and oscillating type inference.
Divergent type inference
Divergent type inference occurs when eagerly expanding a recursive type. A typical example is this:
To solve this problem, we have already introduced
Divergenttypes (#20312).Divergenttypes are treated as a kind of dynamic type 1.When a query function that returns a type enters a cycle, it sets
Divergentas the cycle initial value (instead ofNever). Then, in the cycle recovery function, it reduces the nesting of types containingDivergentto converge.Each cycle recovery function for each query should operate only on the
Divergenttype originating from that query.For this reason, while
Divergentappears the same asAnyto the user, it internally carries some information: the location where the cycle occurred. Previously, we roughly identified this by having the scope where the cycle occurred, but with the update to salsa, functions that create cycle initial values can now receive asalsa::Id(salsa-rs/salsa#1012). This is an opaque ID that uniquely identifies the cycle head (the query that is the starting point for the fixed-point iteration).Divergentnow has thissalsa::Id.Oscillating type inference
Now, another thing to consider is oscillating type inference. Oscillating type inference arises from the fact that monotonicity is broken. Monotonicity here means that for a query function, if it enters a cycle, the calculation must start from a "bottom value" and progress towards the final result with each cycle. Monotonicity breaks down in type systems that have features like overloading and overriding.
Naive fixed-point iteration results in
Divergent -> Sub -> Base -> Sub -> ..., which oscillates forever without diverging or converging. To address this, the salsa API has been modified so that the cycle recovery function receives the value of the previous cycle (salsa-rs/salsa#1012).The cycle recovery function returns the union type of the current cycle and the previous cycle. In the above example, the result type for each cycle is
Divergent -> Sub -> Base (= Sub | Base) -> Base, which converges.The final result of oscillating type inference does not contain
DivergentbecauseDivergentthat appears in a union type can be removed, as is clear from the expansion. This simplification is performed at the same time as nesting reduction.Performance analysis
A happy side effect of this PR is that we've observed widespread performance improvements!
This is likely due to the removal of the
ITERATIONS_BEFORE_FALLBACKand max-specialization depth trick (astral-sh/ty#1433, astral-sh/ty#1415), which means we reach a fixed point much sooner.Ecosystem analysis
The changes look good overall.
You may notice changes in the converged values for recursive types, this is because the way recursive types are normalized has been changed. Previously, types containing
Divergenttypes were normalized by replacing them with theDivergenttype itself, but in this PR, types with a nesting level of 2 or more that containDivergenttypes are normalized by replacing them with a type with a nesting level of 1. This means that information about the non-divergent parts of recursive types is no longer lost.The false positive error introduced in this PR occurs in class definitions with self-referential base classes, such as the one below.
This is due to the lack of support for unions of MROs, or because cyclic legacy generic types are not inferred as generic types early in the query cycle.
Test Plan
All samples listed in astral-sh/ty#256 are tested and passed without any panic!
Acknowledgments
Thanks to @MichaReiser for working on bug fixes and improvements to salsa for this PR. @carljm also contributed early on to the discussion of the query convergence mechanism proposed in this PR.
Footnotes
In theory, it may be possible to strictly treat types containing
Divergenttypes as recursive types, but we probably shouldn't go that deep yet. (AFAIK, there are no PEPs that specify how to handle implicitly recursive types that aren't named by type aliases) ↩