Skip to content

Commit c46acee

Browse files
authored
[ty] Refactor constraint traversal and cache satisfiability checks (astral-sh#29492)
This PR extracts some refactoring changes out of astral-sh#29027 so they can be reviewed separately. This should be entirely behavioral neutral. The changes: - There is now a clear separation between "atomic" constraints and non-atomic constraints. There aren't actually any non-atomic constraints yet, but astral-sh#29027 will add existential nodes as a non-atomic constraint. The defining characteristic is that an atomic constraint is one that is an actual assignment to a typevar; it is what `PathAssignments` builds up as it walks a BDD, and is what can live as an antecedent or consequent in a `SequentMap`. - We've extracted the logic for persisting a `ConstraintSet` into an `OwnedConstraintSet`, moving it into a standalone file and structuring it better via a helper type and some finer grained methods. - Removed some code sharing in `SolutionWalker` that was actually leading to more complex code. `visit_node` and `node_is_satisfiable_on_path` no longer use a shared helper, since the two uses had many differences, which required overridable hook points. The shared part was much smaller, so it's simpler to just copy/paste that part. - That makes it easier to add caching of `node_is_satisfiable_on_path`, similar to the caching we already had for `visit_node`.
1 parent 477638f commit c46acee

11 files changed

Lines changed: 1693 additions & 1137 deletions

File tree

‎crates/ruff_benchmark/benches/ty_constraint_set.rs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,31 @@ fn benchmark_pydantic_core_schema_dict(criterion: &mut Criterion) {
651651
});
652652
}
653653

654+
/// Repeated unions share source-order subtrees even after the TDD stops growing. Persisting each
655+
/// result must not expand those shared subtrees into exponentially many copies.
656+
fn benchmark_shared_source_order_compaction(criterion: &mut Criterion) {
657+
setup_rayon();
658+
659+
let mut code = String::from(
660+
"from ty_extensions._internal import ConstraintSet\n\ndef repeated[T]() -> None:\n c0 = ConstraintSet.lower_bound(int, T)\n c1 = ConstraintSet.lower_bound(str, T)\n",
661+
);
662+
for i in 2..26 {
663+
writeln!(&mut code, " c{i} = c{} | c{}", i - 2, i - 1).ok();
664+
}
665+
666+
criterion.bench_function("ty_micro[shared_source_order_compaction]", |b| {
667+
b.iter_batched_ref(
668+
|| setup_micro_case(&code),
669+
|case| {
670+
let Case { db } = case;
671+
let result = db.check();
672+
assert_eq!(result.len(), 0);
673+
},
674+
BatchSize::SmallInput,
675+
);
676+
});
677+
}
678+
654679
criterion_group!(
655680
constraint_set,
656681
benchmark_typevar_mapping_large_accumulation,
@@ -667,5 +692,6 @@ criterion_group!(
667692
benchmark_many_invariant_typevars,
668693
benchmark_mixed_many_invariant_typevars,
669694
benchmark_pydantic_core_schema_dict,
695+
benchmark_shared_source_order_compaction,
670696
);
671697
criterion_main!(constraint_set);

‎crates/ty_python_semantic/resources/mdtest/regression/constraint_set_ordering.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,3 +561,50 @@ def high_fanout[
561561
# revealed: None
562562
reveal_type(impossible.solutions_for(R11, inferable=inferable))
563563
```
564+
565+
## Satisfiability checks exceeding the traversal budget
566+
567+
Independent alternatives can produce too many paths to check within the computation budget. An
568+
incomplete check returns `bool`, without claiming either literal result. This applies both to
569+
checking that a constraint set never holds and to checking that its negation always holds. Orderings
570+
that reach the contradiction early can instead prove the result within the budget.
571+
572+
```py
573+
from ty_extensions._internal import ConstraintSet
574+
575+
def bounded[A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, X]() -> None:
576+
# (A = int ∨ A = str) ∧ … ∧ (R = int ∨ R = str)
577+
choices = (
578+
(ConstraintSet.equality(A, int) | ConstraintSet.equality(A, str))
579+
& (ConstraintSet.equality(B, int) | ConstraintSet.equality(B, str))
580+
& (ConstraintSet.equality(C, int) | ConstraintSet.equality(C, str))
581+
& (ConstraintSet.equality(D, int) | ConstraintSet.equality(D, str))
582+
& (ConstraintSet.equality(E, int) | ConstraintSet.equality(E, str))
583+
& (ConstraintSet.equality(F, int) | ConstraintSet.equality(F, str))
584+
& (ConstraintSet.equality(G, int) | ConstraintSet.equality(G, str))
585+
& (ConstraintSet.equality(H, int) | ConstraintSet.equality(H, str))
586+
& (ConstraintSet.equality(I, int) | ConstraintSet.equality(I, str))
587+
& (ConstraintSet.equality(J, int) | ConstraintSet.equality(J, str))
588+
& (ConstraintSet.equality(K, int) | ConstraintSet.equality(K, str))
589+
& (ConstraintSet.equality(L, int) | ConstraintSet.equality(L, str))
590+
& (ConstraintSet.equality(M, int) | ConstraintSet.equality(M, str))
591+
& (ConstraintSet.equality(N, int) | ConstraintSet.equality(N, str))
592+
& (ConstraintSet.equality(O, int) | ConstraintSet.equality(O, str))
593+
& (ConstraintSet.equality(P, int) | ConstraintSet.equality(P, str))
594+
& (ConstraintSet.equality(Q, int) | ConstraintSet.equality(Q, str))
595+
& (ConstraintSet.equality(R, int) | ConstraintSet.equality(R, str))
596+
)
597+
# Every combination of choices eventually encounters X = int ∧ X = str.
598+
impossible = ConstraintSet.equality(X, int) & ConstraintSet.equality(X, str) & choices
599+
# TODO: sometimes: revealed Literal[True]
600+
# revealed: bool
601+
reveal_type(impossible.is_never_satisfied(inferable=tuple[A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, X]))
602+
603+
# TODO: sometimes: revealed Literal[True]
604+
# revealed: bool
605+
reveal_type((~impossible).is_always_satisfied(inferable=tuple[A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, X]))
606+
607+
# Finding one witness for the choices alone does not require exploring every combination.
608+
# revealed: Literal[False]
609+
reveal_type(choices.is_never_satisfied(inferable=tuple[A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R]))
610+
```

‎crates/ty_python_semantic/resources/mdtest/type_properties/constraints.md‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,6 +1113,32 @@ def f[S, T, U]():
11131113
...
11141114
```
11151115

1116+
## Repeated combinations preserve the first occurrence
1117+
1118+
Combining previously constructed constraint sets can repeat the same constraints many times. The
1119+
first occurrence still determines binding order, even when later combinations reverse that order.
1120+
1121+
```py
1122+
from ty_extensions._internal import ConstraintSet
1123+
1124+
def repeated[T, U]() -> None:
1125+
c0 = ConstraintSet.equality(T, int)
1126+
c1 = ConstraintSet.equality(U, str)
1127+
c2 = c0 & c1
1128+
c3 = c1 & c2
1129+
c4 = c2 & c3
1130+
c5 = c3 & c4
1131+
c6 = c4 & c5
1132+
c7 = c5 & c6
1133+
c8 = c6 & c7
1134+
c9 = c7 & c8
1135+
1136+
# revealed: tuple[Solution[T=int, U=str]]
1137+
reveal_type(c8.solutions(inferable=tuple[T, U]))
1138+
# revealed: tuple[Solution[U=str, T=int]]
1139+
reveal_type(c9.solutions(inferable=tuple[T, U]))
1140+
```
1141+
11161142
## Other simplifications
11171143

11181144
### Ordering of intersection and union elements
@@ -1342,6 +1368,50 @@ def declared_bound[T: str]() -> None:
13421368
static_assert(constraint.solutions(inferable=tuple[T]) is None)
13431369
```
13441370

1371+
## Satisfiability checks exceeding the traversal budget
1372+
1373+
Independent alternatives can produce too many paths to check within the computation budget. An
1374+
incomplete check returns `bool`, without claiming either literal result. This applies both to
1375+
checking that a constraint set never holds and to checking that its negation always holds.
1376+
1377+
```py
1378+
from ty_extensions._internal import ConstraintSet
1379+
1380+
def bounded[A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, X]() -> None:
1381+
# (A = int ∨ A = str) ∧ … ∧ (R = int ∨ R = str)
1382+
choices = (
1383+
(ConstraintSet.equality(A, int) | ConstraintSet.equality(A, str))
1384+
& (ConstraintSet.equality(B, int) | ConstraintSet.equality(B, str))
1385+
& (ConstraintSet.equality(C, int) | ConstraintSet.equality(C, str))
1386+
& (ConstraintSet.equality(D, int) | ConstraintSet.equality(D, str))
1387+
& (ConstraintSet.equality(E, int) | ConstraintSet.equality(E, str))
1388+
& (ConstraintSet.equality(F, int) | ConstraintSet.equality(F, str))
1389+
& (ConstraintSet.equality(G, int) | ConstraintSet.equality(G, str))
1390+
& (ConstraintSet.equality(H, int) | ConstraintSet.equality(H, str))
1391+
& (ConstraintSet.equality(I, int) | ConstraintSet.equality(I, str))
1392+
& (ConstraintSet.equality(J, int) | ConstraintSet.equality(J, str))
1393+
& (ConstraintSet.equality(K, int) | ConstraintSet.equality(K, str))
1394+
& (ConstraintSet.equality(L, int) | ConstraintSet.equality(L, str))
1395+
& (ConstraintSet.equality(M, int) | ConstraintSet.equality(M, str))
1396+
& (ConstraintSet.equality(N, int) | ConstraintSet.equality(N, str))
1397+
& (ConstraintSet.equality(O, int) | ConstraintSet.equality(O, str))
1398+
& (ConstraintSet.equality(P, int) | ConstraintSet.equality(P, str))
1399+
& (ConstraintSet.equality(Q, int) | ConstraintSet.equality(Q, str))
1400+
& (ConstraintSet.equality(R, int) | ConstraintSet.equality(R, str))
1401+
)
1402+
# Every combination of choices eventually encounters X = int ∧ X = str.
1403+
impossible = ConstraintSet.equality(X, int) & ConstraintSet.equality(X, str) & choices
1404+
# revealed: bool
1405+
reveal_type(impossible.is_never_satisfied(inferable=tuple[A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, X]))
1406+
1407+
# revealed: bool
1408+
reveal_type((~impossible).is_always_satisfied(inferable=tuple[A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, X]))
1409+
1410+
# Finding one witness for the choices alone does not require exploring every combination.
1411+
# revealed: Literal[False]
1412+
reveal_type(choices.is_never_satisfied(inferable=tuple[A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R]))
1413+
```
1414+
13451415
## Solutions with inferable and non-inferable typevars
13461416

13471417
Solution extraction receives the type variables that it is allowed to infer. Other type variables

‎crates/ty_python_semantic/src/types/call/bind.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3230,12 +3230,15 @@ impl<'db> Bindings<'db> {
32303230
method,
32313231
KnownBoundMethodType::ConstraintSetIsAlwaysSatisfied(_)
32323232
) {
3233-
set.is_always_satisfied(db, env, inferable)
3233+
set.is_always_satisfied_bounded(db, env, inferable)
32343234
} else {
3235-
set.is_never_satisfied(db, env, inferable)
3235+
set.is_never_satisfied_bounded(db, env, inferable)
32363236
}
32373237
});
3238-
overload.set_return_type(Type::bool_literal(result));
3238+
overload.set_return_type(result.map_or_else(
3239+
|| KnownClass::Bool.to_instance(db, env),
3240+
Type::bool_literal,
3241+
));
32393242
}
32403243

32413244
Type::KnownBoundMethod(KnownBoundMethodType::ConstraintSetSolutions(

0 commit comments

Comments
 (0)