Skip to content

[ty] Infer definite equality comparison results#26337

Merged
charliermarsh merged 3 commits into
mainfrom
charlie/infer-definite-equality-results
Jun 25, 2026
Merged

[ty] Infer definite equality comparison results#26337
charliermarsh merged 3 commits into
mainfrom
charlie/infer-definite-equality-results

Conversation

@charliermarsh

@charliermarsh charliermarsh commented Jun 24, 2026

Copy link
Copy Markdown
Member

Summary

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

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

Because result inference only needs definite truthiness, the evaluator uses a truthiness-only aggregation mode for this path. Union and intersection evaluation stops as soon as an arm is ambiguous, only provides a narrowing constraint, or disagrees with a previous definite result; the full aggregation path remains unchanged for narrowing callers that need to construct constraints.

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 PR is stacked on #26338, which widens inferred class-valued attributes accessed through an instance. The regression coverage here verifies the interaction: a subclass-overridable self.response_class == Response remains bool, a Final attribute can still produce Literal[True], and module-level collections of class objects retain enough precision for exhaustive equality checks.

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

astral-sh-bot Bot commented Jun 24, 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.19%. The number of fully passing files held steady at 95/134.

@astral-sh-bot

astral-sh-bot Bot commented Jun 24, 2026

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
prefect 447.75MB 447.93MB +0.04% (185.44kB)
sphinx 166.36MB 166.53MB +0.10% (174.85kB)
trio 70.17MB 70.21MB +0.06% (39.73kB)
flake8 28.92MB 28.93MB +0.04% (12.42kB)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
infer_expression_types_impl 37.67MB 37.77MB +0.26% (98.95kB)
Type<'db>::class_member_with_policy_ 7.90MB 7.91MB +0.14% (11.26kB)
member_lookup_with_policy_inner 10.20MB 10.21MB +0.10% (10.69kB)
IntersectionType 958.84kB 969.12kB +1.07% (10.28kB)
member_lookup_with_policy_inner::interned_arguments 6.73MB 6.73MB +0.11% (7.38kB)
Specialization 2.66MB 2.67MB +0.24% (6.67kB)
Type<'db>::class_member_with_policy_::interned_arguments 5.72MB 5.73MB +0.11% (6.40kB)
UnionType 1.06MB 1.07MB +0.45% (4.88kB)
Type<'db>::apply_specialization_inner_::interned_arguments 2.95MB 2.95MB +0.16% (4.69kB)
GenericAlias 1.10MB 1.10MB +0.39% (4.36kB)
Type<'db>::apply_specialization_inner_ 2.03MB 2.03MB +0.21% (4.31kB)
TupleType<'db>::to_class_type_ 113.89kB 118.15kB +3.74% (4.26kB)
infer_scope_types_impl 30.11MB 30.11MB +0.01% (3.00kB)
infer_definition_types 49.91MB 49.91MB +0.01% (2.70kB)
is_redundant_with_impl::interned_arguments 2.34MB 2.35MB +0.11% (2.66kB)
... 20 more

sphinx

Name Old New Diff Outcome
infer_expression_types_impl 14.72MB 14.77MB +0.32% (48.17kB)
Type<'db>::class_member_with_policy_ 3.58MB 3.60MB +0.59% (21.58kB)
member_lookup_with_policy_inner 4.26MB 4.28MB +0.43% (18.61kB)
member_lookup_with_policy_inner::interned_arguments 2.69MB 2.70MB +0.54% (14.77kB)
Type<'db>::class_member_with_policy_::interned_arguments 2.35MB 2.36MB +0.53% (12.80kB)
UnionType 571.95kB 583.39kB +2.00% (11.44kB)
Specialization 1.40MB 1.41MB +0.68% (9.73kB)
Type<'db>::apply_specialization_inner_::interned_arguments 1.53MB 1.54MB +0.55% (8.59kB)
Type<'db>::apply_specialization_inner_ 1.03MB 1.04MB +0.75% (7.84kB)
TupleType<'db>::to_class_type_ 62.51kB 69.84kB +11.72% (7.33kB)
GenericAlias 612.07kB 618.40kB +1.03% (6.33kB)
IntersectionType 526.94kB 531.52kB +0.87% (4.58kB)
StaticClassLiteral<'db>::try_mro_ 1.78MB 1.78MB -0.17% (3.06kB)
is_redundant_with_impl::interned_arguments 1.14MB 1.14MB +0.17% (1.98kB)
infer_definition_types 13.67MB 13.67MB +0.01% (1.92kB)
... 13 more

trio

Name Old New Diff Outcome
infer_expression_types_impl 4.35MB 4.37MB +0.34% (14.98kB)
member_lookup_with_policy_inner 1.02MB 1.03MB +0.40% (4.16kB)
BoundMethodType 183.20kB 180.62kB -1.41% (2.58kB)
Type<'db>::apply_specialization_inner_::interned_arguments 505.78kB 508.36kB +0.51% (2.58kB)
Specialization 456.84kB 459.25kB +0.53% (2.41kB)
Type<'db>::apply_specialization_inner_ 347.88kB 350.20kB +0.66% (2.31kB)
UnionType 142.69kB 144.56kB +1.31% (1.88kB)
TupleType<'db>::to_class_type_ 17.14kB 18.84kB +9.89% (1.70kB)
IntersectionType 133.29kB 134.96kB +1.25% (1.67kB)
GenericAlias 187.38kB 188.93kB +0.83% (1.55kB)
infer_definition_types 3.96MB 3.96MB +0.04% (1.49kB)
Type<'db>::class_member_with_policy_ 889.20kB 890.69kB +0.17% (1.48kB)
is_redundant_with_impl::interned_arguments 219.57kB 220.95kB +0.63% (1.38kB)
member_lookup_with_policy_inner::interned_arguments 766.41kB 767.34kB +0.12% (960.00B)
Type<'db>::class_member_with_policy_::interned_arguments 627.15kB 627.96kB +0.13% (832.00B)
... 10 more

flake8

Name Old New Diff Outcome
infer_expression_types_impl 639.32kB 642.02kB +0.42% (2.70kB)
Type<'db>::class_member_with_policy_ 234.43kB 236.53kB +0.90% (2.10kB)
member_lookup_with_policy_inner 290.32kB 292.05kB +0.60% (1.73kB)
member_lookup_with_policy_inner::interned_arguments 205.20kB 206.60kB +0.69% (1.41kB)
Type<'db>::class_member_with_policy_::interned_arguments 168.80kB 170.02kB +0.72% (1.22kB)
Specialization 174.89kB 175.77kB +0.50% (896.00B)
GenericAlias 75.80kB 76.43kB +0.83% (648.00B)
TupleType<'db>::to_class_type_ 5.88kB 6.52kB +10.76% (648.00B)
UnionType 57.11kB 57.66kB +0.96% (560.00B)
Type<'db>::apply_specialization_inner_ 118.64kB 119.18kB +0.45% (552.00B)
Type<'db>::apply_specialization_inner_::interned_arguments 175.31kB 175.78kB +0.27% (480.00B)
infer_definition_types 1001.04kB 1000.59kB -0.05% (464.00B)
IntersectionType 50.88kB 51.06kB +0.35% (184.00B)
is_redundant_with_impl::interned_arguments 88.43kB 88.60kB +0.19% (176.00B)
all_narrowing_constraints_for_expression 70.62kB 70.47kB -0.21% (152.00B)
... 4 more

@astral-sh-bot

astral-sh-bot Bot commented Jun 24, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-argument-type 0 13 0
invalid-key 0 7 0
unresolved-attribute 0 5 0
invalid-assignment 0 2 0
possibly-unresolved-reference 0 1 0
unsupported-operator 0 1 0
Total 0 29 0

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

Raw diff (29 changes)
cloud-init (https://github.com/canonical/cloud-init)
- tests/unittests/distros/test_opensuse.py:287:16 error[unresolved-attribute] Object of type `Distro` has no attribute `read_only_root`
- tests/unittests/distros/test_opensuse.py:308:16 error[unresolved-attribute] Object of type `Distro` has no attribute `read_only_root`
- tests/unittests/distros/test_opensuse.py:329:16 error[unresolved-attribute] Object of type `Distro` has no attribute `read_only_root`

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`)

psycopg (https://github.com/psycopg/psycopg)
- tests/test_cursor_common.py:159:24 error[unresolved-attribute] Module `psycopg` has no member `ProgrammingError`
- tests/test_cursor_common_async.py:157:24 error[unresolved-attribute] Module `psycopg` has no member `ProgrammingError`

pycryptodome (https://github.com/Legrandin/pycryptodome)
- lib/Crypto/IO/PKCS8.py:210:33 error[invalid-argument-type] Argument to bound method `DerSequence.decode` is incorrect: Expected `bytes`, found `None`
- lib/Crypto/IO/PKCS8.py:210:45 error[invalid-argument-type] Argument to bound method `DerSequence.decode` is incorrect: Expected `int | None`, found `tuple[Literal[1], Literal[2]]`
- lib/Crypto/IO/PKCS8.py:211:37 error[invalid-argument-type] Argument to bound method `DerObjectId.decode` is incorrect: Expected `bytes`, found `None`
- lib/Crypto/IO/PKCS8.py:216:30 error[invalid-argument-type] Argument to bound method `DerObject.decode` is incorrect: Expected `bytes`, found `None`
- lib/Crypto/IO/PKCS8.py:222:43 error[invalid-argument-type] Argument to bound method `DerObject.decode` is incorrect: Expected `bytes`, found `None`
- lib/Crypto/PublicKey/ECC.py:833:44 error[invalid-argument-type] Argument to bound method `DerObject.decode` is incorrect: Expected `bytes`, found `None`
- lib/Crypto/PublicKey/ECC.py:840:57 error[invalid-argument-type] Argument to bound method `DerObjectId.decode` is incorrect: Expected `bytes`, found `None`
- lib/Crypto/PublicKey/ECC.py:865:62 error[invalid-argument-type] Argument to bound method `DerBitString.decode` is incorrect: Expected `bytes`, found `None`
- lib/Crypto/PublicKey/RSA.py:700:22 error[invalid-argument-type] Method `__getitem__` of type `bound method DerSequence.__getitem__(n: int) -> None` cannot be called with key of type `slice[Literal[1], Literal[6], None]` on object of type `DerSequence`
- lib/Crypto/PublicKey/RSA.py:700:22 error[unsupported-operator] Operator `+` is not supported between objects of type `None` and `list[IntegerBase]`
- lib/Crypto/PublicKey/RSA.py:700:42 error[invalid-argument-type] Argument to `IntegerBase.__init__` is incorrect: Expected `IntegerBase | int`, found `None`
- lib/Crypto/PublicKey/RSA.py:700:58 error[invalid-argument-type] Argument to bound method `IntegerBase.inverse` is incorrect: Expected `IntegerBase | int`, found `None`

trio (https://github.com/python-trio/trio)
- src/trio/_core/_tests/test_run.py:2440:24 error[invalid-assignment] Object of type `Value[object] | Error` is not assignable to `Outcome[str] | None`
- src/trio/_core/_tests/test_run.py:2457:34 error[invalid-argument-type] Argument to bound method `CoroutineType.send` is incorrect: Expected `Outcome[object]`, found `None`
- src/trio/_core/_tests/test_run.py:2470:24 error[invalid-argument-type] Argument to bound method `CoroutineType.send` is incorrect: Expected `Outcome[object]`, found `None`

werkzeug (https://github.com/pallets/werkzeug)
- tests/test_test.py:150:9 error[invalid-assignment] Invalid subscript assignment with key of type `Literal["test_int"]` and value of type `Literal[1]` on object of type `MultiDict[str, str]`

xarray (https://github.com/pydata/xarray)
- xarray/tests/test_cftime_offsets.py:326:22 warning[possibly-unresolved-reference] Name `expected` used when possibly not defined

Full report with detailed diff (timing results)

@charliermarsh

Copy link
Copy Markdown
Member Author

@AlexWaygood -- this currently contains a bunch of false negatives because we don't seem to consider mutable fields. An example would be like:

class Response: ...
class HtmlResponse(Response): ...

class TestResponse:
    response_class = Response

    def check(self) -> None:
        if self.response_class == Response:
            print("plain response")
        else:
            print("specialized response")

class TestHtmlResponse(TestResponse):
    response_class = HtmlResponse

On this PR, we consider the else to be unreachable.

@codspeed-hq

codspeed-hq Bot commented Jun 24, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 73 untouched benchmarks
⏩ 64 skipped benchmarks1


Comparing charlie/infer-definite-equality-results (96d5300) with main (6a0d2ec)

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.

@charliermarsh

Copy link
Copy Markdown
Member Author

Codex is trying to fix that by promoting some top-level class literals, e.g.:

+    /// Promote top-level class literals to the class objects represented by `type[...]`.
+    ///
+    /// This is intentionally separate from regular promotion. Applying it during collection
+    /// inference would lose useful precision for local and module-level collections of class
+    /// objects.
+    pub(crate) fn promote_class_literals(self, db: &'db dyn Db) -> Type<'db> {
+        match self {
+            Type::ClassLiteral(class) => {
+                SubclassOfType::from(db, class.default_specialization(db))
+            }
+            Type::Union(union) => union.map_leave_aliases(db, |element| {
+                element.promote_class_literals(db)
+            }),
+            _ => self,
+        }
+    }

@AlexWaygood

Copy link
Copy Markdown
Member

@AlexWaygood -- this currently contains a bunch of false negatives because we don't seem to consider mutable fields. An example would be like:

I think we'll have to stop inferring self.response_class as <class 'Response'> there anyway at some point if we want to enforce Liskov on attributes (which we do!) as well as on methods. Otherwise TestHtmlResponse is a Liskov violation, because TestHtmlResponse.response_class has type <class 'HtmlResponse'>, which is a disjoint type to <class 'Response'> (the type of TestResponse.response_class). But this is obviously a common pattern and there will be reasonable uproar from the community if we complain that this kind of thing is a Liskov violation 😆

We've discussed before about whether we need to "promote" class-literal types to their supertypes in more contexts when they're unannotated. I think this is another piece of evidence that we do need to do that.

@charliermarsh

Copy link
Copy Markdown
Member Author

Okay, I will try that in a separate PR first.

@AlexWaygood

Copy link
Copy Markdown
Member

yeah, I think codex is on the right track there, but we'd definitely want to do that as a standalone change first rather than here

@charliermarsh charliermarsh force-pushed the charlie/infer-definite-equality-results branch from a708685 to e8d9d71 Compare June 24, 2026 18:45
@charliermarsh charliermarsh changed the base branch from main to charlie/widen-class-literal-instance-attributes June 24, 2026 18:45
@charliermarsh charliermarsh force-pushed the charlie/widen-class-literal-instance-attributes branch from dd25afa to c2b82e8 Compare June 24, 2026 19:12
@charliermarsh charliermarsh force-pushed the charlie/infer-definite-equality-results branch from e8d9d71 to e923e73 Compare June 24, 2026 19:12
@charliermarsh charliermarsh force-pushed the charlie/widen-class-literal-instance-attributes branch from 3947599 to 1bbd9a8 Compare June 24, 2026 19:59
@charliermarsh charliermarsh force-pushed the charlie/infer-definite-equality-results branch from e923e73 to faa199e Compare June 24, 2026 20:11
@charliermarsh charliermarsh marked this pull request as ready for review June 24, 2026 21:51
@charliermarsh charliermarsh requested a review from a team as a code owner June 24, 2026 21:51
@charliermarsh

Copy link
Copy Markdown
Member Author

I think the ecosystem results here are now generally acceptable. There's one diagnostic we're now discarding due to incorrect nonlocal flow handling, but that already exists on main. The rest are unreachable branches where we now discard diagnostics, I think.

@charliermarsh charliermarsh marked this pull request as draft June 24, 2026 22:29
@charliermarsh charliermarsh force-pushed the charlie/widen-class-literal-instance-attributes branch from 1bbd9a8 to 0d56725 Compare June 24, 2026 23:38
@charliermarsh charliermarsh marked this pull request as ready for review June 24, 2026 23:38
@charliermarsh charliermarsh force-pushed the charlie/infer-definite-equality-results branch 2 times, most recently from 28c3746 to 82e9e58 Compare June 25, 2026 00:00
Base automatically changed from charlie/widen-class-literal-instance-attributes to main June 25, 2026 11:38
@charliermarsh charliermarsh force-pushed the charlie/infer-definite-equality-results branch from 82e9e58 to 9fb38ce Compare June 25, 2026 11:40

@AlexWaygood AlexWaygood left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think there's even more we could do to unify the two modules in the future, but this is a great improvement on its own for now, thanks!

Comment on lines +256 to +279
/// Selects how recursive comparison results are combined.
///
/// The goal is only an optimization; both modes use the same comparison semantics and agree on
/// which results are definite. [`Constraint`](Self::Constraint) preserves branch-specific narrowing
/// for the left operand. [`Truthiness`](Self::Truthiness) can discard those constraints because its
/// caller only needs to know whether every expanded alternative agrees, and can stop as soon as the
/// comparison cannot be definite.
///
/// For example, truthiness evaluation proves that this comparison is always false by checking the
/// finite alternatives on both sides, without constructing a narrowing constraint:
///
/// ```python
/// from enum import Enum
/// from typing import Literal
///
/// class Choice(Enum):
/// A = 1
/// B = 2
/// C = 3
/// D = 4
///
/// def compare(left: Literal[Choice.A, Choice.B], right: Literal[Choice.C, Choice.D]):
/// reveal_type(left == right) # Literal[False]
/// ```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is clever. Thanks for documenting it so clearly!

@charliermarsh charliermarsh merged commit dbe6e98 into main Jun 25, 2026
63 checks passed
@charliermarsh charliermarsh deleted the charlie/infer-definite-equality-results branch June 25, 2026 14:43
charliermarsh added a commit that referenced this pull request Jun 25, 2026
## Summary

Since #25955 and #26337, equality narrowing, match reachability,
membership compatibility, and ordinary `==` and `!=` result inference
all use the same comparison evaluator, within which comparing large
enums ends up performing a large member cross-product operation.

This PR adds a fast path for enums, whereby same-class operands use
compact member sets and cross-class / multi-class unions project members
onto cached runtime comparison keys. The cache is per enum class and
comparison operator, so repeated comparisons reuse the class-wide scan
instead of repeating it for every type pair or expression. Because it's
in the shared evaluator, this optimization applies consistently to
equality and inequality narrowing, definite comparison results and
resulting reachability, value-pattern narrowing, and membership
compatibility.

(Note: `SameEnumComparison` is a fast path within the fast path for
enums of the same class. Within the same class, we can just compare
member-name sets; for different classes, we need project down to a
value.)

In quick local benchmarks against current `main`, the original
astral-sh/ty#3830 reproducer drops from roughly 14.4 s to 9.3 ms, a
comparison between two 256-member `StrEnum`s drops from roughly 242 ms
to 10.8 ms, and a comparison between unions spanning 16 enum classes
drops from roughly 296 ms to 10.9 ms.

Closes astral-sh/ty#3830.
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.

2 participants