Skip to content

Conversation

@mtshiba
Copy link
Contributor

@mtshiba mtshiba commented Sep 25, 2025

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:

class C:
    def f(self, other: "C"):
        self.x = (other.x, 1)

reveal_type(C().x) # revealed: Unknown | tuple[Unknown | tuple[Unknown | tuple[..., Literal[1]], Literal[1]], Literal[1]]

To solve this problem, we have already introduced Divergent types (#20312). Divergent types are treated as a kind of dynamic type 1.

Unknown | tuple[Unknown | tuple[Unknown | tuple[..., Literal[1]], Literal[1]], Literal[1]]
=> Unknown | tuple[Divergent, Literal[1]]

When a query function that returns a type enters a cycle, it sets Divergent as the cycle initial value (instead of Never). Then, in the cycle recovery function, it reduces the nesting of types containing Divergent to converge.

0th: Divergent
1st: Unknown | tuple[Divergent, Literal[1]]
2nd: Unknown | tuple[Unknown | tuple[Divergent, Literal[1]], Literal[1]]
=> Unknown | tuple[Divergent, Literal[1]]

Each cycle recovery function for each query should operate only on the Divergent type originating from that query.
For this reason, while Divergent appears the same as Any to 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 a salsa::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). Divergent now has this salsa::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.

class Base:
    def flip(self) -> "Sub":
        return Sub()

class Sub(Base):
    def flip(self) -> "Base":
        return Base()

class C:
    def __init__(self, x: Sub):
        self.x = x

    def replace_with(self, other: "C"):
        self.x = other.x.flip()

reveal_type(C(Sub()).x)

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 Divergent because Divergent that 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.

T | Divergent = T | (T | (T | ...)) = T

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_FALLBACK and 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 Divergent types were normalized by replacing them with the Divergent type itself, but in this PR, types with a nesting level of 2 or more that contain Divergent types 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.

# previous
tuple[tuple[Divergent, int], int] => Divergent
# now
tuple[tuple[Divergent, int], int] => tuple[Divergent, int]

The false positive error introduced in this PR occurs in class definitions with self-referential base classes, such as the one below.

from typing_extensions import Generic, TypeVar

T = TypeVar("T")
U = TypeVar("U")

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]'>`"
class Sub2(Base2["Sub2", U]): ...

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

  1. In theory, it may be possible to strictly treat types containing Divergent types 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)

@AlexWaygood AlexWaygood added the ty Multi-file analysis & type inference label Sep 25, 2025
@MichaReiser
Copy link
Member

What are the salsa features that are required for this PR to land?

@MichaReiser
Copy link
Member

MichaReiser commented Oct 22, 2025

I put up a PR that implements the extensions that I think are needed for this to work:

  • Pass the query id to the cycle recovery function. It can be used as a cheap identifier of the divergent value
  • Pass the last provisional value to the cycle recovery function

See salsa-rs/salsa#1012

Therefore, I think it's necessary to specify an additional value joining operation in the tracked function. For example, like this:

I didn't understand this part or the semantics of the join operation. Specifically, how the join operation is different from fallback because cycle_recovery already runs after the query. It can override the value from this iteration. That's why it isn't clear to me why the fallback implementation can't call the join internally when necessary

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.

@MichaReiser
Copy link
Member

One thing I wonder. Should queries be allowed to customize converged so that we can use an operation other than Eq. That would allow us to return list[Diverged] as fallback from the cycle recovery function, it would lead to list[list[Diverged]] in the next iteration when we have while True: x = [x], but the has_converged function could decide to still return true in that case, saying that the query converged. We would then still need to replace the query result with list[Converged] to ensure the last provisional value and the new value are indeed equal.

So maybe, cycle_recovery should be called for all values and it returns:

  • Converged(T): The query has convered, use T as the final query result (allows customizing equal and overriding the final value to go from list[list[Converged]] back to list[Converged])
  • Iterate(T): Keep iterating, use the given value (should be new_value in the common case)

@mtshiba
Copy link
Contributor Author

mtshiba commented Oct 22, 2025

I put up a PR that implements the extensions that I think are needed for this to work:

Thanks, it'll be helpful, but I'll have to elaborate on my thoughts.

I didn't understand this part or the semantics of the join operation. Specifically, how the join operation is different from fallback because cycle_recovery already runs after the query. It can override the value from this iteration. That's why it isn't clear to me why the fallback implementation can't call the join internally when necessary

@carljm had a similar question, and my answer is given here.

With those features in place, is this PR something you could drive forward?

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.

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.

In my opinion, there is no need to set a "threshold" at which to give up on further type inference and switch to Divergent (for reasons other than performance). That's the purpose of #20566.
However, I think it's OK to merge the PR as a provisional implementation to push forward with the implementation of self-related features, as I can create a follow-up PR.

@mtshiba
Copy link
Contributor Author

mtshiba commented Oct 22, 2025

One thing I wonder. Should queries be allowed to customize converged so that we can use an operation other than Eq. That would allow us to return list[Diverged] as fallback from the cycle recovery function, it would lead to list[list[Diverged]] in the next iteration when we have while True: x = [x], but the has_converged function could decide to still return true in that case, saying that the query converged. We would then still need to replace the query result with list[Converged] to ensure the last provisional value and the new value are indeed equal.

So maybe, cycle_recovery should be called for all values and it returns:

  • Converged(T): The query has convered, use T as the final query result (allows customizing equal and overriding the final value to go from list[list[Converged]] back to list[Converged])
  • Iterate(T): Keep iterating, use the given value (should be new_value in the common case)

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.

@MichaReiser
Copy link
Member

MichaReiser commented Oct 22, 2025

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 Fallback is identical with the last provisional and, if that's the case, consider the cycle as converged (which is true, because all queries would read exactly the same value when we iterate again).

@carljm had a similar question, and my answer is given #17371 (comment).

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 Divergent:

Replace all instance of previous_value in new_value with Divergent

This is a no-op for Divergent, but should reduce list[list[Divergent]] to list[Divergent] if the previous_type was list[Divergent] and the new type is list[list[Divergent]].

The change I proposed in Salsa upstream to consider a cycle head as converged when the last_provisional_value == Fallback_value should then mark this cycle head as converged (which might result in the entire cycle to converge, if all other cycle heads have converged too)

@mtshiba
Copy link
Contributor Author

mtshiba commented Oct 23, 2025

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 Divergent:

Replace all instance of previous_value in new_value with Divergent

This is a no-op for Divergent, but should reduce list[list[Divergent]] to list[Divergent] if the previous_type was list[Divergent] and the new type is list[list[Divergent]].

The change I proposed in Salsa upstream to consider a cycle head as converged when the last_provisional_value == Fallback_value should then mark this cycle head as converged (which might result in the entire cycle to converge, if all other cycle heads have converged too)

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 cycle_recovery function is called. However, no matter what replacement we make to the current value (the type cached for the next cycle) within cycle_recovery, it will not match the result of the next cycle. This is because if the type after replacement is T, the type for the next cycle will be None | tuple[T]. Therefore, any manipulation of the type to converge type inference must be done before the equality test.
This is why cycle_recovery handling cannot be integrated with divergence suppression handling. As already explained, suppressing oscillating type inference also needs to be done separately from cycle_recovery. Oscillating type inference can occur not only in function return type inference as in #17371, but also in implicit instance attribute type inference. For example, this type inference should oscillate if self is typed and overload resolution is implemented correctly:

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 salsa::tracked to specify a cycle_join function as an argument, as explained in #17371 (comment). This function joins the previous and current values ​​before the equality check.

@MichaReiser
Copy link
Member

MichaReiser commented Oct 23, 2025

Therefore, I believe the correct approach here is to allow salsa::tracked to specify a cycle_join function as an argument, as explained in #17371 (comment). This function joins the previous and current values ​​before the equality check.

I think the new salsa version supports this now. The cycle_fn gets the old and new value and it can return Fallback(V). If V == last_provisional, then salsa considers the cycle as converged (which I think is the same as you want with your join function)

https://github.com/salsa-rs/salsa/blob/d38145c29574758de7ffbe8a13cd4584c3b09161/src/function/execute.rs#L350-L360

@mtshiba
Copy link
Contributor Author

mtshiba commented Oct 23, 2025

Therefore, I believe the correct approach here is to allow salsa::tracked to specify a cycle_join function as an argument, as explained in #17371 (comment). This function joins the previous and current values ​​before the equality check.

I think the new salsa version supports this now. The cycle_fn gets the old and new value and it can return Fallback(V). If V == last_provisional, then salsa considers the cycle as converged (which I think is the same as you want with your join function)

https://github.com/salsa-rs/salsa/blob/d38145c29574758de7ffbe8a13cd4584c3b09161/src/function/execute.rs#L350-L360

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.

@github-actions
Copy link
Contributor

github-actions bot commented Oct 27, 2025

Diagnostic diff on typing conformance tests

Changes 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

@mtshiba mtshiba force-pushed the recursive-inference branch from 82d9580 to d9eac53 Compare October 27, 2025 05:18
@codspeed-hq
Copy link

codspeed-hq bot commented Oct 27, 2025

CodSpeed Performance Report

Merging #20566 will improve performances by 6.95%

Comparing mtshiba:recursive-inference (278607f) with main (adf4f1e)

Summary

⚡ 5 improvements
✅ 17 untouched
⏩ 30 skipped1

Benchmarks breakdown

Mode Benchmark BASE HEAD Change
Simulation ty_micro[complex_constrained_attributes_1] 70.8 ms 67.5 ms +4.91%
Simulation ty_micro[complex_constrained_attributes_2] 70.6 ms 67.3 ms +4.92%
Simulation hydra-zen 1.1 s 1 s +4.44%
WallTime large[sympy] 57.5 s 55 s +4.42%
WallTime medium[static-frame] 17.4 s 16.2 s +6.95%

Footnotes

  1. 30 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@MichaReiser
Copy link
Member

Ohh... do we also need to pass the id to the initial function?

@mtshiba mtshiba force-pushed the recursive-inference branch from d9eac53 to 041bb24 Compare October 27, 2025 07:58
@mtshiba
Copy link
Contributor Author

mtshiba commented Oct 27, 2025

Ohh... do we also need to pass the id to the initial function?

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.

@MichaReiser
Copy link
Member

Yes. For now I'm using mtshiba/salsa@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 :))

@mtshiba
Copy link
Contributor Author

mtshiba commented Nov 24, 2025

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());
Copy link
Contributor Author

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) => {
Copy link
Contributor Author

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.

Copy link
Contributor

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?

Copy link
Contributor Author

@mtshiba mtshiba Nov 26, 2025

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.

Copy link
Contributor Author

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)
@astral-sh-bot
Copy link

astral-sh-bot bot commented Nov 25, 2025

ecosystem-analyzer results

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)
Copy link
Contributor

@carljm carljm left a 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]'>`"
Copy link
Contributor

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.

Copy link
Contributor Author

@mtshiba mtshiba Nov 26, 2025

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) => {
Copy link
Contributor

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,
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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());
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Comment on lines +8458 to +8469
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),
Copy link
Contributor

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:

  1. Send nested downward as-is, always propagate None upward.
  2. Send nested=true downward, always propagate None upward.
  3. Send nested=true downward, send None upward if called nested, otherwise replace None with div.

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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?

@mtshiba mtshiba force-pushed the recursive-inference branch from b1369cf to deb919a Compare November 26, 2025 07:03
@AlexWaygood AlexWaygood removed their request for review November 26, 2025 15:12
Comment on lines +8458 to +8469
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),
Copy link
Contributor

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,
Copy link
Contributor

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.

@carljm carljm merged commit 2c0c5ff into astral-sh:main Nov 26, 2025
41 checks passed
@mtshiba mtshiba deleted the recursive-inference branch November 27, 2025 06:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ecosystem-analyzer ty Multi-file analysis & type inference

Projects

None yet

4 participants