[ty] Avoid exponential narrowing of gradual string-literal unions (#27742)

## Summary

Fix the exponential memory regression when equality narrowing encounters
a union of `Any & Literal["..."]` alternatives.

`evaluate_target_union` intersected each surviving alternative with the
complement of every rejected alternative, even when the survivor and
rejected union were already disjoint. For gradual string literals,
expanding those redundant complements created exponentially many
equivalent intersections. Skip the exclusions when disjointness is
proven; preserve them for alternatives that can overlap, including `Any`
and `TypeVar` cases.

On the original 20-member reproducer, peak memory drops from **304.4 MiB
to 38.8 MiB**; the reduced intersection-only reproducer drops from
**301.4 MiB to 38.5 MiB**. The original 67-member case, previously
exponential/OOM, now completes in **0.061 s using 42.0 MiB**.

Closes astral-sh/ty#4256.

## Test plan

- Added mdtests covering both branches of `==` and `!=` for gradual
string-literal unions, plus a 20-member regression that exercises the
original exponential expansion.
- Added the dedicated `ty_micro[gradual_literal_union_equality]`
CodSpeed microbenchmark for the 20-member case.
- Ran all 481 ty semantic mdtests and the repository hooks.
This commit is contained in:
Carl Meyer authored and GitHub committed 2026-08-14 07:04:37 -04:00
1 parent 083ec1e307
commit bf15c8cfcc
3 files changed
+94 -1

No files matched your search

+34
View File
@@ -1254,6 +1254,39 @@ fn benchmark_literal_equality_fallthrough_guarded_any(criterion: &mut Criterion)
);
}
/// Regression benchmark for <https://github.com/astral-sh/ty/issues/4256>.
///
/// Excluding rejected gradual string literals must not expand the complement of each intersection
/// into exponentially many equivalent alternatives.
fn benchmark_gradual_literal_union_equality(criterion: &mut Criterion) {
setup_rayon();
let mut code = String::from(
"from typing import Any, Literal\nfrom ty_extensions import Intersection\n\ndef check(value: (\n",
);
for index in 0..20 {
writeln!(
&mut code,
" {}Intersection[Any, Literal[\"{index}\"]]",
if index == 0 { "" } else { "| " },
)
.ok();
}
code.push_str(")) -> None:\n assert value == \"0\"\n repr(value)\n");
criterion.bench_function("ty_micro[gradual_literal_union_equality]", |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,
);
});
}
/// Regression benchmark for <https://github.com/astral-sh/ty/issues/3880>.
///
/// Reachability analysis for a large literal OR pattern on `Any` used to rebuild the remaining
@@ -1582,6 +1615,7 @@ criterion_group!(
benchmark_literal_match_fallthrough,
benchmark_literal_match_fallthrough_guarded_any,
benchmark_literal_equality_fallthrough_guarded_any,
benchmark_gradual_literal_union_equality,
benchmark_literal_or_pattern_reachability,
benchmark_typeis_narrowing,
benchmark_repeated_statement_calls,
@@ -2184,6 +2184,59 @@ def gradual_enum_union_inequality(value: Color | Any, other: Color):
reveal_type(value) # revealed: Color | Any
```
## Unions of gradual string literals
Comparing a union of string literals intersected with `Any` keeps the matching alternative for
equality and removes it for inequality:
```py
from typing import Any, Literal
from ty_extensions import Intersection
def equality(value: Intersection[Any, Literal["a"]] | Intersection[Any, Literal["b"]]):
if value == "a":
reveal_type(value) # revealed: Any & Literal["a"]
else:
reveal_type(value) # revealed: Any & Literal["b"]
if value != "a":
reveal_type(value) # revealed: Any & Literal["b"]
else:
reveal_type(value) # revealed: Any & Literal["a"]
```
Larger unions must narrow without expanding the complement of every rejected alternative, which
would make memory use grow exponentially:
```py
def larger_union(
value: (
Intersection[Any, Literal["a"]]
| Intersection[Any, Literal["b"]]
| Intersection[Any, Literal["c"]]
| Intersection[Any, Literal["d"]]
| Intersection[Any, Literal["e"]]
| Intersection[Any, Literal["f"]]
| Intersection[Any, Literal["g"]]
| Intersection[Any, Literal["h"]]
| Intersection[Any, Literal["i"]]
| Intersection[Any, Literal["j"]]
| Intersection[Any, Literal["k"]]
| Intersection[Any, Literal["l"]]
| Intersection[Any, Literal["m"]]
| Intersection[Any, Literal["n"]]
| Intersection[Any, Literal["o"]]
| Intersection[Any, Literal["p"]]
| Intersection[Any, Literal["q"]]
| Intersection[Any, Literal["r"]]
| Intersection[Any, Literal["s"]]
| Intersection[Any, Literal["t"]]
),
):
if value == "a":
reveal_type(value) # revealed: Any & Literal["a"]
```
## Booleans and integers
```py
@@ -1166,7 +1166,13 @@ fn evaluate_target_union<'db>(
let Some(mut narrowed) = narrowed else {
continue;
};
if let Some(removed) = removed {
// A surviving alternative that is disjoint from every rejected alternative already
// satisfies their exclusions. Constructing those redundant exclusions can exponentially
// expand intersections such as `Any & Literal["a"]` when the rejected alternatives are
// similarly shaped intersections with other string literals.
if let Some(removed) = removed
&& !narrowed.is_disjoint_from(db, env, removed)
{
narrowed = IntersectionBuilder::new(db, env)
.add_positive(narrowed)
.add_negative(removed)