Skip to content

[ty] Avoid expanding same-enum comparisons#26270

Closed
charliermarsh wants to merge 10 commits into
mainfrom
charlie/fix-3830-strenum-comparison
Closed

[ty] Avoid expanding same-enum comparisons#26270
charliermarsh wants to merge 10 commits into
mainfrom
charlie/fix-3830-strenum-comparison

Conversation

@charliermarsh

@charliermarsh charliermarsh commented Jun 23, 2026

Copy link
Copy Markdown
Member

Summary

When comparing two narrowed values of the same enum, we materialize the remaining members and compare their cross-product, which is extremely slow for a large enum.

This PR adds a compact representation for operands from the same enum: all members, one member, an included set, or all members except a known set. We compare those sets directly to determine overlap, truthiness, and safe branch constraints without expanding member pairs.

This also makes comparison expressions more precise when the enum's comparison behavior and member values are known. Previously, comparisons between ordinary Enum members could produce Literal[True] or Literal[False], while StrEnum, IntEnum, and other scalar-mixin enums generally fell back to a dunder method whose annotated return type was only bool. We now use the same modeled runtime values and comparison semantics for both narrowing and expression inference:

from enum import StrEnum
from typing import Literal

class Choice(StrEnum):
    FIRST = "first"
    SECOND = "second"
    THIRD = "third"
    FOURTH = "fourth"

def compare(
    left: Literal[Choice.FIRST, Choice.SECOND],
    right: Literal[Choice.THIRD, Choice.FOURTH],
):
    reveal_type(left == right)  # Literal[False]

Closes astral-sh/ty#3830.

@charliermarsh charliermarsh added the ty Multi-file analysis & type inference label Jun 23, 2026
@astral-sh-bot

astral-sh-bot Bot commented Jun 23, 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 94.47%. The percentage of expected errors that received a diagnostic held steady at 89.10%. The number of fully passing files held steady at 95/134.

@astral-sh-bot

astral-sh-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
flake8 29.13MB 29.13MB +0.00% (416.00B)
sphinx 167.30MB 167.30MB -0.00% (3.02kB) ⬇️
prefect 450.53MB 450.51MB -0.00% (17.08kB) ⬇️
trio 70.58MB 70.56MB -0.03% (19.69kB) ⬇️

Significant changes

Click to expand detailed breakdown

flake8

Name Old New Diff Outcome
Specialization 175.00kB 175.11kB +0.06% (112.00B)
UnionType 57.11kB 57.22kB +0.19% (112.00B)
GenericAlias 75.87kB 75.94kB +0.09% (72.00B)
TupleType<'db>::to_class_type_ 5.99kB 6.06kB +1.17% (72.00B)
infer_expression_types_impl 640.57kB 640.61kB +0.01% (48.00B)

sphinx

Name Old New Diff Outcome
all_narrowing_constraints_for_expression 1.90MB 1.90MB -0.08% (1.57kB) ⬇️
Type<'db>::class_member_with_policy_ 3.58MB 3.58MB -0.03% (1.20kB) ⬇️
member_lookup_with_policy_inner::interned_arguments 2.69MB 2.69MB -0.03% (840.00B) ⬇️
infer_expression_types_impl 14.75MB 14.75MB +0.01% (824.00B) ⬇️
Type<'db>::class_member_with_policy_::interned_arguments 2.35MB 2.35MB -0.03% (728.00B) ⬇️
member_lookup_with_policy_inner 4.20MB 4.20MB -0.01% (576.00B) ⬇️
BoundMethodType 772.19kB 771.64kB -0.07% (560.00B) ⬇️
is_redundant_with_impl::interned_arguments 1.13MB 1.13MB +0.04% (440.00B) ⬇️
UnionType 567.53kB 567.77kB +0.04% (240.00B) ⬇️
Specialization 1.40MB 1.40MB +0.02% (224.00B) ⬇️
is_redundant_with_impl 625.20kB 625.41kB +0.03% (216.00B) ⬇️
enum_comparison_profile 0.00B 176.00B +176.00B (new) ⬇️
GenericAlias 612.14kB 612.28kB +0.02% (144.00B) ⬇️
TupleType<'db>::to_class_type_ 62.58kB 62.72kB +0.22% (144.00B) ⬇️
enum_comparison_profile::interned_arguments 0.00B 128.00B +128.00B (new) ⬇️
... 6 more

prefect

Name Old New Diff Outcome
Type<'db>::class_member_with_policy_ 7.89MB 7.88MB -0.06% (5.02kB) ⬇️
member_lookup_with_policy_inner::interned_arguments 6.73MB 6.72MB -0.05% (3.75kB) ⬇️
Type<'db>::class_member_with_policy_::interned_arguments 5.72MB 5.72MB -0.06% (3.25kB) ⬇️
BoundMethodType 1.70MB 1.69MB -0.13% (2.27kB) ⬇️
member_lookup_with_policy_inner 10.12MB 10.12MB -0.02% (2.26kB) ⬇️
enum_comparison_profile 0.00B 1.90kB +1.90kB (new) ⬇️
all_narrowing_constraints_for_expression 5.15MB 5.14MB -0.03% (1.70kB) ⬇️
is_redundant_with_impl::interned_arguments 2.33MB 2.33MB -0.07% (1.63kB) ⬇️
enum_comparison_profile::interned_arguments 0.00B 1.25kB +1.25kB (new) ⬇️
EnumComplementType 2.66kB 3.63kB +36.36% (992.00B) ⬇️
UnionType 1.06MB 1.06MB -0.07% (832.00B) ⬇️
IntersectionType 967.86kB 967.27kB -0.06% (600.00B) ⬇️
is_redundant_with_impl 1.30MB 1.30MB -0.04% (536.00B) ⬇️
Specialization 2.66MB 2.66MB +0.02% (448.00B) ⬇️
infer_definition_types 50.57MB 50.57MB -0.00% (408.00B) ⬇️
... 16 more

trio

Name Old New Diff Outcome
Type<'db>::class_member_with_policy_ 889.12kB 882.62kB -0.73% (6.50kB) ⬇️
member_lookup_with_policy_inner::interned_arguments 765.82kB 761.37kB -0.58% (4.45kB) ⬇️
Type<'db>::class_member_with_policy_::interned_arguments 627.35kB 623.49kB -0.62% (3.86kB) ⬇️
BoundMethodType 183.05kB 180.23kB -1.54% (2.81kB) ⬇️
member_lookup_with_policy_inner 1.02MB 1.01MB -0.27% (2.77kB) ⬇️
infer_expression_types_impl 4.35MB 4.35MB -0.03% (1.46kB) ⬇️
all_narrowing_constraints_for_expression 451.17kB 450.28kB -0.20% (912.00B) ⬇️
UnionType 142.16kB 141.31kB -0.59% (864.00B) ⬇️
IntersectionType 133.91kB 134.74kB +0.62% (856.00B) ⬇️
is_redundant_with_impl::interned_arguments 218.71kB 219.48kB +0.35% (792.00B) ⬇️
enum_comparison_profile 0.00B 680.00B +680.00B (new) ⬇️
is_redundant_with_impl 120.97kB 121.43kB +0.38% (472.00B) ⬇️
enum_comparison_profile::interned_arguments 0.00B 448.00B +448.00B (new) ⬇️
Specialization 456.95kB 457.17kB +0.05% (224.00B) ⬇️
TupleType<'db>::to_class_type_ 17.25kB 17.43kB +1.04% (184.00B) ⬇️
... 5 more

@astral-sh-bot

astral-sh-bot Bot commented Jun 23, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-key 0 7 0
Total 0 7 0

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

Raw diff:

freqtrade (https://github.com/freqtrade/freqtrade)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCAnalyzedDFMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCEntryMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCExitMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCNewCandleMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCProtectionMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCStrategyMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)
- freqtrade/rpc/telegram.py:579:59 error[invalid-key] Unknown key "status" for TypedDict `RPCWhitelistMsg` (subscripted object has type `RPCStatusMsg | RPCStrategyMsg | RPCProtectionMsg | ... omitted 5 union elements`)

Full report with detailed diff (timing results)

@charliermarsh charliermarsh force-pushed the charlie/fix-3830-strenum-comparison branch 2 times, most recently from 732534f to 534e01f Compare June 23, 2026 17:05
@charliermarsh charliermarsh force-pushed the charlie/fix-3830-strenum-comparison branch from 55d526f to 5af5b6e Compare June 23, 2026 18:04
@charliermarsh charliermarsh changed the base branch from main to charlie/enum-member-exhaustiveness June 23, 2026 18:04
@charliermarsh charliermarsh added the performance Potential performance improvement label Jun 23, 2026
@charliermarsh charliermarsh force-pushed the charlie/enum-member-exhaustiveness branch from 7e97231 to c031ea2 Compare June 23, 2026 19:22
@charliermarsh charliermarsh force-pushed the charlie/fix-3830-strenum-comparison branch from 5af5b6e to d40b4b1 Compare June 23, 2026 19:23
charliermarsh added a commit that referenced this pull request Jun 24, 2026
## Summary

The equality rewrite in #25788 centralized runtime `==` and `!=`
reasoning for narrowing and match reachability, but ordinary comparison
inference continued to use separate literal and dunder logic. As a
result, two disjoint enum domains could narrow correctly while the
comparison expression itself remained `bool`, keeping an impossible
branch reachable.

This routes definite equality and inequality truthiness through the
shared comparison evaluator before falling back to the existing
inference path. Ambiguous comparisons still use normal dunder inference,
preserving custom non-boolean return types and the independence of
`__eq__` and `__ne__`.

```python
from enum import Enum
from typing import Literal

class Choice(str, Enum):
    FIRST = "first"
    SECOND = "second"
    THIRD = "third"
    FOURTH = "fourth"

def compare(
    left: Literal[Choice.FIRST, Choice.SECOND],
    right: Literal[Choice.THIRD, Choice.FOURTH],
):
    reveal_type(left == right)  # Literal[False]
    reveal_type(left != right)  # Literal[True]
```

This also resolves existing tuple comparison TODOs whose mismatched
literal elements are already known to compare unequal. With the behavior
provided here, #26270 can remain responsible for avoiding same-enum
domain expansion without changing comparison results.
@charliermarsh charliermarsh force-pushed the charlie/enum-member-exhaustiveness branch from c031ea2 to 9ca4e30 Compare June 24, 2026 00:53
@charliermarsh charliermarsh force-pushed the charlie/fix-3830-strenum-comparison branch from 2a06e75 to 3faf443 Compare June 24, 2026 00:53
@charliermarsh charliermarsh force-pushed the charlie/enum-member-exhaustiveness branch from 9ca4e30 to b619ccc Compare June 24, 2026 01:31
@charliermarsh charliermarsh force-pushed the charlie/fix-3830-strenum-comparison branch from 2ef0050 to 5b77421 Compare June 24, 2026 01:31
@charliermarsh charliermarsh marked this pull request as ready for review June 24, 2026 01:40
@charliermarsh charliermarsh requested a review from a team as a code owner June 24, 2026 01:40
@astral-sh-bot astral-sh-bot Bot requested a review from dhruvmanila June 24, 2026 01:40
@charliermarsh charliermarsh requested review from dcreager and removed request for dhruvmanila June 24, 2026 01:40
@charliermarsh charliermarsh force-pushed the charlie/enum-member-exhaustiveness branch from b619ccc to b7c3250 Compare June 24, 2026 13:52
charliermarsh added a commit that referenced this pull request Jun 24, 2026
## Summary

We currently treat the members declared on every enum as its complete
runtime domain. That assumption does not hold for `Flag` and `IntFlag`,
which also have zero and unnamed combinations, or for enums whose custom
`_missing_` method or metaclass can create additional members. As a
result, we can incorrectly treat a one-member enum as a singleton, widen
all declared members to the nominal enum, erase a non-empty complement,
or consider a `match` over the declared members exhaustive.

This adds a `members_are_exhaustive` property to `EnumClassLiteral` and
uses it wherever we rely on an enum being a finite set: complement
construction, union simplification, singleton detection,
finite-alternative narrowing, and match exhaustiveness. Open enums
retain the nominal remainder after known members are excluded.

Explicit membership tests remain precise when the enum uses identity
comparison. For example, a metaclass may add `INJECTED`, but testing
against `ONLY` still establishes that exact member on the positive
branch and excludes only that member on the negative branch:

```python
class OpenEnum(Enum, metaclass=InjectingEnumMeta):
    ONLY = 1

def check(value: OpenEnum):
    if value in (OpenEnum.ONLY,):
        reveal_type(value)  # Literal[OpenEnum.ONLY]
    else:
        reveal_type(value)  # OpenEnum & ~Literal[OpenEnum.ONLY]
```

This is the semantic foundation split out of #26270; that PR adds the
compact same-enum comparison path on top.
Base automatically changed from charlie/enum-member-exhaustiveness to main June 24, 2026 13:57
@charliermarsh charliermarsh force-pushed the charlie/fix-3830-strenum-comparison branch 2 times, most recently from 28b3415 to 3f9b8f2 Compare June 24, 2026 14:42

@jelle-openai jelle-openai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we correctly model comparison between distinct enum types?

r>>> import enum
>>> class X(enum.StrEnum):
...     a = "a"
...     
>>> class Y(enum.StrEnum):
...     a = "a"
...     
>>> X.a == Y.a
True


def compare_after_truthiness_check(left: Choice, right: Choice):
if right and left != right:
reveal_type(right) # revealed: Choice & ~AlwaysFalsy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe unrelated, but ideally we should simplify out the AlwaysFalsy here. Choice has no instances that are falsy and ty should be able to figure that out.

right: Literal[Choice.SECOND, Choice.THIRD],
):
if left == right:
reveal_type(left) # revealed: Literal[Choice.SECOND]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool that ty can do this

}

/// Return the constraint established by membership in an exact set of open-enum members.
/// Compare two compact domains from the same enum without expanding either operand.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is "compact" here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Tried to simplify.)

@charliermarsh

Copy link
Copy Markdown
Member Author

No, behavior is unchanged vs. main in that case -- it just reveals bool.

@charliermarsh charliermarsh force-pushed the charlie/fix-3830-strenum-comparison branch from 3f9b8f2 to e88a854 Compare June 24, 2026 17:26
@charliermarsh

Copy link
Copy Markdown
Member Author

I might look at that separately. I agree we should get it right.

@AlexWaygood

Copy link
Copy Markdown
Member

yeah, my original hope when I began the big equality-narrowing refactor was that we would be able to reuse the equality evaluator in type inference as well as narrowing. That should theoretically be possible, and it would be great if we could get consistent results between the two areas

@charliermarsh

Copy link
Copy Markdown
Member Author

Maybe I should explore that first though before we merge this.

@charliermarsh

Copy link
Copy Markdown
Member Author

@AlexWaygood -- do you mean like #26290? (I merged this by accident then reverted.)

@jelle-openai

Copy link
Copy Markdown
Contributor

I'm fine with comparison across different enums returning bool; it seems pretty pathological for user code to rely on cross-enum comparisons. Just want to make sure we don't incorrectly infer "it's a totally different enum, so eq must return False".

@AlexWaygood

Copy link
Copy Markdown
Member

@AlexWaygood -- do you mean like #26290? (I merged this by accident then reverted.)

yes!

@charliermarsh

Copy link
Copy Markdown
Member Author

Yeah we still get that case right (from that perspective) -- we fall back to bool via StrEnum equality after the same-enum path "fails".

@charliermarsh

Copy link
Copy Markdown
Member Author

Moving to draft while I think about the other pieces here (reusing shared equality, cross-enum comparisons).

@oconnor663 oconnor663 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation in this PR makes sense to me, modulo @AlexWaygood's comment about reuse.

Comment on lines +332 to +334
if left is NeverEqual.FIRST:
return
reveal_type(left == right) # revealed: Literal[False]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could expand this test case:

Suggested change
if left is NeverEqual.FIRST:
return
reveal_type(left == right) # revealed: Literal[False]
if left is NeverEqual.FIRST and right is NeverEqual.FIRST:
reveal_type(left == right) # revealed: Literal[False]
class AlwaysEqual(Enum):
FIRST = 1
SECOND = 2
THIRD = 3
def __eq__(self, other: object) -> Literal[True]:
return True
def _(left: AlwaysEqual, right: AlwaysEqual):
reveal_type(left == right) # revealed: Literal[True]
if left is AlwaysEqual.FIRST and right is AlwaysEqual.SECOND:
reveal_type(left == right) # revealed: Literal[True]
class EqualityUnknown(Enum):
FIRST = 1
SECOND = 2
THIRD = 3
def __eq__(self, other: object): ...
def _(left: EqualityUnknown, right: EqualityUnknown):
reveal_type(left == right) # revealed: bool
if left is EqualityUnknown.FIRST and right is EqualityUnknown.FIRST:
reveal_type(left == right) # revealed: bool
if left is EqualityUnknown.FIRST and right is EqualityUnknown.SECOND:
reveal_type(left == right) # revealed: bool

@charliermarsh

charliermarsh commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

Replacing this with #26340 which handles both same-class and across-class operations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Potential performance improvement ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repeated StrEnum comparisons become very slow after truthiness narrowing

4 participants