Skip to content

[ty] Make Divergent a top-level type variant#24252

Merged
charliermarsh merged 3 commits intomainfrom
charlie/divergent
Mar 30, 2026
Merged

[ty] Make Divergent a top-level type variant#24252
charliermarsh merged 3 commits intomainfrom
charlie/divergent

Conversation

@charliermarsh
Copy link
Copy Markdown
Member

@charliermarsh charliermarsh commented Mar 27, 2026

Summary

This PR follows #24245 (comment), making Divergent a top-level type rather than a DynamicType variant.

@charliermarsh charliermarsh added internal An internal refactor or improvement ty Multi-file analysis & type inference labels Mar 27, 2026
@charliermarsh charliermarsh force-pushed the charlie/divergent branch 2 times, most recently from bf1cf64 to 6a610f1 Compare March 27, 2026 18:35
@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot bot commented Mar 27, 2026

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 86.61%. The percentage of expected errors that received a diagnostic held steady at 81.56%. The number of fully passing files held steady at 70/132.

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot bot commented Mar 27, 2026

Memory usage report

Summary

Project Old New Diff Outcome
prefect 716.74MB 716.80MB +0.01% (60.21kB)
sphinx 265.21MB 265.24MB +0.01% (29.14kB)
trio 117.99MB 118.00MB +0.01% (6.92kB)
flake8 48.07MB 48.08MB +0.00% (1.41kB)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
infer_expression_type_impl 13.82MB 13.84MB +0.14% (19.15kB)
infer_expression_types_impl 61.76MB 61.77MB +0.02% (15.12kB)
CallableType 2.08MB 2.09MB +0.53% (11.25kB)
infer_definition_types 89.72MB 89.73MB +0.01% (6.74kB)
loop_header_reachability 434.99kB 437.12kB +0.49% (2.13kB)
all_negative_narrowing_constraints_for_expression 2.62MB 2.62MB +0.06% (1.64kB)
is_redundant_with_impl::interned_arguments 5.42MB 5.42MB +0.02% (1.38kB)
Type<'db>::member_lookup_with_policy_ 16.19MB 16.19MB +0.01% (1.01kB)
is_redundant_with_impl 5.51MB 5.51MB +0.01% (768.00B)
all_narrowing_constraints_for_expression 7.13MB 7.13MB -0.01% (728.00B)
UnionType 3.52MB 3.52MB +0.02% (720.00B)
Type<'db>::member_lookup_with_policy_::interned_arguments 5.76MB 5.76MB +0.01% (624.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_ 9.93MB 9.93MB +0.00% (216.00B)
UnionType<'db>::from_two_elements_ 5.24MB 5.24MB +0.00% (116.00B)
UnionType<'db>::from_two_elements_::interned_arguments 2.45MB 2.45MB +0.00% (88.00B)
... 3 more

sphinx

Name Old New Diff Outcome
CallableType 1.10MB 1.11MB +1.18% (13.22kB)
infer_expression_types_impl 21.48MB 21.48MB +0.02% (5.37kB)
infer_expression_type_impl 2.95MB 2.96MB +0.18% (5.33kB)
infer_definition_types 23.97MB 23.97MB +0.01% (3.04kB)
loop_header_reachability 378.58kB 379.58kB +0.26% (1020.00B)
all_narrowing_constraints_for_expression 2.33MB 2.33MB +0.03% (696.00B)
all_negative_narrowing_constraints_for_expression 1.00MB 1.00MB +0.04% (444.00B)
infer_scope_types_impl 15.49MB 15.49MB +0.00% (36.00B)
infer_unpack_types 432.58kB 432.61kB +0.01% (24.00B)
Type<'db>::member_lookup_with_policy_ 6.51MB 6.51MB +0.00% (12.00B)
StaticClassLiteral<'db>::implicit_attribute_inner_ 2.40MB 2.40MB +0.00% (12.00B)

trio

Name Old New Diff Outcome
CallableType 580.03kB 583.40kB +0.58% (3.38kB)
infer_expression_type_impl 1.32MB 1.32MB +0.13% (1.75kB)
infer_expression_types_impl 7.08MB 7.08MB +0.02% (1.49kB)
infer_definition_types 7.73MB 7.73MB +0.01% (504.00B)
loop_header_reachability 133.23kB 133.48kB +0.19% (264.00B)
Type<'db>::member_lookup_with_policy_::interned_arguments 909.39kB 909.59kB +0.02% (208.00B)
Type<'db>::class_member_with_policy_ 2.00MB 2.00MB -0.01% (196.00B)
IntersectionType 222.50kB 222.34kB -0.07% (168.00B)
UnionType 304.84kB 304.73kB -0.04% (112.00B)
all_narrowing_constraints_for_expression 589.21kB 589.32kB +0.02% (108.00B)
Type<'db>::try_call_dunder_get_::interned_arguments 349.07kB 348.97kB -0.03% (104.00B)
Type<'db>::class_member_with_policy_::interned_arguments 1.11MB 1.11MB -0.01% (104.00B)
is_redundant_with_impl 473.31kB 473.21kB -0.02% (96.00B)
Type<'db>::try_call_dunder_get_ 1.37MB 1.37MB -0.01% (88.00B)
Type<'db>::member_lookup_with_policy_ 1.81MB 1.81MB +0.00% (68.00B)
... 1 more

