Skip to content

Commit b41cb9a

Browse files
authored
[ty] Avoid repeated nested substitutions in path assignments (#24660)
I discovered this while looking into ecosystem timeouts on #24540. @AlexWaygood also found this while trying to bump our vendored typeshed. When solving a constraint set that contains multiple typevars, we sometimes try to substitute a nested typevar in a constraint multiple times, leading to an endless expansion. In #24540, we saw this with `streamlit`. Given constraints ``` Iterable[V_co] & int ≤ _T@list ArrowStreamExportable & str ≤ _T@list V_co = _T@list ``` Note that `V_co` is covariant, and `V_co = _T@list`, so we can substitute any lower bound of `_T@list` into `Iterable` in the first constraint. In particular, we can substitute `V_co = ArrowStreamExportable & str`, giving ``` Iterable[ArrowStreamExportable & str] & int ≤ _T@list ``` That gives us another lower bound for `_T@list`, which we can substitute again ``` Iterable[Iterable[ArrowStreamExportable & str] & int] & int ≤ _T@list ``` and on and on! To catch this, we now keep track of when a new derived constraint comes from a substitution. When walking paths in a constraint set, we do _not_ add a derived constraint if we already have some other constraint on the path that came from the same substitution. In the `streamlit` example, that means we will only substitute for `V_co` in the first constraint at most once per BDD path.
1 parent ee9088e commit b41cb9a

2 files changed

Lines changed: 147 additions & 10 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Derived constraint cycles
2+
3+
```toml
4+
[environment]
5+
python-version = "3.13"
6+
```
7+
8+
Before [ty#24660], this example would never complete, because we would repeatedly try to substitute
9+
one of the typevars in a constraint over and over, creating increasingly large types in the lower or
10+
upper bound of the constraint.
11+
12+
```py
13+
from typing import Callable, Protocol
14+
15+
class Foo[In, Out](Protocol):
16+
def method(self, other: In, /) -> Out:
17+
raise NotImplementedError
18+
19+
def add[In, Out](a: Foo[In, Out], b: In, /) -> Out:
20+
raise NotImplementedError
21+
22+
def reduce[T](function: Callable[[T, T], T]) -> T:
23+
raise NotImplementedError
24+
25+
reduce(add)
26+
```
27+
28+
[ty#24660]: https://github.com/astral-sh/ruff/pull/24660

crates/ty_python_semantic/src/types/constraints.rs

Lines changed: 119 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,50 @@ pub struct TypeVarId;
10051005
#[derive(salsa::Update, get_size2::GetSize)]
10061006
pub struct ConstraintId;
10071007

1008+
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)]
1009+
enum NestedSubstitutionSide {
1010+
Lower,
1011+
Upper,
1012+
}
1013+
1014+
/// Identifies one nested-typevar substitution that has been applied while saturating a single
1015+
/// BDD path.
1016+
///
1017+
/// We intentionally key this by the constraint that we substitute _into_ and the typevar that we
1018+
/// substitute _for_, but not by the replacement type. For the pathological cases that matter for
1019+
/// performance, the same nested substitution shape can keep producing ever-deeper replacement
1020+
/// types (for instance, repeated `Iterable[...]` wrapping). Recording only the substitution site
1021+
/// lets [`PathAssignments`] apply that substitution at most once per path, which preserves the
1022+
/// initial cross-typevar relationship without repeatedly unfolding the same pattern.
1023+
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)]
1024+
struct NestedSubstitution {
1025+
substituted_into: ConstraintId,
1026+
substituted_typevar: TypeVarId,
1027+
side: NestedSubstitutionSide,
1028+
}
1029+
1030+
/// A constraint derived from the sequent map, optionally annotated with the nested substitution
1031+
/// step that produced it.
1032+
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, salsa::Update, get_size2::GetSize)]
1033+
struct DerivedConstraint {
1034+
constraint: ConstraintId,
1035+
nested_substitution: Option<NestedSubstitution>,
1036+
}
1037+
1038+
fn nested_substitution<'db>(
1039+
db: &'db dyn Db,
1040+
builder: &ConstraintSetBuilder<'db>,
1041+
substituted_into: ConstraintId,
1042+
substituted_typevar: BoundTypeVarInstance<'db>,
1043+
side: NestedSubstitutionSide,
1044+
) -> NestedSubstitution {
1045+
NestedSubstitution {
1046+
substituted_into,
1047+
substituted_typevar: builder.typevar_id(db, substituted_typevar),
1048+
side,
1049+
}
1050+
}
1051+
10081052
/// An individual constraint in a constraint set. This restricts a single typevar to be within a
10091053
/// lower and upper bound.
10101054
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize, salsa::Update)]
@@ -4241,7 +4285,7 @@ struct SequentMap {
42414285
/// Sequents of the form `C₁ ∧ C₂ → false`
42424286
pair_impossibilities: FxHashSet<(ConstraintId, ConstraintId)>,
42434287
/// Sequents of the form `C₁ ∧ C₂ → D`
4244-
pair_implications: FxIndexMap<(ConstraintId, ConstraintId), FxIndexSet<ConstraintId>>,
4288+
pair_implications: FxIndexMap<(ConstraintId, ConstraintId), FxIndexSet<DerivedConstraint>>,
42454289
/// Sequents of the form `C → D`
42464290
single_implications: FxIndexMap<ConstraintId, FxIndexSet<ConstraintId>>,
42474291
}
@@ -4385,6 +4429,18 @@ impl SequentMap {
43854429
ante1: ConstraintId,
43864430
ante2: ConstraintId,
43874431
post: ConstraintId,
4432+
) {
4433+
self.add_pair_implication_with_provenance(db, builder, ante1, ante2, post, None);
4434+
}
4435+
4436+
fn add_pair_implication_with_provenance<'db>(
4437+
&mut self,
4438+
db: &'db dyn Db,
4439+
builder: &ConstraintSetBuilder<'db>,
4440+
ante1: ConstraintId,
4441+
ante2: ConstraintId,
4442+
post: ConstraintId,
4443+
nested_substitution: Option<NestedSubstitution>,
43884444
) {
43894445
// If the post constraint is unsatisfiable, then the antecedents contradict each other.
43904446
let post_data = builder.constraint_data(post);
@@ -4400,11 +4456,15 @@ impl SequentMap {
44004456
if ante1.implies(db, builder, post) || ante2.implies(db, builder, post) {
44014457
return;
44024458
}
4459+
let derived = DerivedConstraint {
4460+
constraint: post,
4461+
nested_substitution,
4462+
};
44034463
if self
44044464
.pair_implications
44054465
.entry(Self::pair_key(ante1, ante2))
44064466
.or_default()
4407-
.insert(post)
4467+
.insert(derived)
44084468
{
44094469
tracing::trace!(
44104470
target: "ty_python_semantic::types::constraints::SequentMap",
@@ -4878,12 +4938,19 @@ impl SequentMap {
48784938
constrained_data.lower,
48794939
new_upper,
48804940
);
4881-
self.add_pair_implication(
4941+
self.add_pair_implication_with_provenance(
48824942
db,
48834943
builder,
48844944
bound_constraint,
48854945
constrained_constraint,
48864946
post,
4947+
Some(nested_substitution(
4948+
db,
4949+
builder,
4950+
constrained_constraint,
4951+
bound_typevar,
4952+
NestedSubstitutionSide::Upper,
4953+
)),
48874954
);
48884955
}
48894956
}
@@ -4935,12 +5002,19 @@ impl SequentMap {
49355002
new_lower,
49365003
constrained_data.upper,
49375004
);
4938-
self.add_pair_implication(
5005+
self.add_pair_implication_with_provenance(
49395006
db,
49405007
builder,
49415008
bound_constraint,
49425009
constrained_constraint,
49435010
post,
5011+
Some(nested_substitution(
5012+
db,
5013+
builder,
5014+
constrained_constraint,
5015+
bound_typevar,
5016+
NestedSubstitutionSide::Lower,
5017+
)),
49445018
);
49455019
}
49465020
}
@@ -5024,12 +5098,19 @@ impl SequentMap {
50245098
constrained_data.lower,
50255099
new_upper,
50265100
);
5027-
self.add_pair_implication(
5101+
self.add_pair_implication_with_provenance(
50285102
db,
50295103
builder,
50305104
bound_constraint,
50315105
constrained_constraint,
50325106
post,
5107+
Some(nested_substitution(
5108+
db,
5109+
builder,
5110+
constrained_constraint,
5111+
nested_typevar,
5112+
NestedSubstitutionSide::Upper,
5113+
)),
50335114
);
50345115
}
50355116
}
@@ -5061,12 +5142,19 @@ impl SequentMap {
50615142
new_lower,
50625143
constrained_data.upper,
50635144
);
5064-
self.add_pair_implication(
5145+
self.add_pair_implication_with_provenance(
50655146
db,
50665147
builder,
50675148
bound_constraint,
50685149
constrained_constraint,
50695150
post,
5151+
Some(nested_substitution(
5152+
db,
5153+
builder,
5154+
constrained_constraint,
5155+
nested_typevar,
5156+
NestedSubstitutionSide::Lower,
5157+
)),
50705158
);
50715159
}
50725160
}
@@ -5307,7 +5395,7 @@ impl SequentMap {
53075395
"{} ∧ {} → {}",
53085396
ante1.display(self.db, self.builder),
53095397
ante2.display(self.db, self.builder),
5310-
post.display(self.db, self.builder),
5398+
post.constraint.display(self.db, self.builder),
53115399
)?;
53125400
}
53135401
}
@@ -5346,6 +5434,8 @@ impl SequentMap {
53465434
pub(crate) struct PathAssignments {
53475435
map: SequentMap,
53485436
assignments: FxIndexMap<ConstraintAssignment, usize>,
5437+
/// Nested substitutions that we have already applied on the current root→terminal path.
5438+
nested_substitutions: FxIndexSet<NestedSubstitution>,
53495439
/// Constraints that we have discovered, mapped to whether we have processed them yet. (This
53505440
/// ensures a stable order for all of the derived constraints that we create, while still
53515441
/// letting us create them lazily.)
@@ -5361,6 +5451,7 @@ impl PathAssignments {
53615451
Self {
53625452
map: SequentMap::default(),
53635453
assignments: FxIndexMap::default(),
5454+
nested_substitutions: FxIndexSet::default(),
53645455
discovered,
53655456
}
53665457
}
@@ -5399,6 +5490,7 @@ impl PathAssignments {
53995490
// pass along the range of which assignments are new, and so that we can reset back to this
54005491
// point before returning.
54015492
let start = self.assignments.len();
5493+
let nested_substitutions_start = self.nested_substitutions.len();
54025494

54035495
// Add the new assignment and anything we can derive from it.
54045496
tracing::trace!(
@@ -5440,6 +5532,8 @@ impl PathAssignments {
54405532
// Reset back to where we were before following this edge, so that the caller can reuse a
54415533
// single instance for the entire BDD traversal.
54425534
self.assignments.truncate(start);
5535+
self.nested_substitutions
5536+
.truncate(nested_substitutions_start);
54435537
result
54445538
}
54455539

@@ -5605,11 +5699,26 @@ impl PathAssignments {
56055699
let mut new_constraints = Vec::new();
56065700
for ((ante1, ante2), posts) in &self.map.pair_implications {
56075701
for post in posts {
5608-
if self.assignment_holds(ante1.when_true())
5609-
&& self.assignment_holds(ante2.when_true())
5702+
if !self.assignment_holds(ante1.when_true())
5703+
|| !self.assignment_holds(ante2.when_true())
56105704
{
5611-
new_constraints.push(*post);
5705+
continue;
56125706
}
5707+
5708+
// Nested-typevar sequents are the mechanism that preserves cross-typevar facts when
5709+
// we later existentially quantify away one of the typevars. However, once we've
5710+
// applied a particular substitution site on the current path, reapplying it with a
5711+
// newly derived replacement type does not add fundamentally new information — it
5712+
// just keeps unfolding the same pattern one layer deeper. Skipping repeated
5713+
// applications here prevents those infinite-looking expansion chains while still
5714+
// keeping the first derived relationship.
5715+
if let Some(nested_substitution) = post.nested_substitution
5716+
&& !self.nested_substitutions.insert(nested_substitution)
5717+
{
5718+
continue;
5719+
}
5720+
5721+
new_constraints.push(post.constraint);
56135722
}
56145723
}
56155724

0 commit comments

Comments
 (0)