Skip to content

[ty] Collect intersection inference through constraint relations - #28677

Open
carljm wants to merge 9 commits into
cjm/constrained-typevar-failure-diagnosticsfrom
cjm/intersection-constraint-collection
Open

carljm wants to merge 9 commits into
cjm/constrained-typevar-failure-diagnosticsfrom
cjm/intersection-constraint-collection

Conversation

@carljm

@carljm carljm commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Intersection arguments to generic functions can offer several valid specializations, with relationships between their type variables and other arguments. Collect these constraints through the full constraint-set-assignability type relation so inference preserves variance, alternatives, and correlations. For example, another argument can select T = A from the alternatives provided by Sink[A] & Sink[B].

There are some cases we have to carve out:

  • Constraint-set assignability can currently lose constraints when gradual types are involved. [ty] Lazy evaluation of gradual constraints (v2) #28307 will fix this; until then we have to keep gradual types in the old solver to avoid regression.
  • ParamSpec and TypeVarTuple must be kept in the old solver until the new solver supports them. This includes callables with ParamSpec or TypeVarTuple that are nested inside an intersection. We don't handle this problem in general, but by far the most common case is an intersection with ~AlwaysTruthy or ~AlwaysFalsy. We avoid regression in that scenario by, whenever we have a single-positive-element intersection in covariant position, dropping the negative element(s) and recursing into the positive element in the old solver, instead of passing the intersection to the new solver. This is a sound upcast, and negative intersection elements generally don't contribute any constraints to generics solving.

We also have to add some post-validation on the bounds and constraints of non-inferrable typevars. The old constraint solver validates these, but in this PR when we pass an intersection containing a non-inferable typevar directly to the new solver, we bypass that validation. The validate_noninferable method added here can be removed once a better solution lands (see #28048 and #27652.)

This partially addresses astral-sh/ty#3557. Argument checking and return inference still use the union-merged specialization when there are multiple valid solutions, and we haven't moved unions into the new solver yet.

Test plan

  • Add paired legacy and PEP 695 mdtests for alternative selection, correlations across type variables and arguments, nested variance, bound and constraint failures, fixed outer variables, and gradual evidence.
  • Cover bounded generic constructors, truthiness-narrowed gradual ParamSpec wrappers, and classmethod Self inference from intersected receivers.
  • Mark imprecise results caused by the union-merged specialization as TODOs for per-specialization refinement.

Benchmarks

  • Reported micro-benchmark regressions don't reproduce locally in wall time; the areas CodSpeed attributes the regressions to (reachability and expression inference) don't clearly correlate with areas changed in this PR. We've been seeing this on a lot of PRs recently; deeper investigation suggests it's due to inlining choices in the compiler. Accepting the regressions since they are just ~5% on micro-benchmarks.

Ecosystem report

Analyzed head: 2c4536930678c26de7e9fe025a38c4c8407d6ee3. All 27 diagnostic changes are expected: stricter generic bound and constraint checking, three removed narrowing false positives, and return-type recovery changes after invalid calls. Full ecosystem report.

@carljm carljm added the ty Multi-file analysis & type inference label Sep 17, 2026
@astral-sh-bot

astral-sh-bot Bot commented Sep 17, 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 98.15%. The percentage of expected errors that received a diagnostic held steady at 98.07%. The number of fully passing files held steady at 133/146.

@astral-sh-bot

astral-sh-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
prefect 466.59MB 466.61MB +0.00% (21.97kB)
sphinx 167.54MB 167.54MB +0.00% (8.03kB)
trio 93.13MB 93.13MB +0.00% (904.00B)
flake8 38.22MB 38.22MB -0.00% (16.00B) ⬇️

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
when_constraint_set_assignable_to_owned_impl 3.53MB 3.53MB +0.22% (8.00kB)
TypePair 7.61MB 7.62MB +0.09% (7.12kB)
for_constraint_pair_inner::interned_arguments 2.10MB 2.10MB +0.05% (1.12kB)
for_constraint_pair_inner 2.99MB 2.99MB +0.03% (1.05kB)
GenericAlias<'db>::try_mro_ 4.01MB 4.01MB -0.02% (848.00B) ⬇️
IntersectionType 1.59MB 1.59MB +0.05% (800.00B)
simplify_intersection_pair_impl 520.49kB 521.21kB +0.14% (736.00B)
for_constraint_inner::interned_arguments 797.91kB 798.47kB +0.07% (576.00B)
VarianceVariable 400.97kB 401.44kB +0.12% (480.00B)
for_constraint_inner 758.00kB 758.47kB +0.06% (480.00B)
is_redundant_with_impl 3.07MB 3.07MB +0.01% (424.00B)
Type<'db>::cached_materialization_ 332.14kB 332.50kB +0.11% (368.00B)
Specialization 4.69MB 4.69MB -0.01% (336.00B) ⬇️
GenericAlias<'db>::variance_equation_::interned_arguments 173.85kB 174.11kB +0.15% (264.00B)
infer_scope_types_impl 19.50MB 19.50MB +0.00% (264.00B)
... 30 more

sphinx

Name Old New Diff Outcome
TypePair 2.03MB 2.03MB +0.07% (1.41kB)
infer_definition_types 11.54MB 11.54MB +0.01% (1.38kB)
infer_expression_types_impl 11.59MB 11.59MB +0.01% (1.17kB)
infer_scope_types_impl 6.16MB 6.16MB +0.02% (1.13kB)
when_constraint_set_assignable_to_owned_impl 957.24kB 958.14kB +0.09% (928.00B)
infer_unpack_types 257.41kB 258.18kB +0.30% (784.00B)
all_narrowing_constraints_for_expression 1.65MB 1.65MB +0.02% (320.00B)
loop_header_reachability 277.77kB 278.05kB +0.10% (288.00B)
may_have_unbounded_specialization_inner 3.34kB 3.62kB +8.18% (280.00B)
may_have_unbounded_specialization_inner::interned_arguments 2.62kB 2.81kB +7.14% (192.00B)
evaluate_projected_narrowing_checkpoint 247.49kB 247.62kB +0.05% (128.00B)
BoundMethodType<'db>::callables_ 185.05kB 185.15kB +0.05% (96.00B)
infer_statement_types_impl 531.35kB 531.27kB -0.01% (80.00B) ⬇️
member_lookup_with_policy_and_receiver_inner 1.69MB 1.69MB +0.00% (16.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_ 765.16kB 765.18kB +0.00% (16.00B)
... 3 more

trio

Name Old New Diff Outcome
TypePair 931.03kB 931.50kB +0.05% (480.00B)
when_constraint_set_assignable_to_owned_impl 531.54kB 531.86kB +0.06% (320.00B)
infer_expression_types_impl 6.08MB 6.08MB +0.00% (80.00B)
infer_scope_types_impl 2.82MB 2.82MB +0.00% (16.00B)
infer_definition_types 4.66MB 4.66MB +0.00% (8.00B)

flake8

Name Old New Diff Outcome
infer_scope_types_impl 536.93kB 536.92kB -0.00% (8.00B) ⬇️
infer_definition_types 1.16MB 1.16MB -0.00% (8.00B) ⬇️

@astral-sh-bot

astral-sh-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

Lint rule Added Removed Changed
unsound-return-statement 9 0 3
invalid-argument-type 5 3 0
no-matching-overload 5 0 0
invalid-return-type 0 0 1
unused-type-ignore-comment 0 1 0
Total 19 4 4
Raw diff (27 changes)
Tanjun (https://github.com/FasterSpeeding/Tanjun)
- tanjun/annotations.py:1449:31 error[invalid-argument-type] Argument to `Choices.__init__` is incorrect: Argument type `_EnumT@__getitem__ | float*` does not satisfy constraints (`int`, `float`, `str`) of type variable `_ChoiceT`
- tanjun/annotations.py:1454:31 error[invalid-argument-type] Argument to `Choices.__init__` is incorrect: Argument type `_EnumT@__getitem__ | int` does not satisfy constraints (`int`, `float`, `str`) of type variable `_ChoiceT`
- tanjun/annotations.py:1459:31 error[invalid-argument-type] Argument to `Choices.__init__` is incorrect: Argument type `_EnumT@__getitem__ | str` does not satisfy constraints (`int`, `float`, `str`) of type variable `_ChoiceT`

aiohttp (https://github.com/aio-libs/aiohttp)
- aiohttp/client.py:1497:16 error[invalid-return-type] Return type does not match returned value: expected `_RetType_co@_BaseRequestContextManager`, found `ClientResponse | _RetType_co@_BaseRequestContextManager`
+ aiohttp/client.py:1497:16 error[invalid-return-type] Return type does not match returned value: expected `_RetType_co@_BaseRequestContextManager`, found `ClientResponse | Unknown`

discord.py (https://github.com/Rapptz/discord.py)
- discord/utils.py:245:54 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
+ discord/utils.py:248:16 warning[unsound-return-statement] Unsound return statement: `Unknown | list[T_co@SequenceProxy]` is not a subtype of `list[T_co@SequenceProxy]`

rotki (https://github.com/rotki/rotki)
+ rotkehlchen/chain/decoding/tools.py:119:16 warning[unsound-return-statement] Unsound return statement: `tuple[HistoryEventType, HistoryEventSubType, str | None, Unknown | None, str, str] | None` is not a subtype of `tuple[HistoryEventType, HistoryEventSubType, str | None, A@BaseDecoderTools | None, str, str] | None`
+ rotkehlchen/chain/decoding/tools.py:122:13 error[invalid-argument-type] Argument to function `decode_transfer_direction` is incorrect: Argument type `A@BaseDecoderTools` does not satisfy constraints (`BTCAddress`, `ChecksumAddress`, `SubstrateAddress`, `SolanaAddress`) of type variable `AnyBlockchainAddress`
+ rotkehlchen/chain/decoding/tools.py:123:13 error[invalid-argument-type] Argument to function `decode_transfer_direction` is incorrect: Argument type `A@BaseDecoderTools` does not satisfy constraints (`BTCAddress`, `ChecksumAddress`, `SubstrateAddress`, `SolanaAddress`) of type variable `AnyBlockchainAddress`

spark (https://github.com/apache/spark)
+ python/pyspark/core/rdd.py:1384:36 error[invalid-argument-type] Argument to function `bisect_left` is incorrect: Argument type `K@sortByKey` does not satisfy upper bound `SupportsDunderLT[Any] | SupportsDunderGT[Any]` of type variable `SupportsRichComparisonT`
+ python/pyspark/core/rdd.py:1386:24 warning[unsound-return-statement] Unsound return statement: `Unknown` is not a subtype of `int`
+ python/pyspark/core/rdd.py:1388:24 warning[unsound-return-statement] Unsound return statement: `Unknown` is not a subtype of `int`
+ python/pyspark/core/rdd.py:1431:24 error[invalid-argument-type] Argument to bound method `RDD.keyBy` is incorrect: Argument type `S@sortBy` does not satisfy upper bound `Hashable` of type variable `K`
+ python/pyspark/core/rdd.py:2575:19 error[no-matching-overload] No overload of function `nlargest` matches arguments
+ python/pyspark/core/rdd.py:2578:20 error[no-matching-overload] No overload of function `nlargest` matches arguments
+ python/pyspark/core/rdd.py:2578:20 warning[unsound-return-statement] Unsound return statement: `Unknown` is not a subtype of `list[T@top]`
+ python/pyspark/core/rdd.py:2635:24 error[no-matching-overload] No overload of function `nsmallest` matches arguments
+ python/pyspark/core/rdd.py:2635:24 warning[unsound-return-statement] Unsound return statement: `Unknown` is not a subtype of `list[T@takeOrdered]`
+ python/pyspark/core/rdd.py:2637:20 warning[unsound-return-statement] Unsound return statement: `Unknown | list[T@takeOrdered]` is not a subtype of `list[T@takeOrdered]`
+ python/pyspark/core/rdd.py:2637:51 error[no-matching-overload] No overload of function `nsmallest` matches arguments
- python/pyspark/core/rdd.py:1173:13 warning[unsound-return-statement] Unsound return statement: `RDD[T@intersection | Unknown]` is not a subtype of `RDD[T@intersection]`
+ python/pyspark/core/rdd.py:1173:13 warning[unsound-return-statement] Unsound return statement: `RDD[Unknown]` is not a subtype of `RDD[T@intersection]`
- python/pyspark/core/rdd.py:4354:16 warning[unsound-return-statement] Unsound return statement: `RDD[T@subtract | Unknown]` is not a subtype of `RDD[T@subtract]`
+ python/pyspark/core/rdd.py:4354:16 warning[unsound-return-statement] Unsound return statement: `RDD[Unknown]` is not a subtype of `RDD[T@subtract]`
+ python/pyspark/mllib/clustering.py:1100:16 warning[unsound-return-statement] Unsound return statement: `DStream[tuple[Unknown, int]]` is not a subtype of `DStream[tuple[T@predictOnValues, int]]`
- python/pyspark/mllib/regression.py:957:16 warning[unsound-return-statement] Unsound return statement: `DStream[tuple[K@predictOnValues, Unknown]]` is not a subtype of `DStream[tuple[K@predictOnValues, float]]`
+ python/pyspark/mllib/regression.py:957:16 warning[unsound-return-statement] Unsound return statement: `DStream[tuple[Unknown, Unknown]]` is not a subtype of `DStream[tuple[K@predictOnValues, float]]`
+ python/pyspark/shuffle.py:579:16 error[no-matching-overload] No overload of function `merge` matches arguments
+ python/pyspark/shuffle.py:579:16 warning[unsound-return-statement] Unsound return statement: `Unknown` is not a subtype of `Iterable[V@ExternalSorter]`
+ python/pyspark/shuffle.py:826:33 error[invalid-argument-type] Argument to function `sorted` is incorrect: Argument type `K@ExternalGroupBy` does not satisfy upper bound `SupportsDunderLT[Any] | SupportsDunderGT[Any]` of type variable `SupportsRichComparisonT`

Full report with detailed diff (timing results)

Base automatically changed from cjm/fixed-caller-constraint-selection-standalone to main September 18, 2026 16:29
@carljm
carljm force-pushed the cjm/intersection-constraint-collection branch from c879c79 to 10eaad2 Compare September 21, 2026 16:53
@carljm
carljm requested a review from dcreager September 21, 2026 16:54
@codspeed

codspeed Bot commented Sep 21, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 153 untouched benchmarks
⏩ 60 skipped benchmarks1


Comparing cjm/intersection-constraint-collection (2c45369) with cjm/constrained-typevar-failure-diagnostics (359f061)2

Open in CodSpeed

Footnotes

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

  2. No successful run was found on cjm/constrained-typevar-failure-diagnostics (736fcac) during the generation of this report, so b6ab8ef was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@carljm
carljm force-pushed the cjm/intersection-constraint-collection branch from 5ea87e1 to 1d3e699 Compare September 21, 2026 22:22
@carljm
carljm changed the base branch from main to cjm/constrained-typevar-failure-diagnostics September 21, 2026 22:23
@carljm
carljm added this pull request to stack #28770 September 21, 2026 22:23
Comment on lines +3135 to +3140
/// Checks declarations for solutions containing type variables fixed by an outer caller.
///
/// TODO: Remove this check when solving preserves universal validity for non-inferable
/// variables. A raw constraint can accept `S <= T` with `T: str` for some `S`, but inferring
/// `T = S` is valid only if every type allowed by the caller's `S` satisfies the bound.
/// Relations involving other variables being inferred are left for subsequent solving.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Recursive inference in infer_map_impl will check S against str if matching a Source[S] argument against a Source[T] parameter, where T: str. But after this PR, the intersection arm in infer_map_impl no longer recurses if the argument is Source[S] & Unrelated, it passes the full intersection argument to constraint-set assignability, which fails to do this bounds check.

This is addressed in a better way by #28048 and #27652. This method is a temporary workaround to avoid regression in the meantime.

@carljm
carljm marked this pull request as ready for review September 21, 2026 23:47
@carljm
carljm requested a review from a team as a code owner September 21, 2026 23:47
@carljm
carljm force-pushed the cjm/intersection-constraint-collection branch from 68f183f to 038b68d Compare September 22, 2026 16:21
@carljm

carljm commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author
Ecosystem report

PR #28677 ecosystem summary

Analyzed head: 2c45369306.

The 27 diagnostic changes reflect stricter checking of generic bounds and constraints, improved inference after class narrowing, and changes to recovery after failed specialization. Spark, discord.py, and rotki pass unrestricted caller type variables to operations that require more specific types; the PR catches those missing guarantees. Tanjun loses three false positives because narrowed class arguments retain their intersections. The remaining return-message changes follow failed generic calls, including aiohttp's existing union-bound receiver limitation. All retained changes are expected; none blocks review.

Diagnostic changes (12 unsound-return-statement; 8 invalid-argument-type; 5 no-matching-overload; 1 invalid-return-type; 1 unused-type-ignore-comment)

1. Optional keys require both overload paths to be valid (5 no-matching-overload; 4 unsound-return-statement)

Report entries (9 diagnostic hits)

Spark forwards optional key functions to heapq.nlargest, heapq.nsmallest, and heapq.merge. The overload accepting a key works with arbitrary elements, but the key=None overload requires comparable elements. The implementation's unconstrained element variable does not establish that requirement. The PR rejects these calls instead of accepting the unchecked None path; the failed calls also produce Unknown, adding warnings where that result is returned as a precise collection type.

The two overloads below show the same distinction with a smaller bound: the None overload requires int elements, while the other overload accepts any element type. An unconstrained caller variable cannot satisfy both possibilities.

Optional overload selection (5 no-matching-overload; 4 unsound-return-statement).

from typing import Protocol, TypeVar, overload
T = TypeVar('T')

class Items(Protocol[T]):
    first: T
B = TypeVar('B', bound=int)

@overload
def select(items: Items[T], key: int) -> Items[T]:
    ...

@overload
def select(items: Items[B], key: None) -> Items[B]:
    ...

def select(items, key):
    ...

def wrapper(a: Items[T], key: int | None) -> Items[T]:
    # Merge base: no diagnostic
    # PR: [no-matching-overload] No overload of function `select` matches arguments
    # PR: [unsound-return-statement] Unsound return statement: `Unknown` is not a subtype of `Items[T@wrapper]`
    return select(a, key)

2. Caller type variables must guarantee callee bounds (3 invalid-argument-type; 3 unsound-return-statement; 1 unused-type-ignore-comment)

Report entries (7 diagnostic hits)

Spark passes hashable keys to bisect_left and sorted, which require comparable keys, and passes an ordering callback to RDD.keyBy, which requires hashable results. Hashability and comparability do not imply each other. The merge base accepted these calls by treating the caller's type variable as though it could be narrowed to satisfy the callee; the PR checks the caller's declared guarantee and rejects them. At the bisect_left call, failed overload resolution produces Unknown, which also adds a warning to each of the two return branches.

The caller below accepts arbitrary T, while locate requires a variable bounded by int. The PR rejects that missing guarantee; the return warning follows from the failed overload call.

Bounded overload and return recovery (3 invalid-argument-type; 2 unsound-return-statement).

from typing import Protocol, TypeVar, overload
T = TypeVar('T')

class Items(Protocol[T]):
    first: T
B = TypeVar('B', bound=int)

@overload
def locate(a: Items[B]) -> int:
    ...

@overload
def locate(a: Items[B], hi: int) -> int:
    ...

def locate(a, hi=None):
    ...

def wrapper(bounds: Items[T]) -> int:
    # Merge base: no diagnostic
    # PR: [invalid-argument-type] Argument to function `locate` is incorrect: Argument type `T@wrapper` does not satisfy upper bound `int` of type variable `B`
    # PR: [unsound-return-statement] Unsound return statement: `Unknown` is not a subtype of `int`
    return locate(bounds)

Discord's sequence proxy permits arbitrary element types but sorts them without a key. The merge base incorrectly accepted an unrestricted caller type variable where sorting requires comparable values. The PR rejects that specialization, making the existing blanket ignore necessary and producing an Unknown result that triggers a return warning. Restricting the element type to comparable values, or providing an appropriately typed key, satisfies the sorting requirement.

The reduced example expresses the sorting function's relevant bound and the collection's structural element relationship locally. Returning a single element instead of a list preserves the same failed specialization and return-warning behavior.

Ignored bound violation followed by an unsound return (1 unsound-return-statement; 1 unused-type-ignore-comment).

from typing import Protocol, TypeVar

class SupportsLT(Protocol):
    def __lt__(self, other: object, /) -> bool: ...

T = TypeVar("T", covariant=True)

class Source(Protocol[T]):
    def get(self) -> T: ...

C = TypeVar("C", bound="SupportsLT")

def ordered_values(items: Source[C]) -> C:
    raise NotImplementedError

def copied(items: Source[T]) -> T:
    # Merge base: [unused-type-ignore-comment] Unused blanket `type: ignore` directive
    # PR: no diagnostic
    result = ordered_values(items)  # type: ignore
    # Merge base: no diagnostic
    # PR: [unsound-return-statement] Unsound return statement: `Unknown` is not a subtype of `T@copied`
    return result

3. Failed receiver specializations discard partial results (4 unsound-return-statement)

Report entries (4 diagnostic hits)

These calls already violate a receiver's type-variable bound: Spark's methods require hashable keys, but their callers accept unrestricted key types. The PR no longer carries a partially inferred key type through the failed specialization. The existing argument errors remain; recovery with Unknown adds one return warning and changes three existing return warnings. These are expected consequences of rejecting the invalid specializations, rather than newly rejected valid calls.

Chained and single receiver calls (3 unsound-return-statement).

The two RDD chains previously returned RDD[T | Unknown]; they now return RDD[Unknown]. The clustering call previously kept its caller's key type despite the bound failure; losing that unsupported specialization adds its return warning. This example uses an int bound to isolate the same requirement. Both versions also report the unchanged invalid-argument-type error at each call.

from typing import Generic, TypeVar
T = TypeVar('T')
T_co = TypeVar('T_co', covariant=True)
K = TypeVar('K', bound=int)

class Batch(Generic[T_co]):

    def only_ints(self: 'Batch[K]') -> 'Batch[K]':
        raise NotImplementedError

    def checked_twice(self: 'Batch[T]') -> 'Batch[T]':
        # Merge base: [unsound-return-statement] "Unsound return statement: `Batch[T@checked_twice | Unknown]` is not a subtype of `Batch[T@checked_twice]`"
        # PR: [unsound-return-statement] "Unsound return statement: `Batch[Unknown]` is not a subtype of `Batch[T@checked_twice]`"
        return self.only_ints().only_ints()

def checked(batch: "Batch[T]") -> "Batch[T]":
    # Merge base: no unsound-return-statement warning
    # PR: [unsound-return-statement] "Unsound return statement: `Batch[Unknown]` is not a subtype of `Batch[T@checked]`"
    return batch.only_ints()

A result that was already partly unknown (1 unsound-return-statement).

In the regression model, the callback's result was already unknown because the annotated model type has no predict method. The PR additionally discards the unsupported key specialization: DStream[tuple[K, Unknown]] becomes DStream[tuple[Unknown, Unknown]]. The invalid receiver and missing attribute errors are unchanged in this reduced example.

from typing import Generic, TypeVar
T_co = TypeVar('T_co', covariant=True)
K = TypeVar('K')
BoundK = TypeVar('BoundK', bound=int)
U = TypeVar('U')

class Batch(Generic[T_co]):

    def with_value(self: 'Batch[tuple[BoundK, object]]', f: U) -> 'Batch[tuple[BoundK, U]]':
        raise NotImplementedError

def unchecked(model: object, batch: 'Batch[tuple[K, object]]') -> 'Batch[tuple[K, float]]':
    # Merge base: [unsound-return-statement] "Unsound return statement: `Batch[tuple[K@unchecked, Unknown]]` is not a subtype of `Batch[tuple[K@unchecked, float]]`"
    # PR: [unsound-return-statement] "Unsound return statement: `Batch[tuple[Unknown, Unknown]]` is not a subtype of `Batch[tuple[K@unchecked, float]]`"
    return batch.with_value(model.predict)

4. Constrained callees require guarantees from their generic callers (2 invalid-argument-type; 1 unsound-return-statement)

Report entries (3 diagnostic hits)

Rotki forwards a collection of addresses and an address lookup callback from a class whose address type variable is unconstrained to a helper that permits only four specific address types. The merge base accepts this call, although the caller's annotation does not guarantee any permitted address type. The PR rejects both arguments. Recovery replaces the inferred address type within the result with Unknown, which also produces the return warning. These are expected diagnostics for the missing constraint in the caller's annotation.

The reduced example preserves the collection's readable element type and the callback's accepted argument type as two protocols. A may be any type, so selecting C = A is not valid for every caller of run.

Caller and callee constraints (2 invalid-argument-type; 1 unsound-return-statement).

from typing import Protocol

class Source[T](Protocol):
    def value(self) -> T: ...

class Consumer[T](Protocol):
    def __call__(self, address: T) -> None: ...

def run[A](source: Source[A], consumer: Consumer[A]) -> A:
    # Merge base: no diagnostic
    # PR: [unsound-return-statement] Unsound return statement: `Unknown` is not a subtype of `A@run`
    return decode(
        # Merge base: no diagnostic
        # PR: [invalid-argument-type] Argument to function `decode` is incorrect: Argument type `A@run` does not satisfy constraints (`int`, `str`) of type variable `C`
        source,
        # Merge base: no diagnostic
        # PR: [invalid-argument-type] Argument to function `decode` is incorrect: Argument type `A@run` does not satisfy constraints (`int`, `str`) of type variable `C`
        consumer,
    )

def decode[C: (int, str)](source: Source[C], consumer: Consumer[C]) -> C:
    raise NotImplementedError

5. Class narrowing survives generic inference (3 invalid-argument-type)

Report entries (3 diagnostic hits)

Tanjun checks that an enum class subclasses float, int, or str before passing its members to a choice constructor constrained to those types. The merge base loses that narrowing while specializing the generic __members__ property: the int branch produces a mapping of E | int instead of E & int. The PR preserves the intersection, so all three branches satisfy the choice constraint and the false positives disappear.

The reduced example replaces enum member lookup with a generic function accepting a class. After issubclass, member(enum_type) has type T | int on the merge base and T & int on the PR. The same narrowing behavior accounts for the original float and str branches.

Existing ty issues: ty#3557

A narrowed class passed through a generic helper (3 invalid-argument-type).

from typing import TypeVar

T = TypeVar("T")

def member(cls: type[T]) -> T:
    raise NotImplementedError

C = TypeVar('C', int, str)

def choices_for(enum_type: type[T]) -> None:
    if issubclass(enum_type, int):
        # Merge base: [invalid-argument-type] Argument to function `accept_choice` is incorrect: Argument type `T@choices_for | int` does not satisfy constraints (`int`, `str`) of type variable `C`
        # PR: no diagnostic
        accept_choice(member(enum_type))


def accept_choice(value: C) -> None: ...

6. Recovery after an invalid union-bounded method call (1 invalid-return-type)

Report entries (1 diagnostic hit)

Aiohttp awaits __aenter__ on a type variable bounded by two response classes. Both revisions already report invalid receiver arguments because of ty's existing limitation when calling methods through a type variable with a union bound. The return diagnostic now contains ClientResponse | Unknown instead of ClientResponse | _RetType_co: the failed generic receiver specialization no longer contributes the outer type variable to the awaited result. This changes recovery after an existing error without adding a diagnostic or fixing the receiver limitation.

Existing ty issues: ty#2585

Awaited result after a failed generic receiver specialization (1 invalid-return-type).

The websocket method's bounded S expresses the relevant behavior of its original Self annotation.

from typing import TypeVar

class Response:
    async def __aenter__(self) -> "Response":
        return self

S = TypeVar("S", bound="WebSocket")

class WebSocket:
    async def __aenter__(self: S) -> S:
        return self

T = TypeVar("T", bound="Response | WebSocket")

async def enter(response: T) -> T:
    # Merge base: [invalid-return-type] Return type does not match returned value: expected `T@enter`, found `Response | T@enter`
    # PR: [invalid-return-type] Return type does not match returned value: expected `T@enter`, found `Response | Unknown`
    return await response.__aenter__()

Reproduction

  • Detailed report: ecosystem-analyzer report
  • Actions run: run 35934561574, attempt 1
  • Ruff comparison: f7e27e81a0b461eded62452aeb270747db4eb139 to 2af9c314d3cfe7f8dcb5039f2356d1b2220b2372
  • ecosystem-analyzer: a31ef389f3cc304b59623df7fe4b068419a323a4
  • mypy-primer: 3058720299b812c393ad926bcca96eede20fa683
  • Dependency cutoff: 2026-09-23T23:38:00Z
  • Project Python interpreter: 3.11 for all five projects. Effective analysis targets from project configuration: Tanjun 3.11; aiohttp 3.10; discord.py 3.8; rotki 3.14; Spark 3.11.
  • Python target platform: linux for all five projects.
  • Execution environment: Linux x86_64 containers with Python 3.11.16 and uv 0.12.11, using the exact CI profiling binaries. The containers use Debian 13 under Docker emulation on macOS; CI used Ubuntu 24.04 x86_64. Checker settings: TY_UV=scripts, UV_LOCKED=1, RUST_BACKTRACE=1, UV_NO_BUILD=1, UV_NO_BINARY=0, and the captured PR configuration installed as user-level configuration.
  • Checker deadline: 180 seconds, matching the analyzer's default dev profile with prebuilt profiling binaries.
  • Project analysis mode: Tanjun and aiohttp non-strict; discord.py, rotki, and Spark strict.
  • Comparison method: run each pinned project's mypy-primer command against both binaries with its Linux virtualenv, original project configuration, and --output-format concise. Strict projects additionally use --config analysis.strict-equality-semantics=true --config analysis.strict-generic-narrowing=true. Isolated examples preserve each project's effective Python target and strictness settings.

@carljm
carljm force-pushed the cjm/intersection-constraint-collection branch from 038b68d to 55dc223 Compare September 22, 2026 16:58
@carljm
carljm removed this pull request from stack #28770 September 22, 2026 23:57
@carljm
carljm added this pull request to stack #28798 September 22, 2026 23:57
@carljm
carljm force-pushed the cjm/intersection-constraint-collection branch from 55dc223 to 57e8f58 Compare September 23, 2026 01:30
@carljm
carljm removed this pull request from stack #28798 September 23, 2026 23:13
@carljm
carljm force-pushed the cjm/intersection-constraint-collection branch from 57e8f58 to bd15813 Compare September 23, 2026 23:13
@carljm
carljm added this pull request to stack #28850 September 23, 2026 23:13
@carljm
carljm force-pushed the cjm/intersection-constraint-collection branch from bd15813 to 2c45369 Compare September 23, 2026 23:38

This branch has not been deployed

No deployments
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