flake8

Name Old New Diff Outcome
CallableType 166.36kB 167.20kB +0.51% (864.00B)
infer_expression_types_impl 1.07MB 1.07MB +0.02% (192.00B)
infer_expression_type_impl 141.97kB 142.14kB +0.12% (180.00B)
infer_definition_types 1.87MB 1.87MB +0.01% (120.00B)
loop_header_reachability 13.36kB 13.39kB +0.26% (36.00B)
all_narrowing_constraints_for_expression 82.01kB 82.03kB +0.03% (24.00B)
infer_unpack_types 36.88kB 36.89kB +0.03% (12.00B)
all_negative_narrowing_constraints_for_expression 39.60kB 39.61kB +0.03% (12.00B)

@astral-sh-bot
Copy link
Copy Markdown

astral-sh-bot bot commented Mar 27, 2026

ecosystem-analyzer results

No diagnostic changes detected ✅

Full report with detailed diff (timing results)

Copy link
Copy Markdown
Contributor

@carljm carljm left a comment

Choose a reason for hiding this comment

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

Ecosystem results suggest that one case we missed here is skipping redundant-cast for divergent types.

I think it may be that both inline comments, plus the above, will be fixed if we make Type::is_dynamic return true for a non-materialized Type::Divergent.

fn extend_with_type(&mut self, db: &'db dyn Db, ty: Type<'db>) {
match ty {
Type::Union(union) => {
fn is_dynamic(db: &dyn Db, ty: Type<'_>) -> bool {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Codex suggests we also need this helper to return true for Type::Divergent in order to maintain the prior behavior here. It says that this test passes on main but fails after this PR, without this change:

from ty_extensions import has_member, static_assert

class Base:
    def flip(self) -> "Base":
        return Base()

class Sub(Base):
    pass

class C:
    def __init__(self, x: Sub):
        self.x = [x]

    def replace_with(self, other: "C"):
        self.x = [self.x[0].flip()]

static_assert(has_member(C(Sub()).x[0], "flip"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Codex also found this check -- it should return true for Type::Divergent, or else the behavior of this snippet regresses (we get an invalid-key on the index on the last line):

from typing import TypedDict

A = list["A | None"]

class Movie(TypedDict):
    name: str

def f(x: A, movie: Movie):
    movie[x[0]]

@mtshiba
Copy link
Copy Markdown
Collaborator

mtshiba commented Mar 30, 2026

What Divergent should actually be is a delicate issue. My comment is not intended to block this PR, but I would like to discuss Divergent and give a proper definition. As I will explain below, I believe that Divergent cannot be fully contained within the specifications of Python's type system and should be defined as an extension.

When I first introduced Divergent (#20312), I thought it would suffice to treat it simply as a type of dynamic type, like Any. This would be the simplest definition of Divergent. However, as already shown, the fact that Divergent is replaced by a fully-static type by materialization causes problems. So it seems that Divergent cannot be considered a simple dynamic type.

However, in this PR, Divergent becomes a type with an unclear origin. It is not a dynamic type, but it is neither Never nor object. In terms of operational rules, it is a type that "allows many operations and propagates itself to the results". Such a type clearly deviates from the specifications of Python's type system.

Theoretically, it would make the most sense to reinterpret Divergent as a recursive variable of a recursive type. In other words, it's equivalent to $X$ in $\mu X. tuple[X | int]$, for example. However, recursive types do not exist in Python's type system. Also, this direction would require quite a major change.

@carljm
Copy link
Copy Markdown
Contributor

carljm commented Mar 30, 2026

@mtshiba I think it has never been the case (even from the first PR) that Divergent could really be considered a "simple dynamic type". It has always had this "disappears in unions" behavior, which is more like the behavior of Never. In fact Never is the type-theoretically correct place to start a fixpoint iteration, and Divergent originally replaced Never in this role, so in some ways it could be considered that it should be more "like Never" than "a simple dynamic type".

Perhaps ideally Divergent would just be an "is recursively defined as part of query X" marker that could be set on any type, orthogonal to the type itself, and would propagate through uses of that type. That is, it would be outside the type system entirely, but would allow us to avoid infinitely expanding types.

Theoretically, it would make the most sense to reinterpret Divergent as a recursive variable of a recursive type. In other words, it's equivalent to $X$ in $μX. tuple[X | int]$ , for example. However, recursive types do not exist in Python's type system.

I would say that recursive types do exist in Python's type system, given that the type spec and conformance suite require support for recursive type aliases. The question of how to represent them is not specified, however. We've discussed using a de Bruijn index representation to allow transformations of recursive types to not fall back to Any (as they currently do). I'm not sure if such a representation can work for fixpoint iteration of Salsa queries in general, though.

@charliermarsh charliermarsh merged commit e871de4 into main Mar 30, 2026
51 checks passed
@charliermarsh charliermarsh deleted the charlie/divergent branch March 30, 2026 19:17
carljm added a commit that referenced this pull request Mar 31, 2026
* main: (35 commits)
  Store definition indexes as u32 (#24307)
  Avoid re-using symbol in RUF024 fix (#24316)
  [ty] Add materialization to `Divergent` type (#24255)
  [ty] Make `Divergent` a top-level type variant (#24252)
  [ty] Fix nested global and nonlocal lookups through forwarding scopes (#24279)
  Fetch the cargo-dist binary directly instead of using the installer (#24258)
  [ty] Fix panic on `list[Annotated[()]]` (#24303)
  Don't measure the AST deallocation time in parser benchmarks (#24301)
  Enable CodSpeed's memory benchmarks for simulation benchmarks (#24298)
  Upgrade imara-diff to 0.2.0 (#24299)
  [ty] Represent `InitVar` as a special form internally, not a class (#24248)
  `RUF067`: Allow dunder-named assignments in non-strict mode
  [`pyupgrade`] UP018 should detect more unnecessarily wrapped literals (UP018) (#24093)
  [ty] Remove unused `system.glob` method (#24300)
  [ty] Reject functional TypedDict with mismatched name (#24295)
  Update Rust crate arc-swap to v1.9.0 (#24292)
  [ty] Remove unused `@Todo(Functional TypedDicts)` (#24297)
  Update CodSpeedHQ/action action to v4.12.1 (#24290)
  Update taiki-e/install-action action to v2.69.6 (#24293)
  Update Rust crate toml to v1.0.7 (#24289)
  ...
@mtshiba
Copy link
Copy Markdown
Collaborator

mtshiba commented Apr 1, 2026

@mtshiba I think it has never been the case (even from the first PR) that Divergent could really be considered a "simple dynamic type". It has always had this "disappears in unions" behavior, which is more like the behavior of Never. In fact Never is the type-theoretically correct place to start a fixpoint iteration, and Divergent originally replaced Never in this role, so in some ways it could be considered that it should be more "like Never" than "a simple dynamic type".

@carljm What makes Divergent special is that it has its own normalization rules in cycle recovery. This does not characterize subtyping/assignability behavior. (Divergent | T).cycle_normalized() = T does not mean Divergent | T = T. Therefore, more precisely, Divergent behaves like Never in cycle recovery and like Any in normal type inference.
(However, IntersectionBuilder mistakenly sets a special rule for the Divergent type, Divergent & T = Divergent, which should not be reduced in normal type inference)

You might then wonder why the behavior of Divergent in normal type inference wasn't aligned with Never. There's an important difference between Divergent and Never. Types containing Divergent are not necessarily empty types. However, since we had no way to accurately represent the instances, I substituted it with dynamic types.

Perhaps ideally Divergent would just be an "is recursively defined as part of query X" marker that could be set on any type, orthogonal to the type itself, and would propagate through uses of that type. That is, it would be outside the type system entirely, but would allow us to avoid infinitely expanding types.

I now find it more appealing to reinterpret Divergent as part of a recursive type ($\mu$-types) than to interpret it as something like Any/Never, because $\mu$-types can literally represent infinite cycles.

Currently, we only have the Divergent type, which is the marker representing the recursive part, but formally, a recursive type should consist of a marker and a binder that encloses the entire type. Something like Type::Recursive(id, tuple[Divergent(id), int]). If we try to get the type of the 0th element of the tuple, this will also be Type::Recursive(id, tuple[Divergent(id), int]), but if we try to get the type of the 1st element, it will be int.

Is this the same as what you have in mind?

I would say that recursive types do exist in Python's type system, given that the type spec and conformance suite require support for recursive type aliases. The question of how to represent them is not specified, however. We've discussed using a de Bruijn index representation to allow transformations of recursive types to not fall back to Any (as they currently do). I'm not sure if such a representation can work for fixpoint iteration of Salsa queries in general, though.

Whether the recursive types mentioned here can be interpreted as recursive type aliases is debatable, as they are synthesized during type inference and are not "aliases" of anything. However, this kind of internal extension is present in ty, with examples like SynthesizedProtocol types, so perhaps there's no need to hesitate.

I would like to create a PoC to replace Divergent with the recursive type system.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal An internal refactor or improvement ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants