Skip to content

[ty] Prevent unbounded expansion of recursive union aliases - #26882

Draft
mtshiba wants to merge 1 commit into
mainfrom
recursive-union-expansion
Draft

[ty] Prevent unbounded expansion of recursive union aliases#26882
mtshiba wants to merge 1 commit into
mainfrom
recursive-union-expansion

Conversation

@mtshiba

@mtshiba mtshiba commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Stacked on #26503

Test Plan

mdtest updated

@mtshiba mtshiba added the ty Multi-file analysis & type inference label Jul 16, 2026
@astral-sh-bot

astral-sh-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 96.83%. The percentage of expected errors that received a diagnostic held steady at 91.74%. The number of fully passing files held steady at 99/133.

@astral-sh-bot

astral-sh-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
sphinx 165.58MB 165.72MB +0.09% (144.76kB)
prefect 447.08MB 447.21MB +0.03% (136.12kB)
trio 69.83MB 69.85MB +0.04% (25.41kB)
flake8 28.68MB 28.69MB +0.03% (8.58kB)

Significant changes

Click to expand detailed breakdown

sphinx

Name Old New Diff Outcome
TupleType<'db>::to_class_type_with_context_ 0.00B 79.70kB +79.70kB (new)
TupleType<'db>::to_class_type_with_context_::interned_arguments 0.00B 71.69kB +71.69kB (new)
TupleType<'db>::to_class_type_ 68.21kB 0.00B -100.00% (68.21kB) ⬇️
infer_expression_types_impl 15.17MB 15.19MB +0.12% (18.05kB)
StaticClassLiteral<'db>::try_mro_ 1.82MB 1.83MB +0.73% (13.59kB)
TypePair 1.61MB 1.60MB -0.53% (8.77kB) ⬇️
infer_definition_types 13.68MB 13.69MB +0.06% (8.45kB)
Specialization 1.57MB 1.58MB +0.44% (7.12kB)
is_redundant_with_impl 665.38kB 659.59kB -0.87% (5.79kB) ⬇️
infer_scope_types_impl 8.01MB 8.01MB +0.06% (4.59kB)
GenericAlias 705.16kB 709.66kB +0.64% (4.50kB)
IntersectionType 599.68kB 595.25kB -0.74% (4.43kB) ⬇️
StaticClassLiteral<'db>::try_mro_::interned_arguments 612.00kB 616.29kB +0.70% (4.29kB)
all_narrowing_constraints_for_expression 2.16MB 2.16MB +0.18% (3.88kB)
UnionType 578.58kB 582.23kB +0.63% (3.66kB)
... 31 more

prefect

Name Old New Diff Outcome
TupleType<'db>::to_class_type_with_context_ 0.00B 116.54kB +116.54kB (new)
TupleType<'db>::to_class_type_ 108.55kB 0.00B -100.00% (108.55kB) ⬇️
TupleType<'db>::to_class_type_with_context_::interned_arguments 0.00B 106.44kB +106.44kB (new)
TypePair 3.15MB 3.13MB -0.45% (14.35kB) ⬇️
Type<'db>::class_member_with_policy_inner_ 9.15MB 9.16MB +0.11% (9.95kB)
infer_expression_types_impl 38.73MB 38.74MB +0.02% (7.70kB)
is_redundant_with_impl 1.33MB 1.33MB -0.51% (6.89kB) ⬇️
StaticClassLiteral<'db>::try_mro_ 3.46MB 3.46MB +0.18% (6.31kB)
infer_scope_types_impl 29.93MB 29.93MB +0.02% (5.40kB)
IntersectionType 1.02MB 1.02MB -0.34% (3.61kB) ⬇️
Specialization 3.04MB 3.04MB +0.12% (3.58kB)
infer_definition_types 50.13MB 50.13MB +0.01% (3.48kB)
infer_unpack_types 625.63kB 628.14kB +0.40% (2.52kB)
GenericAlias 1.31MB 1.31MB +0.17% (2.32kB)
StaticClassLiteral<'db>::try_mro_::interned_arguments 1.21MB 1.21MB +0.16% (2.04kB)
... 27 more

