Skip to content

Expose the query ID and the last provisional value to the cycle recovery function#1012

Merged
MichaReiser merged 3 commits intosalsa-rs:masterfrom
MichaReiser:expose-id-and-last-prov-value
Oct 23, 2025
Merged

Expose the query ID and the last provisional value to the cycle recovery function#1012
MichaReiser merged 3 commits intosalsa-rs:masterfrom
MichaReiser:expose-id-and-last-prov-value

Conversation

@MichaReiser
Copy link
Contributor

@MichaReiser MichaReiser commented Oct 22, 2025

Extend the cycle_recovery function with two new arguments:

  • id: The query id. Can be used as a cheap unique identifier to "re-identify" a value returned by a previous iteration
  • last_provisional_value: The value returned in the last iteration. Useful to detect if the value is diverging

I included two more changes in this PR. Happy to split them out in their own PR if desired:

  1. Make cycle_fn optional and provide a default that returns Iterate because this is enough for most cyclic functions
  2. If cycle_fn returns Fallback, compare the new value with the last provisional. If they're the same, consider this query as converged (there's no point in running another iteration).

@netlify
Copy link

netlify bot commented Oct 22, 2025

Deploy Preview for salsa-rs canceled.

Name Link
🔨 Latest commit 620ced5
🔍 Latest deploy log https://app.netlify.com/projects/salsa-rs/deploys/68f9cad2ff8d3300082b8a60

@MichaReiser MichaReiser requested review from carljm and ibraheemdev and removed request for carljm October 22, 2025 08:04
@codspeed-hq
Copy link

codspeed-hq bot commented Oct 22, 2025

CodSpeed Performance Report

Merging #1012 will not alter performance

Comparing MichaReiser:expose-id-and-last-prov-value (620ced5) with master (16d51d6)

Summary

✅ 13 untouched

@MichaReiser MichaReiser added this pull request to the merge queue Oct 23, 2025
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Oct 23, 2025
@MichaReiser MichaReiser force-pushed the expose-id-and-last-prov-value branch from 9fc59a2 to 620ced5 Compare October 23, 2025 06:27
@MichaReiser MichaReiser enabled auto-merge October 23, 2025 06:28
@MichaReiser MichaReiser added this pull request to the merge queue Oct 23, 2025
Merged via the queue into salsa-rs:master with commit d38145c Oct 23, 2025
14 checks passed
@MichaReiser MichaReiser deleted the expose-id-and-last-prov-value branch October 23, 2025 06:46
@github-actions github-actions bot mentioned this pull request Oct 23, 2025
github-merge-queue bot pushed a commit that referenced this pull request Oct 29, 2025
…#1015 (#1016)

* doc: Explain the motivation for breaking API changes made in #1012 and #1015

* Update book/src/cycles.md

---------

Co-authored-by: Micha Reiser <micha@reiser.io>
carljm added a commit to astral-sh/ruff that referenced this pull request Nov 26, 2025
## Summary

Derived from #17371

Fixes astral-sh/ty#256
Fixes astral-sh/ty#1415
Fixes astral-sh/ty#1433
Fixes astral-sh/ty#1524

Properly handles any kind of recursive inference and prevents panics.

---

Let me explain techniques for converging fixed-point iterations during
recursive type inference.
There are two types of type inference that naively don't converge
(causing salsa to panic): divergent type inference and oscillating type
inference.

### Divergent type inference

Divergent type inference occurs when eagerly expanding a recursive type.
A typical example is this:

```python
class C:
    def f(self, other: "C"):
        self.x = (other.x, 1)

reveal_type(C().x) # revealed: Unknown | tuple[Unknown | tuple[Unknown | tuple[..., Literal[1]], Literal[1]], Literal[1]]
```

To solve this problem, we have already introduced `Divergent` types
(#20312). `Divergent` types are
treated as a kind of dynamic type [^1].

```python
Unknown | tuple[Unknown | tuple[Unknown | tuple[..., Literal[1]], Literal[1]], Literal[1]]
=> Unknown | tuple[Divergent, Literal[1]]
```

When a query function that returns a type enters a cycle, it sets
`Divergent` as the cycle initial value (instead of `Never`). Then, in
the cycle recovery function, it reduces the nesting of types containing
`Divergent` to converge.

```python
0th: Divergent
1st: Unknown | tuple[Divergent, Literal[1]]
2nd: Unknown | tuple[Unknown | tuple[Divergent, Literal[1]], Literal[1]]
=> Unknown | tuple[Divergent, Literal[1]]
```

Each cycle recovery function for each query should operate only on the
`Divergent` type originating from that query.
For this reason, while `Divergent` appears the same as `Any` to the
user, it internally carries some information: the location where the
cycle occurred. Previously, we roughly identified this by having the
scope where the cycle occurred, but with the update to salsa, functions
that create cycle initial values ​​can now receive a `salsa::Id`
(salsa-rs/salsa#1012). This is an opaque ID that
uniquely identifies the cycle head (the query that is the starting point
for the fixed-point iteration). `Divergent` now has this `salsa::Id`.

### Oscillating type inference

Now, another thing to consider is oscillating type inference.
Oscillating type inference arises from the fact that monotonicity is
broken. Monotonicity here means that for a query function, if it enters
a cycle, the calculation must start from a "bottom value" and progress
towards the final result with each cycle. Monotonicity breaks down in
type systems that have features like overloading and overriding.

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

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

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

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

reveal_type(C(Sub()).x)
```

Naive fixed-point iteration results in `Divergent -> Sub -> Base -> Sub
-> ...`, which oscillates forever without diverging or converging. To
address this, the salsa API has been modified so that the cycle recovery
function receives the value of the previous cycle
(salsa-rs/salsa#1012).
The cycle recovery function returns the union type of the current cycle
and the previous cycle. In the above example, the result type for each
cycle is `Divergent -> Sub -> Base (= Sub | Base) -> Base`, which
converges.

The final result of oscillating type inference does not contain
`Divergent` because `Divergent` that appears in a union type can be
removed, as is clear from the expansion. This simplification is
performed at the same time as nesting reduction.

```
T | Divergent = T | (T | (T | ...)) = T
```

[^1]: In theory, it may be possible to strictly treat types containing
`Divergent` types as recursive types, but we probably shouldn't go that
deep yet. (AFAIK, there are no PEPs that specify how to handle
implicitly recursive types that aren't named by type aliases)

## Performance analysis

A happy side effect of this PR is that we've observed widespread
performance improvements!
This is likely due to the removal of the `ITERATIONS_BEFORE_FALLBACK`
and max-specialization depth trick
(astral-sh/ty#1433,
astral-sh/ty#1415), which means we reach a
fixed point much sooner.

## Ecosystem analysis

The changes look good overall.
You may notice changes in the converged values ​​for recursive types,
this is because the way recursive types are normalized has been changed.
Previously, types containing `Divergent` types were normalized by
replacing them with the `Divergent` type itself, but in this PR, types
with a nesting level of 2 or more that contain `Divergent` types are
normalized by replacing them with a type with a nesting level of 1. This
means that information about the non-divergent parts of recursive types
is no longer lost.

```python
# previous
tuple[tuple[Divergent, int], int] => Divergent
# now
tuple[tuple[Divergent, int], int] => tuple[Divergent, int]
```

The false positive error introduced in this PR occurs in class
definitions with self-referential base classes, such as the one below.

```python
from typing_extensions import Generic, TypeVar

T = TypeVar("T")
U = TypeVar("U")

class Base2(Generic[T, U]): ...

# TODO: no error
# error: [unsupported-base] "Unsupported class base with type `<class 'Base2[Sub2, U@Sub2]'> | <class 'Base2[Sub2[Unknown], U@Sub2]'>`"
class Sub2(Base2["Sub2", U]): ...
```

This is due to the lack of support for unions of MROs, or because cyclic
legacy generic types are not inferred as generic types early in the
query cycle.

## Test Plan

All samples listed in astral-sh/ty#256 are tested and passed without any
panic!

## Acknowledgments

Thanks to @MichaReiser for working on bug fixes and improvements to
salsa for this PR. @carljm also contributed early on to the discussion
of the query convergence mechanism proposed in this PR.

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
@github-actions github-actions bot mentioned this pull request Dec 16, 2025
This was referenced Dec 17, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants