Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions crates/ruff_benchmark/benches/ty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1551,6 +1551,64 @@ fn benchmark_factored_upper_bounds(criterion: &mut Criterion) {
});
}

/// Benchmarks independent alternatives that constrain only non-inferable type variables.
///
/// Each binary alternative doubles the number of complete paths in the original decision diagram.
/// Support-aware solution walking should avoid enumerating that cross product whether the
/// inferable constraint appears before or after the hidden alternatives.
fn benchmark_independent_noninferable_alternatives(criterion: &mut Criterion) {
setup_rayon();

for alternative_count in [4, 8, 12, 16, 20] {
for inferable_first in [true, false] {
let mut code =
String::from("from ty_extensions._internal import ConstraintSet\n\ndef infer[I");
for index in 0..alternative_count {
write!(&mut code, ", N{index}").ok();
}
code.push_str("]() -> None:\n");

if inferable_first {
code.push_str(" constraints = ConstraintSet.range(int, I, int)\n");
} else {
code.push_str(
" constraints = ConstraintSet.range(str, N0, str) | ConstraintSet.range(bytes, N0, bytes)\n",
);
}

let first_hidden = usize::from(!inferable_first);
for index in first_hidden..alternative_count {
writeln!(
&mut code,
" constraints &= ConstraintSet.range(str, N{index}, str) | ConstraintSet.range(bytes, N{index}, bytes)"
)
.ok();
}

if !inferable_first {
code.push_str(" constraints &= ConstraintSet.range(int, I, int)\n");
}
code.push_str(" constraints.solutions(inferable=tuple[I])\n");

let position = if inferable_first { "first" } else { "last" };
let benchmark_name = format!(
"ty_micro[independent_noninferable_alternatives/{position}/{alternative_count}]"
);
criterion.bench_function(&benchmark_name, |b| {
b.iter_batched_ref(
|| setup_micro_case(&code),
|case| {
let Case { db, .. } = case;
let result = db.check();
assert_eq!(result.len(), 0);
},
BatchSize::SmallInput,
);
});
}
}
}

/// Guards against quadratic pruning when contravariant callbacks contribute many upper-only bounds.
fn benchmark_many_upper_bound_callbacks(criterion: &mut Criterion) {
const NUM_CALLBACKS: usize = 1_200;
Expand Down Expand Up @@ -2161,6 +2219,7 @@ criterion_group!(
benchmark_typeis_narrowing,
benchmark_repeated_statement_calls,
benchmark_factored_upper_bounds,
benchmark_independent_noninferable_alternatives,
benchmark_many_upper_bound_callbacks,
benchmark_pandas_tdd,
benchmark_mixed_typed_dict_union_copy,
Expand Down
141 changes: 141 additions & 0 deletions crates/ty_python_semantic/resources/mdtest/bidirectional.md
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,147 @@ x1: dict[Hashable, Callable[..., object]] = {"x": lambda: 1}
x2: dict[Hashable, Callable[..., object]] = dict(x=lambda: 1)
```

## Covariant constructors with outer return contexts

A bounded, defaulted, covariant constructor should use its outer return context instead of falling
back to its declared default. These false positives are pending
[#26680](https://github.com/astral-sh/ruff/pull/26680), which will conjoin contextual return
constraints with argument constraints before solving.

```py
from __future__ import annotations

from typing import Generic
from typing_extensions import Self, TypeVar

class Client:
def no_argument(self) -> EmptyBox[Self]:
# TODO(#26680): Infer `EmptyBox[Self]`.
# error: [invalid-return-type] "expected `EmptyBox[Self@no_argument]`, found `EmptyBox[Client]`"
return EmptyBox()

T = TypeVar("T", bound=Client, default=Client, covariant=True)

class EmptyBox(Generic[T]):
def __init__(self) -> None:
pass

class Holder(Generic[T]):
def related(self) -> RelatedBox[T]:
# TODO(#26680): Infer `RelatedBox[T]`.
# error: [invalid-return-type] "expected `RelatedBox[T@Holder]`, found `RelatedBox[Client]`"
return RelatedBox(self)

class RelatedBox(Generic[T]):
def __init__(self, holder: Holder[T]) -> None:
self.holder = holder
```

## Dataclass constructors with outer return contexts

A covariant dataclass argument referring to outer `Self` should not specialize to its declared
default. The resulting return and argument false positives are pending
[#26680](https://github.com/astral-sh/ruff/pull/26680).

```py
from __future__ import annotations

from dataclasses import dataclass
from typing import Generic
from typing_extensions import Self, TypeVar

class PartialUser:
def equipped(self, present: bool) -> Equipped[Self]:
# TODO(#26680): Infer `Equipped[Self]`.
# error: [invalid-return-type]
return Equipped(
# TODO(#26680): Accept `Item[Self]`.
# error: [invalid-argument-type] "Expected `Item[User] | None`, found `Item[Self@equipped] | None`"
first=Item(self) if present else None,
)

class User(PartialUser):
pass

UserT = TypeVar("UserT", bound=PartialUser, default=User, covariant=True)

class Item(Generic[UserT]):
def __init__(self, owner: UserT) -> None:
self.owner = owner

@dataclass
class Equipped(Generic[UserT]):
first: Item[UserT] | None
```

## Callback diagnostics retain contextual outer type variables

A constrained outer return type should remain visible in an invalid callback diagnostic. The
callback itself is still invalid, but replacing its expected result with `Unknown` loses useful
information. Restoring the outer result is pending
[#26680](https://github.com/astral-sh/ruff/pull/26680).

```py
from collections.abc import Callable
from typing import Generic, TypeVar

C = TypeVar("C", str, bytes, covariant=True)
Selector = TypeVar("Selector", bound=Callable[[int], C]) # error: [invalid-type-variable-bound]

class CallbackView(Generic[C]):
def __init__(self, callback: Callable[[int], C]) -> None:
self.callback = callback

class CallbackInterface(Generic[C]):
def __init__(self, callback: Selector) -> None:
self.callback = callback

def view(self) -> CallbackView[C]:
# TODO(#26680): Expect `(int, /) -> C@CallbackInterface`.
# error: [invalid-return-type]
# error: [invalid-argument-type] "Expected `(int, /) -> Unknown`, found `Selector@__init__`"
return CallbackView(self.callback)
```

## Callable factories retain contextual outer element types

Narrowing a union to a callable should not replace its outer iterable element type with `Unknown`.
The factory argument remains invalid, but its expected result should preserve the outer `CT`. Its
correct diagnostic is pending [#26680](https://github.com/astral-sh/ruff/pull/26680); the separate
narrowed-sequence constructor must remain valid.

```py
from collections.abc import Callable, Iterable, Iterator, Sequence
from typing import Generic, TypeVar

RT = TypeVar("RT")
CT = TypeVar("CT")

class FactoryView(Iterable[RT], Generic[RT]):
def __init__(self, factory: Callable[[], Iterable[RT]]) -> None:
self.factory = factory

def __iter__(self) -> Iterator[RT]:
return iter(self.factory())

class SequenceView(Iterable[RT], Generic[RT]):
def __init__(self, sequence: Sequence[RT]) -> None:
self.sequence = sequence

def __iter__(self) -> Iterator[RT]:
return iter(self.sequence)

def build_iter_view(matches: Iterable[CT] | Callable[[], Iterable[CT]]) -> Iterable[CT]:
if callable(matches):
# TODO(#26680): Expect `() -> Iterable[CT@build_iter_view]`.
# error: [invalid-return-type]
# error: [invalid-argument-type] "Expected `() -> Iterable[Unknown]`"
return FactoryView(matches)
if not isinstance(matches, Sequence):
matches = list(matches)
return SequenceView(matches)
```

## Generic call argument inference

A function's arguments are also inferred using the type context:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,34 +97,24 @@ def nested_transitive[T, U, V]() -> None:
ConstraintSet.range(Never, T, list[U]) & ConstraintSet.range(Never, U, int) & ConstraintSet.range(list[int], T, object)
) | ConstraintSet.range(bytes, V, object)

# TODO: sometimes: revealed tuple[Solution[T=list[int]], Solution[T=Never], Solution[]]
# TODO: sometimes: revealed tuple[Solution[T=list[int]], Solution[T=list[int]], Solution[]]
# TODO: sometimes: revealed tuple[Solution[T=list[int]], Solution[], Solution[]]
# revealed: tuple[Solution[T=list[int]], Solution[]]
reveal_type(constraints.solutions_for(T, inferable=tuple[T, U, V]))

# TODO: sometimes: revealed tuple[Solution[U=int], Solution[U=Never], Solution[]]
# TODO: sometimes: revealed tuple[Solution[U=int], Solution[], Solution[]]
# revealed: tuple[Solution[U=int], Solution[]]
reveal_type(constraints.solutions_for(U, inferable=tuple[T, U, V]))

# TODO: sometimes: revealed tuple[Solution[], Solution[V=bytes], Solution[V=bytes]]
# revealed: tuple[Solution[], Solution[V=bytes]]
reveal_type(constraints.solutions_for(V, inferable=tuple[T, U, V]))

# TODO: sometimes: revealed tuple[Solution[T=list[int], U=int], Solution[T=Never, V=bytes], Solution[V=bytes]]
# TODO: sometimes: revealed tuple[Solution[T=list[int], U=int], Solution[T=list[int], V=bytes], Solution[V=bytes]]
# TODO: sometimes: revealed tuple[Solution[T=list[int], U=int], Solution[U=Never, V=bytes], Solution[V=bytes]]
# revealed: tuple[Solution[T=list[int], U=int], Solution[V=bytes]]
reveal_type(constraints.solutions(inferable=tuple[T, U, V]))
```

## Negated alternatives do not infer positive evidence

In `¬((T ≤ int) ∨ (T ≤ str)) | (bytes ≤ U)`, the lhs of the union is a negation, and should not
place any positive restriction on `T`. Like above, we are not obligated to produce a solution that
includes both sides of the union, so any solution that includes `bytes ≤ U` should not include a
solution for `T`.
In `¬((T ≤ int) ∨ (T ≤ str)) | (bytes ≤ U)`, the lhs of the union imposes no positive restriction on
either typevar. That unconstrained alternative makes the entire constraint set unconstrained, so
neither typevar has an inferred solution.

```py
from typing import Never
Expand All @@ -136,15 +126,12 @@ def negated_alternative[T, U]() -> None:
bytes, U, object
)

# TODO: sometimes: revealed tuple[Solution[], Solution[T=Never], Solution[]]
# revealed: tuple[Solution[], Solution[]]
reveal_type(constraints.solutions_for(T, inferable=tuple[T, U]))

# TODO: sometimes: revealed tuple[Solution[], Solution[U=bytes], Solution[U=bytes]]
# revealed: tuple[Solution[], Solution[U=bytes]]
reveal_type(constraints.solutions_for(U, inferable=tuple[T, U]))

# TODO: sometimes: revealed tuple[Solution[], Solution[T=Never, U=bytes], Solution[U=bytes]]
# revealed: tuple[Solution[], Solution[U=bytes]]
reveal_type(constraints.solutions(inferable=tuple[T, U]))
```
Expand Down Expand Up @@ -242,11 +229,41 @@ def chain_uts[U, T, S]() -> None:
reveal_type(constraints.solutions_for(U, inferable=tuple[S, T, U]))
```

## Non-inferable constraint source order and typevar orientation

A non-inferable constraint can appear before or after inferable constraints, and a bare relationship
can be encoded with either variable as its subject. None of those representation choices should
change which type variables are returned.

```py
from ty_extensions._internal import ConstraintSet

def noninferable_constraint_first[I, J, N]() -> None:
constraints = ConstraintSet.range(int, N, int) & ConstraintSet.range(str, I, str) & ConstraintSet.range(bytes, J, bytes)
# revealed: tuple[Solution[I=str, J=bytes]]
reveal_type(constraints.solutions(inferable=tuple[I, J]))

def noninferable_constraint_last[I, J, N]() -> None:
constraints = ConstraintSet.range(str, I, str) & ConstraintSet.range(bytes, J, bytes) & ConstraintSet.range(int, N, int)
# revealed: tuple[Solution[I=str, J=bytes]]
reveal_type(constraints.solutions(inferable=tuple[I, J]))

def inferable_subject[I, N]() -> None:
constraints = ConstraintSet.range(N, I, N)
# revealed: tuple[Solution[I=N@inferable_subject]]
reveal_type(constraints.solutions(inferable=tuple[I]))

def noninferable_subject[N, I]() -> None:
constraints = ConstraintSet.range(I, N, I)
# revealed: tuple[Solution[I=N@noninferable_subject]]
reveal_type(constraints.solutions(inferable=tuple[I]))
```

## Abstraction and non-inferable typevars

Removing non-inferable typevars rebuilds the TDD with `ite`; irrelevant positive decisions must not
leak onto the surviving paths. Universal abstraction of an alternative must likewise leave only the
unrelated branch.
Non-inferable typevars must not appear in reported solution bindings, and irrelevant positive
decisions must not leak onto independent alternatives. Universal abstraction of an alternative must
likewise leave only the unrelated branch.

```py
from typing import Never
Expand All @@ -259,16 +276,10 @@ def noninferable_nested[T, U, V]() -> None:
) | ConstraintSet.range(bytes, V, object)

# `U` is deliberately non-inferable here.
# TODO: We should not include a solution for non-inferable U.
# TODO: sometimes: revealed tuple[Solution[T=list[int], U=int], Solution[T=Never, V=bytes], Solution[V=bytes]]
# TODO: sometimes: revealed tuple[Solution[T=list[int], U=int], Solution[T=list[int], V=bytes], Solution[V=bytes]]
# revealed: tuple[Solution[T=list[int], U=int], Solution[V=bytes]]
# revealed: tuple[Solution[T=list[int]], Solution[V=bytes]]
reveal_type(constraints.solutions(inferable=tuple[T, V]))
# TODO: sometimes: revealed tuple[Solution[T=list[int]], Solution[T=Never], Solution[]]
# TODO: sometimes: revealed tuple[Solution[T=list[int]], Solution[T=list[int]], Solution[]]
# revealed: tuple[Solution[T=list[int]], Solution[]]
reveal_type(constraints.solutions_for(T, inferable=tuple[T, V]))
# TODO: sometimes: revealed tuple[Solution[], Solution[V=bytes], Solution[V=bytes]]
# revealed: tuple[Solution[], Solution[V=bytes]]
reveal_type(constraints.solutions_for(V, inferable=tuple[T, V]))

Expand Down Expand Up @@ -353,8 +364,8 @@ def get_value(value: GetValue[ConstrainedValue]) -> ConstrainedValue:
raise NotImplementedError

def typed_dict_union(value: ValueA | ValueB) -> None:
# TODO: sometimes: revealed object
# revealed: int
# TODO: Infer `int` once declared constraints use the same BDD paths as other alternatives.
# revealed: object
reveal_type(get_value(value))
```

Expand Down
Loading
Loading