trio

Name Old New Diff Outcome
TupleType<'db>::to_class_type_with_context_ 0.00B 20.81kB +20.81kB (new)
TupleType<'db>::to_class_type_ 17.83kB 0.00B -100.00% (17.83kB) ⬇️
TupleType<'db>::to_class_type_with_context_::interned_arguments 0.00B 17.31kB +17.31kB (new)
infer_expression_types_impl 4.50MB 4.51MB +0.09% (4.33kB)
TypePair 348.48kB 345.98kB -0.72% (2.49kB) ⬇️
all_narrowing_constraints_for_expression 511.92kB 513.24kB +0.26% (1.32kB)
is_redundant_with_impl 131.27kB 130.07kB -0.92% (1.20kB) ⬇️
IntersectionType 147.73kB 146.59kB -0.78% (1.15kB) ⬇️
infer_scope_types_impl 2.38MB 2.38MB +0.04% (1.04kB)
infer_definition_types 3.97MB 3.97MB +0.03% (1.03kB)
analyze_non_terminal_call 386.59kB 387.30kB +0.18% (720.00B)
infer_expression_type_impl 40.05kB 40.67kB +1.54% (632.00B)
infer_unpack_types 88.83kB 89.39kB +0.62% (568.00B)
loop_header_reachability 71.03kB 71.21kB +0.26% (192.00B)
infer_deferred_types 1.18MB 1.18MB +0.01% (72.00B)
... 11 more

flake8

Name Old New Diff Outcome
TupleType<'db>::to_class_type_with_context_ 0.00B 7.40kB +7.40kB (new)
TupleType<'db>::to_class_type_with_context_::interned_arguments 0.00B 6.50kB +6.50kB (new)
TupleType<'db>::to_class_type_ 6.07kB 0.00B -100.00% (6.07kB) ⬇️
TypePair 132.69kB 132.00kB -0.52% (704.00B) ⬇️
infer_definition_types 1012.43kB 1012.97kB +0.05% (552.00B)
infer_expression_types_impl 669.88kB 670.41kB +0.08% (544.00B)
is_redundant_with_impl 52.43kB 52.09kB -0.64% (344.00B) ⬇️
IntersectionType 56.76kB 56.43kB -0.58% (336.00B) ⬇️
infer_scope_types_impl 508.89kB 509.14kB +0.05% (256.00B)
all_narrowing_constraints_for_expression 81.50kB 81.62kB +0.14% (120.00B)
when_constraint_set_assignable_to_owned_impl 96.26kB 96.35kB +0.10% (96.00B)
infer_unpack_types 23.87kB 23.96kB +0.36% (88.00B)
code_generator_of_static_class 16.49kB 16.56kB +0.43% (72.00B)
StaticClassLiteral<'db>::try_mro_ 224.68kB 224.75kB +0.03% (72.00B)
try_metaclass_inner 24.17kB 24.24kB +0.29% (72.00B)
... 11 more

@astral-sh-bot

astral-sh-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

No diagnostic changes detected ✅

Flaky changes detected. This PR summary excludes flaky changes; see the HTML report for details.

Full report with detailed diff (timing results)

@codspeed-hq

codspeed-hq Bot commented Jul 16, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 95 untouched benchmarks
⏩ 64 skipped benchmarks1


Comparing recursive-union-expansion (aa852c5) with main (cc42899)

Open in CodSpeed

Footnotes

  1. 64 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.

@mtshiba
mtshiba force-pushed the recursive-union-expansion branch 3 times, most recently from 38a3ca5 to 827bd05 Compare July 16, 2026 20:18
mtshiba added a commit that referenced this pull request Jul 19, 2026
## Summary

Fixes ~~astral-sh/ty#3452~~
I intended to fix it, but the fixes were separated into #26881, #26882
and #26898. This PR provides a foundation for fixes, but there are
observable improvements on this PR alone. For example:

```py
from ty_extensions import static_assert
from ty_extensions._internal import is_subtype_of

type Left[T] = tuple[Left[list[T]]]
type Right[T] = tuple[Right[list[T]]]

# TODO: Left[int] should be equivalent to (subtype of) Right[int]
static_assert(not is_subtype_of(Left[int], Right[int]))
```

This will result in stack overflow in the current main.

A recursive type like `type StableRecursiveList[T] = T |
list[StableRecursiveList[T]]` can be checked without any problem even in
the current main. This is because the specialization that appears in the
recursive type on the right side is the same as the left side, so the
recursion guard using simple type equality works.

The problem here is that among recursive aliases, the specialization on
the right-hand side grows with each expansion. Current recursion guards
cannot notice such type alias reentrancy.

Therefore, when considering generic recursive type aliases, two levels
of protection must be applied, distinguishing between equivalence based
on type definition identity and full type equivalence, including
specialization. Applying a recursive guard that only considers the
latter will fail to detect cases of growing specialization (this is
exactly the problem exposed by MRE). On the other hand, if we guard by
considering only the former, specialization will not be considered, so
we will treat `GrowingList[int]` and `GrowingList[str]` as the same
thing.

With this PR, the `CycleDetector` will now return a `Cycle` state in
addition to `Ready` and `Pending` as a result of a visit. If the type
aliases, including specializations, are equivalent to types already seen
during the visit, the detector will still return the fallback value as
`Ready`. If the type aliases are the same but the specializations are
different, return `Cycle` and ask the upstream relation checker to
decide. In the case of `TypeRelationChecker`, when this is received, it
executes `recursive_type_pair_fallback` to complete the determination.

---

So, what should `recursive_type_pair_fallback` do? If we call
`check_type_pair` during this process, we will enter recursion again, so
we need to make it a finite process. As I thought about it, I realized
that this problem was undecidable. There is no general subtyping
algorithm for growing recursive type aliases. This is because they will
have expressive power equal to or greater than that of context-free
grammars [^1]. In other words, determining the subtype of two such
recursive aliases is the same problem as determining the equivalence and
inclusion of two CFGs that are known to be undecidable.

```py
from typing import Literal, final
from ty_extensions import static_assert
from ty_extensions._internal import is_subtype_of

@Final
class End:
    pass

type AAnd[Rest] = tuple[Literal["a"], Rest]
type BAnd[Rest] = tuple[Literal["b"], Rest]

# S -> ε | aSb
# {a^n b^n | n >= 0}
type S[Rest] = Rest | AAnd[S[BAnd[Rest]]]
# T -> ε | aTb | aaTbb
# {a^n b^n | n >= 0}
type T[Rest] = Rest | AAnd[T[BAnd[Rest]]] | AAnd[AAnd[T[BAnd[BAnd[Rest]]]]]

# S and T produce exactly the same language, but the type checker cannot tell that.
static_assert(not is_subtype_of(S[End], T[End]))
```

Therefore, we have to give up on this kind of recursive alias typing at
some point (FYI, mypy makes growing recursive aliases like this illegal
in the first place; pyright allows them, but seems to just have a
recursion depth limit).
In this PR, it simply returns a conservative solution immediately when a
growing pattern of recursive type alias is detected. In reality, we can
extend the support a little more, but we'll leave that as future work
and focus on fixes first.

---

#26881 strengthens the recursion guard in `TypeTransformer` under this
PR change. This directly fixes #3452.
#26882 strengthens the recursion guard in `UnionBuilder` under this PR
change. This will properly stop the expansion of recursive union aliases
that would result in stack overflow in the current main.
#26898 adds `RecursionGuard` that wraps `TypeCollector`.

[^1]: To be more specific, the type argument of type alias can be
considered as the stack memory of a pushdown automaton. If the generic
type alias itself appears on the right side with a different
specialization than the left side, it corresponds to being able to push
additional information onto the stack along with the state transition.
If it has only trivial specializations like the left side, it cannot be
used as a stack, and its abilities are equivalent to a finite automaton.

## Test Plan

new mdtest cases

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant