Repository: astral-sh/ruff
Pull request #16698: [red-knot] Check whether two callable types are equivalent
Author: login: dhruvmanila
Labels: name: ty
description: Multi-file analysis & type inference
Created: 2025-03-13T05:27:23Z | Merged: 2025-03-21T03:19:07Z

## Summary

This PR checks whether two callable types are equivalent or not.

This is required because for an equivalence relationship, the default value does not necessarily need to be the same but if the parameter in one of the callable has a default value then the corresponding parameter in the other callable should also have a default value. This is the main reason a manual implementation is required.

And, as per https://typing.python.org/en/latest/spec/callables.html#id4, the default _type_ doesn't participate in a subtype relationship, only the optionality (required or not) participates. This means that the following two callable types are equivalent:

```py
def f1(a: int = 1) -> None: ...
def f2(a: int = 2) -> None: ...
```

Additionally, the name of positional-only, variadic and keyword-variadic are not required to be the same for an equivalence relation.

A potential solution to avoid the manual implementation would be to only store whether a parameter has a default value or not but the type is currently required to check for assignability.

## Test plan

Add tests for callable types in `is_equivalent_to.md`



Diff:
diff --git a/crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md b/crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
index 047a45fc2fd0b8..7aa76be99cd551 100644
--- a/crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
+++ b/crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
@@ -118,4 +118,126 @@ class R: ...
 static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
 ```
 
+## Callable
+
+### Equivalent
+
+For an equivalence relationship, the default value does not necessarily need to be the same but if
+the parameter in one of the callable has a default value then the corresponding parameter in the
+other callable should also have a default value.
+
+```py
+from knot_extensions import CallableTypeFromFunction, is_equivalent_to, static_assert
+from typing import Callable
+
+def f1(a: int = 1) -> None: ...
+def f2(a: int = 2) -> None: ...
+
+static_assert(is_equivalent_to(CallableTypeFromFunction[f1], CallableTypeFromFunction[f2]))
+```
+
+The names of the positional-only, variadic and keyword-variadic parameters does not need to be the
+same.
+
+```py
+def f3(a1: int, /, *args1: int, **kwargs2: int) -> None: ...
+def f4(a2: int, /, *args2: int, **kwargs1: int) -> None: ...
+
+static_assert(is_equivalent_to(CallableTypeFromFunction[f3], CallableTypeFromFunction[f4]))
+```
+
+Putting it all together, the following two callables are equivalent:
+
+```py
+def f5(a1: int, /, b: float, c: bool = False, *args1: int, d: int = 1, e: str, **kwargs1: float) -> None: ...
+def f6(a2: int, /, b: float, c: bool = True, *args2: int, d: int = 2, e: str, **kwargs2: float) -> None: ...
+
+static_assert(is_equivalent_to(CallableTypeFromFunction[f5], CallableTypeFromFunction[f6]))
+```
+
+### Not equivalent
+
+There are multiple cases when two callable types are not equivalent which are enumerated below.
+
+```py
+from knot_extensions import CallableTypeFromFunction, is_equivalent_to, static_assert
+from typing import Callable
+```
+
+When the number of parameters is different:
+
+```py
+def f1(a: int) -> None: ...
+def f2(a: int, b: int) -> None: ...
+
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f1], CallableTypeFromFunction[f2]))
+```
+
+When either of the callable types uses a gradual form for the parameters:
+
+```py
+static_assert(not is_equivalent_to(Callable[..., None], Callable[[int], None]))
+static_assert(not is_equivalent_to(Callable[[int], None], Callable[..., None]))
+```
+
+When the return types are not equivalent or absent in one or both of the callable types:
+
+```py
+def f3(): ...
+def f4() -> None: ...
+
+static_assert(not is_equivalent_to(Callable[[], int], Callable[[], None]))
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f3], CallableTypeFromFunction[f3]))
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f3], CallableTypeFromFunction[f4]))
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f4], CallableTypeFromFunction[f3]))
+```
+
+When the parameter names are different:
+
+```py
+def f5(a: int) -> None: ...
+def f6(b: int) -> None: ...
+
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f5], CallableTypeFromFunction[f6]))
+```
+
+When only one of the callable types has parameter names:
+
+```py
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f5], Callable[[int], None]))
+```
+
+When the parameter kinds are different:
+
+```py
+def f7(a: int, /) -> None: ...
+def f8(a: int) -> None: ...
+
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f7], CallableTypeFromFunction[f8]))
+```
+
+When the annotated types of the parameters are not equivalent or absent in one or both of the
+callable types:
+
+```py
+def f9(a: int) -> None: ...
+def f10(a: str) -> None: ...
+def f11(a) -> None: ...
+
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f9], CallableTypeFromFunction[f10]))
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f10], CallableTypeFromFunction[f11]))
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f11], CallableTypeFromFunction[f10]))
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f11], CallableTypeFromFunction[f11]))
+```
+
+When the default value for a parameter is present only in one of the callable type:
+
+```py
+def f12(a: int) -> None: ...
+def f13(a: int = 2) -> None: ...
+
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f12], CallableTypeFromFunction[f13]))
+static_assert(not is_equivalent_to(CallableTypeFromFunction[f13], CallableTypeFromFunction[f12]))
+```
+
 [the equivalence relation]: https://typing.readthedocs.io/en/latest/spec/glossary.html#term-equivalent
diff --git a/crates/red_knot_python_semantic/src/types.rs b/crates/red_knot_python_semantic/src/types.rs
index c03efd47f4d379..5f6df57ed40290 100644
--- a/crates/red_knot_python_semantic/src/types.rs
+++ b/crates/red_knot_python_semantic/src/types.rs
@@ -898,6 +898,10 @@ impl<'db> Type<'db> {
                 left.is_equivalent_to(db, right)
             }
             (Type::Tuple(left), Type::Tuple(right)) => left.is_equivalent_to(db, right),
+            (
+                Type::Callable(CallableType::General(left)),
+                Type::Callable(CallableType::General(right)),
+            ) => left.is_equivalent_to(db, right),
             _ => self == other && self.is_fully_static(db) && other.is_fully_static(db),
         }
     }
@@ -4293,10 +4297,8 @@ impl<'db> FunctionType<'db> {
 
     /// Convert the `FunctionType` into a [`Type::Callable`].
     ///
-    /// Returns `None` if the function is overloaded. This powers the `CallableTypeFromFunction`
-    /// special form from the `knot_extensions` module.
+    /// This powers the `CallableTypeFromFunction` special form from the `knot_extensions` module.
     pub(crate) fn into_callable_type(self, db: &'db dyn Db) -> Type<'db> {
-        // TODO: Add support for overloaded callables
         Type::Callable(CallableType::General(GeneralCallableType::new(
             db,
             self.signature(db).clone(),
@@ -4542,6 +4544,90 @@ impl<'db> GeneralCallableType<'db> {
             .is_some_and(|return_type| return_type.is_fully_static(db))
     }
 
+    /// Return `true` if `self` represents the exact same set of possible runtime objects as `other`.
+    pub(crate) fn is_equivalent_to(self, db: &'db dyn Db, other: Self) -> bool {
+        let self_signature = self.signature(db);
+        let other_signature = other.signature(db);
+
+        let self_parameters = self_signature.parameters();
+        let other_parameters = other_signature.parameters();
+
+        if self_parameters.len() != other_parameters.len() {
+            return false;
+        }
+
+        if self_parameters.is_gradual() || other_parameters.is_gradual() {
+            return false;
+        }
+
+        // Check equivalence relationship between two optional types. If either of them is `None`,
+        // then it is not a fully static type which means it's not equivalent either.
+        let is_equivalent = |self_type: Option<Type<'db>>, other_type: Option<Type<'db>>| match (
+            self_type, other_type,
+        ) {
+            (Some(self_type), Some(other_type)) => self_type.is_equivalent_to(db, other_type),
+            _ => false,
+        };
+
+        if !is_equivalent(self_signature.return_ty, other_signature.return_ty) {
+            return false;
+        }
+
+        for (self_parameter, other_parameter) in self_parameters.iter().zip(other_parameters) {
+            match (self_parameter.kind(), other_parameter.kind()) {
+                (
+                    ParameterKind::PositionalOnly {
+                        default_ty: self_default,
+                        ..
+                    },
+                    ParameterKind::PositionalOnly {
+                        default_ty: other_default,
+                        ..
+                    },
+                ) if self_default.is_some() == other_default.is_some() => {}
+
+                (
+                    ParameterKind::PositionalOrKeyword {
+                        name: self_name,
+                        default_ty: self_default,
+                    },
+                    ParameterKind::PositionalOrKeyword {
+                        name: other_name,
+                        default_ty: other_default,
+                    },
+                ) if self_default.is_some() == other_default.is_some()
+                    && self_name == other_name => {}
+
+                (ParameterKind::Variadic { .. }, ParameterKind::Variadic { .. }) => {}
+
+                (
+                    ParameterKind::KeywordOnly {
+                        name: self_name,
+                        default_ty: self_default,
+                    },
+                    ParameterKind::KeywordOnly {
+                        name: other_name,
+                        default_ty: other_default,
+                    },
+                ) if self_default.is_some() == other_default.is_some()
+                    && self_name == other_name => {}
+
+                (ParameterKind::KeywordVariadic { .. }, ParameterKind::KeywordVariadic { .. }) => {}
+
+                _ => return false,
+            }
+
+            if !is_equivalent(
+                self_parameter.annotated_type(),
+                other_parameter.annotated_type(),
+            ) {
+                return false;
+            }
+        }
+
+        true
+    }
+
     /// Return `true` if `self` has exactly the same set of possible static materializations as
     /// `other` (if `self` represents the same set of possible sets of possible runtime objects as
     /// `other`).
diff --git a/crates/red_knot_python_semantic/src/types/signatures.rs b/crates/red_knot_python_semantic/src/types/signatures.rs
index 3590c72ac06312..d4324d271cb929 100644
--- a/crates/red_knot_python_semantic/src/types/signatures.rs
+++ b/crates/red_knot_python_semantic/src/types/signatures.rs
@@ -601,6 +601,11 @@ impl<'db> Parameter<'db> {
         self.annotated_ty
     }
 
+    /// Kind of the parameter.
+    pub(crate) fn kind(&self) -> &ParameterKind<'db> {
+        &self.kind
+    }
+
     /// Name of the parameter (if it has one).
     pub(crate) fn name(&self) -> Option<&ast::name::Name> {
         match &self.kind {


Reviews:
user: login: dhruvmanila
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:43:07Z
user: login: AlexWaygood
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T11:34:39Z
user: login: dhruvmanila
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T11:40:57Z
user: login: dcreager
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T13:52:59Z
user: login: AlexWaygood
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T14:00:37Z
user: login: carljm
body: I would be inclined to explore whether we can keep an invariant that equivalent callable types are normalized to actually be the same `Signature` object.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T17:28:56Z
user: login: dhruvmanila
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T04:55:41Z
user: login: dhruvmanila
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T05:18:17Z
user: login: dhruvmanila
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T05:46:02Z
user: login: dhruvmanila
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T06:43:03Z
user: login: dhruvmanila
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T07:06:21Z
user: login: carljm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T23:20:33Z
user: login: carljm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T23:28:17Z
user: login: dhruvmanila
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T03:08:09Z
user: login: dhruvmanila
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T08:48:57Z

Review comments:
diff_hunk: @@ -4542,6 +4546,77 @@ impl<'db> GeneralCallableType<'db> {
             .is_some_and(|return_type| return_type.is_fully_static(db))
     }
 
+    /// Return `true` if `self` represents the exact same set of possible runtime objects as `other`.
+    pub(crate) fn is_equivalent_to(self, db: &'db dyn Db, other: Self) -> bool {
+        let self_signature = self.signature(db);
+        let other_signature = other.signature(db);
+
+        let self_parameters = self_signature.parameters();
+        let other_parameters = other_signature.parameters();
+
+        if self_parameters.len() != other_parameters.len() {
+            return false;
+        }
+
+        if self_parameters.is_gradual() || other_parameters.is_gradual() {
+            return false;
+        }
+
+        // Check equivalence relationship between two optional types. If either of them is `None`,
+        // then it is not a fully static type which means it's not equivalent either.
+        let is_equivalent = |self_type: Option<Type<'db>>, other_type: Option<Type<'db>>| match (
+            self_type, other_type,
+        ) {
+            (Some(self_type), Some(other_type)) => self_type.is_equivalent_to(db, other_type),
+            _ => false,
+        };
+
+        if !is_equivalent(self_signature.return_ty, other_signature.return_ty) {
+            return false;
+        }
+
+        for (self_parameter, other_parameter) in self_parameters.iter().zip(other_parameters) {
+            if self_parameter.name() != other_parameter.name() {
+                return false;
+            }
+
+            if self_parameter.default_type().is_some() != other_parameter.default_type().is_some() {
+                return false;
+            }
+
+            if !matches!(
+                (self_parameter.kind(), other_parameter.kind()),
+                (
+                    ParameterKind::PositionalOnly { .. },
+                    ParameterKind::PositionalOnly { .. }
+                ) | (
+                    ParameterKind::PositionalOrKeyword { .. },
+                    ParameterKind::PositionalOrKeyword { .. }
+                ) | (
+                    ParameterKind::Variadic { .. },
+                    ParameterKind::Variadic { .. }
+                ) | (
+                    ParameterKind::KeywordOnly { .. },
+                    ParameterKind::KeywordOnly { .. }
+                ) | (
+                    ParameterKind::KeywordVariadic { .. },
+                    ParameterKind::KeywordVariadic { .. }
+                )
+            ) {
+                return false;
+            }
path: crates/red_knot_python_semantic/src/types.rs
user: login: dhruvmanila
body: This is required because we want to avoid checking the default type itself for equivalence.
created_at: 2025-03-19T10:42:52Z
author_association: MEMBER
diff_hunk: @@ -4542,6 +4546,77 @@ impl<'db> GeneralCallableType<'db> {
             .is_some_and(|return_type| return_type.is_fully_static(db))
     }
 
+    /// Return `true` if `self` represents the exact same set of possible runtime objects as `other`.
+    pub(crate) fn is_equivalent_to(self, db: &'db dyn Db, other: Self) -> bool {
+        let self_signature = self.signature(db);
+        let other_signature = other.signature(db);
+
+        let self_parameters = self_signature.parameters();
+        let other_parameters = other_signature.parameters();
+
+        if self_parameters.len() != other_parameters.len() {
+            return false;
+        }
+
+        if self_parameters.is_gradual() || other_parameters.is_gradual() {
+            return false;
+        }
+
+        // Check equivalence relationship between two optional types. If either of them is `None`,
+        // then it is not a fully static type which means it's not equivalent either.
+        let is_equivalent = |self_type: Option<Type<'db>>, other_type: Option<Type<'db>>| match (
+            self_type, other_type,
+        ) {
+            (Some(self_type), Some(other_type)) => self_type.is_equivalent_to(db, other_type),
+            _ => false,
+        };
+
+        if !is_equivalent(self_signature.return_ty, other_signature.return_ty) {
+            return false;
+        }
+
+        for (self_parameter, other_parameter) in self_parameters.iter().zip(other_parameters) {
+            if self_parameter.name() != other_parameter.name() {
+                return false;
+            }
+
+            if self_parameter.default_type().is_some() != other_parameter.default_type().is_some() {
+                return false;
+            }
path: crates/red_knot_python_semantic/src/types.rs
user: login: dhruvmanila
body: This is the main reason a manual implementation is required.
created_at: 2025-03-19T10:43:02Z
author_association: MEMBER
diff_hunk: @@ -118,4 +118,107 @@ class R: ...
 static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
 ```
 
+## Callable
+
+### Equivalent
+
+For an equivalence relationship, the default value does not necessarily need to be the same but if
+the parameter in one of the callable has a default value then the corresponding parameter in the
+other callable should also have a default value.
path: crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
user: login: AlexWaygood
body: this makes me wonder whether we should be preserving the default _value_ of the parameter at all when inferring the signature of a function (as opposed to just whether it _has_ a default). For a `def` function or a `lambda`, we obviously need to check whether the inferred type of the default value is assignable to the parameter annotation (if there is one). But is there a reason we need to keep track of the default value as part of the signature after that?

It seems like it could simplify the implementation you have here quite a bit if our `Signature` did not keep track of the precise default value of parameters, and only kept track of whether each parameter has a default
created_at: 2025-03-19T11:34:39Z
author_association: MEMBER
diff_hunk: @@ -118,4 +118,107 @@ class R: ...
 static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
 ```
 
+## Callable
+
+### Equivalent
+
+For an equivalence relationship, the default value does not necessarily need to be the same but if
+the parameter in one of the callable has a default value then the corresponding parameter in the
+other callable should also have a default value.
path: crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
user: login: dhruvmanila
body: Yeah, that's one of the potential solution that I've mentioned in the PR description. I'll need to check what the fallout of that change would be but I agree that it would be a simpler solution.
created_at: 2025-03-19T11:40:57Z
author_association: MEMBER
diff_hunk: @@ -118,4 +118,107 @@ class R: ...
 static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
 ```
 
+## Callable
+
+### Equivalent
+
+For an equivalence relationship, the default value does not necessarily need to be the same but if
+the parameter in one of the callable has a default value then the corresponding parameter in the
+other callable should also have a default value.
path: crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
user: login: dcreager
body: We'll need to store that somewhere, since at a call site we need to know what value to use if no argument is provided for that parameter.

Or I guess — maybe we don't, since if no argument was matched to the parameter, there's no argument type to verify is assignable to that default value type?

I'm wondering if generics would be an issue. _[tests something...]_  Yes, [here we go](https://pyright-play.net/?strict=true&code=CYUwZgBGDaAqC6AKAHgLgrCBeCAWAlBALQB8EANgJYDOALnPKgFASsQBOItAruwHYRoyeEyacAbiACG5APq0AngAcQiMIgCMAdnz4xISTPnLV6gER12lPgHMzu-YbmKVaxA6A):

```python
def f[T](x: T = 4) -> list[T]:
    return [x]

reveal_type(f(17))  # revealed: list[int]
reveal_type(f("string"))  # revealed: list[str]
reveal_type(f())  # revealed: list[int]
```

We'll need to keep around the default `Literal[4]` / `int` type to be able to solve the constraint system for when we instantiate that generic function in the `f()` call.
created_at: 2025-03-19T13:52:59Z
author_association: MEMBER
diff_hunk: @@ -118,4 +118,107 @@ class R: ...
 static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
 ```
 
+## Callable
+
+### Equivalent
+
+For an equivalence relationship, the default value does not necessarily need to be the same but if
+the parameter in one of the callable has a default value then the corresponding parameter in the
+other callable should also have a default value.
path: crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
user: login: AlexWaygood
body: > ```python
> def f[T](x: T = 4) -> list[T]:
>     return [x]
> 
> reveal_type(f(17))  # revealed: list[int]
> reveal_type(f("string"))  # revealed: list[str]
> reveal_type(f())  # revealed: list[int]
> ```

Pyright accepts that function but mypy rejects it. I'm not sure whether using a TypeVar for a parameter with a default value like that is explicitly allowed by the spec or not.

If I were writing a stub for this function in typeshed, I would use overloads in order to be explicit and unambiguous:

```py
@overload
def f(x: int = 4) -> list[int]: ...
@overload
def f[T](x: T) -> list[T]: ...
```

although I think using a type parameter with a default would also be unambiguous (but mypy still rejects it -- not sure if that's a mypy bug or not):

```py
def f[T = int](x: T = 4) -> list[T]: ...
```
created_at: 2025-03-19T14:00:37Z
author_association: MEMBER
diff_hunk: @@ -4542,6 +4546,77 @@ impl<'db> GeneralCallableType<'db> {
             .is_some_and(|return_type| return_type.is_fully_static(db))
     }
 
+    /// Return `true` if `self` represents the exact same set of possible runtime objects as `other`.
+    pub(crate) fn is_equivalent_to(self, db: &'db dyn Db, other: Self) -> bool {
+        let self_signature = self.signature(db);
+        let other_signature = other.signature(db);
+
+        let self_parameters = self_signature.parameters();
+        let other_parameters = other_signature.parameters();
+
+        if self_parameters.len() != other_parameters.len() {
+            return false;
+        }
+
+        if self_parameters.is_gradual() || other_parameters.is_gradual() {
+            return false;
+        }
+
+        // Check equivalence relationship between two optional types. If either of them is `None`,
+        // then it is not a fully static type which means it's not equivalent either.
+        let is_equivalent = |self_type: Option<Type<'db>>, other_type: Option<Type<'db>>| match (
+            self_type, other_type,
+        ) {
+            (Some(self_type), Some(other_type)) => self_type.is_equivalent_to(db, other_type),
+            _ => false,
+        };
+
+        if !is_equivalent(self_signature.return_ty, other_signature.return_ty) {
+            return false;
+        }
+
+        for (self_parameter, other_parameter) in self_parameters.iter().zip(other_parameters) {
+            if self_parameter.name() != other_parameter.name() {
path: crates/red_knot_python_semantic/src/types.rs
user: login: carljm
body: I think this should not be required for positional-only parameters? Their name cannot affect whether a call succeeds or fails to bind.
created_at: 2025-03-19T17:17:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4542,6 +4546,77 @@ impl<'db> GeneralCallableType<'db> {
             .is_some_and(|return_type| return_type.is_fully_static(db))
     }
 
+    /// Return `true` if `self` represents the exact same set of possible runtime objects as `other`.
+    pub(crate) fn is_equivalent_to(self, db: &'db dyn Db, other: Self) -> bool {
+        let self_signature = self.signature(db);
+        let other_signature = other.signature(db);
+
+        let self_parameters = self_signature.parameters();
+        let other_parameters = other_signature.parameters();
+
+        if self_parameters.len() != other_parameters.len() {
+            return false;
+        }
+
+        if self_parameters.is_gradual() || other_parameters.is_gradual() {
+            return false;
+        }
+
+        // Check equivalence relationship between two optional types. If either of them is `None`,
+        // then it is not a fully static type which means it's not equivalent either.
path: crates/red_knot_python_semantic/src/types.rs
user: login: carljm
body: Ultimately we will also need `is_gradual_equivalent_to`, in which `None` vs `None` should be considered equivalent. Similar to what we previously discussed for `is_subtype_of` vs `is_assignable_to`, it may end up being simplest to rename this method to `is_gradual_equivalent_to` and ensure it handles all types correctly, and in the `is_equivalent_to_` case it will only be called with fully-static callable types.
created_at: 2025-03-19T17:19:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,4 +118,107 @@ class R: ...
 static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
 ```
 
+## Callable
+
+### Equivalent
+
+For an equivalence relationship, the default value does not necessarily need to be the same but if
+the parameter in one of the callable has a default value then the corresponding parameter in the
+other callable should also have a default value.
path: crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
user: login: carljm
body: As far as I know (and can see), we don't use the default value from the Signature object either for verifying its assignability to the annotated type, or for deciding the type of the parameter internally in the function. All of that is derived directly from inference on the AST. So I think just storing a boolean for defaults could work.

I do see a significant advantage to trying to maintain the invariant that equivalent callable types should be identical. Not only for implementation complexity of `is_equivalent_to`, but also for predictable behavior. If two callable types are equivalent, we would simplify one of them from a union in favor of the other, and it could be weird if we would arbitrarily carry metadata that doesn't affect equivalence from one of them and not from the other.

The other change I think we would need to make in order to implement "equivalent signatures are identical" is to never store a name for a positional-only parameter. (It also looks like this PR might currently be missing the case that two callable types are equivalent if their only difference is the name of a positional-only parameter.)

Another approach could be to maintain this invariant for `GeneralCallable` types, but not necessarily for `Signature` itself. That is, `Signature` could retain the ability for positional-only parameters to have names, and retain the type of default values, but we could ensure that whenever we create a `GeneralCallable` type, we erase the default types (replace with `Unknown`) and erase the names of positional-only parameters. This approach would require a bit more care, but might be useful if we think that precise default types and names of positional-only parameters are useful to have for e.g. signatures of `FunctionLiteral` types. (I am not sure they are useful even there, though. So I would be inclined to first evaluate the "never store this information" option and see what the fallout is like. It will probably impact whether we use parameter names or positions in binding errors against positional-only parameters, but I am not sure that using names is preferable there.)

Regarding @dcreager 's generics example, I think it is interesting but in some sense orthogonal to this PR. If we decide to follow pyright and include the default type as a constraint on a type variable, then we not only need to preserve that default type in the signature, we also need to consider it in equivalence of callable types. Two generic functions differing only in the default type of a parameter would not actually be equivalent. So this could be supported (if we decide we need to support it) by introducing an enum with `DefaultType::Present` and `DefaultType::Precise(Type<'db>)` variants, or (if we decide to have `Signature` continue to represent default types, but erase them to `Unknown` in callable types) by just not erasing those particular default types that do matter.
created_at: 2025-03-19T17:28:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4542,6 +4546,77 @@ impl<'db> GeneralCallableType<'db> {
             .is_some_and(|return_type| return_type.is_fully_static(db))
     }
 
+    /// Return `true` if `self` represents the exact same set of possible runtime objects as `other`.
+    pub(crate) fn is_equivalent_to(self, db: &'db dyn Db, other: Self) -> bool {
+        let self_signature = self.signature(db);
+        let other_signature = other.signature(db);
+
+        let self_parameters = self_signature.parameters();
+        let other_parameters = other_signature.parameters();
+
+        if self_parameters.len() != other_parameters.len() {
+            return false;
+        }
+
+        if self_parameters.is_gradual() || other_parameters.is_gradual() {
+            return false;
+        }
+
+        // Check equivalence relationship between two optional types. If either of them is `None`,
+        // then it is not a fully static type which means it's not equivalent either.
path: crates/red_knot_python_semantic/src/types.rs
user: login: dhruvmanila
body: We already have `is_gradual_equivalent_to` but I understand what you mean. I'm going to first explore whether we can maintain the equivalence relation at the type level and then see if this needs to be simplified.
created_at: 2025-03-20T04:55:41Z
author_association: MEMBER
diff_hunk: @@ -118,4 +118,107 @@ class R: ...
 static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
 ```
 
+## Callable
+
+### Equivalent
+
+For an equivalence relationship, the default value does not necessarily need to be the same but if
+the parameter in one of the callable has a default value then the corresponding parameter in the
+other callable should also have a default value.
path: crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
user: login: dhruvmanila
body: Yeah, I was thinking of exploring an alternate implementation of maintaining the invariant at the type level, will do so now.

> The other change I think we would need to make in order to implement "equivalent signatures are identical" is to never store a name for a positional-only parameter. (It also looks like this PR might currently be missing the case that two callable types are equivalent if their only difference is the name of a positional-only parameter.)

Yeah, I missed that. This does have a minor fallout where the display would only show the type of the parameter as there's no name but I don't think that's a major issue. Mypy does not show the name for the positional-only parameter while Pyright does:
```
Pyright: Type of "right" is "(x: int, y: float, /, *, a: int) -> None"
mypy: Revealed type is "def (builtins.int, builtins.float, *, a: builtins.int)"
```

Additionally, this might also have a fallout in the LSP where the hover implementation and the signature help would only show the positional-only parameter type and not the name. Following is a screenshot triggering the signature help for the first parameter:

<img width="652" alt="Screenshot 2025-03-20 at 10 46 02 AM" src="https://github.com/user-attachments/assets/c424535e-7e36-4b85-9c86-11851550bd68" />

It might be fine for signature help but I think the hover implementation should still show the signature with the parameter names even for positional-only parameters.
created_at: 2025-03-20T05:18:17Z
author_association: MEMBER
diff_hunk: @@ -4542,6 +4546,77 @@ impl<'db> GeneralCallableType<'db> {
             .is_some_and(|return_type| return_type.is_fully_static(db))
     }
 
+    /// Return `true` if `self` represents the exact same set of possible runtime objects as `other`.
+    pub(crate) fn is_equivalent_to(self, db: &'db dyn Db, other: Self) -> bool {
+        let self_signature = self.signature(db);
+        let other_signature = other.signature(db);
+
+        let self_parameters = self_signature.parameters();
+        let other_parameters = other_signature.parameters();
+
+        if self_parameters.len() != other_parameters.len() {
+            return false;
+        }
+
+        if self_parameters.is_gradual() || other_parameters.is_gradual() {
+            return false;
+        }
+
+        // Check equivalence relationship between two optional types. If either of them is `None`,
+        // then it is not a fully static type which means it's not equivalent either.
+        let is_equivalent = |self_type: Option<Type<'db>>, other_type: Option<Type<'db>>| match (
+            self_type, other_type,
+        ) {
+            (Some(self_type), Some(other_type)) => self_type.is_equivalent_to(db, other_type),
+            _ => false,
+        };
+
+        if !is_equivalent(self_signature.return_ty, other_signature.return_ty) {
+            return false;
+        }
+
+        for (self_parameter, other_parameter) in self_parameters.iter().zip(other_parameters) {
+            if self_parameter.name() != other_parameter.name() {
path: crates/red_knot_python_semantic/src/types.rs
user: login: dhruvmanila
body: I think it's the same for variadic and keyword-variadic parameters as well because their name is not relevant in a subtype relation.

Both of the assignment is valid which means they're equivalent:

```py
class OtherCallable(Protocol):  # Supertype
    def __call__(self, *args1: int) -> Any: ...


class SelfCallable(Protocol):  # Subtype
    def __call__(self, *args2: int) -> None: ...


def _(self_callable: SelfCallable, other_callable: OtherCallable):
    a: OtherCallable = self_callable
    b: SelfCallable = other_callable
```
created_at: 2025-03-20T05:46:02Z
author_association: MEMBER
diff_hunk: @@ -4542,6 +4546,77 @@ impl<'db> GeneralCallableType<'db> {
             .is_some_and(|return_type| return_type.is_fully_static(db))
     }
 
+    /// Return `true` if `self` represents the exact same set of possible runtime objects as `other`.
+    pub(crate) fn is_equivalent_to(self, db: &'db dyn Db, other: Self) -> bool {
+        let self_signature = self.signature(db);
+        let other_signature = other.signature(db);
+
+        let self_parameters = self_signature.parameters();
+        let other_parameters = other_signature.parameters();
+
+        if self_parameters.len() != other_parameters.len() {
+            return false;
+        }
+
+        if self_parameters.is_gradual() || other_parameters.is_gradual() {
+            return false;
+        }
+
+        // Check equivalence relationship between two optional types. If either of them is `None`,
+        // then it is not a fully static type which means it's not equivalent either.
path: crates/red_knot_python_semantic/src/types.rs
user: login: dhruvmanila
body: Hmm, taking a look at the current implementation of `is_gradual_equivalent_to`, I think it's incorrect because it only looks at the annotated type for the parameters and not any other properties like name, default, and kind. This means that the following two callable types are gradual equivalent:

```py
def f1(a): ...
def f2(b): ...
```

I think I'll fix this as a follow-up which will then also include the simplification because it needs to special case the `...` gradual form.
created_at: 2025-03-20T06:43:03Z
author_association: MEMBER
diff_hunk: @@ -118,4 +118,107 @@ class R: ...
 static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
 ```
 
+## Callable
+
+### Equivalent
+
+For an equivalence relationship, the default value does not necessarily need to be the same but if
+the parameter in one of the callable has a default value then the corresponding parameter in the
+other callable should also have a default value.
path: crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
user: login: dhruvmanila
body: This will also have a fallout in the error messages where we won't be able to include the name of the positional-only, variadic and keyword-variadic parameters, it will only include the index.

For example, in a function that accepts positional-only parameter:
```diff
- Object of type `float` cannot be assigned to parameter 1 (`a`) of function `pos_only`; expected type `int`
+ Object of type `float` cannot be assigned to parameter 1 of function `pos_only`; expected type `int`
```

For variadic and keyword-variadic, we could choose to be explicit and mention the parameter kind instead of the name:
```diff
- Object of type `float` cannot be assigned to parameter `*args` of function `var`; expected type `int`
+ Object of type `float` cannot be assigned to variadic parameter of function `var`; expected type `int` [lint:invalid-argument-type]
```

And, for keyword variadic parameter:

```diff
+ Object of type `float` cannot be assigned to keyword variadic parameter of function `kvar`; expected type `int` [lint:invalid-argument-type]
```
created_at: 2025-03-20T07:06:21Z
author_association: MEMBER
diff_hunk: @@ -118,4 +118,107 @@ class R: ...
 static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
 ```
 
+## Callable
+
+### Equivalent
+
+For an equivalence relationship, the default value does not necessarily need to be the same but if
+the parameter in one of the callable has a default value then the corresponding parameter in the
+other callable should also have a default value.
path: crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
user: login: carljm
body: I think all of these observations are not arguments against making equivalent `GeneralCallable` types identical, but they are arguments in favor of this variant that I mentioned:

> Another approach could be to maintain this invariant for `GeneralCallable` types, but not necessarily for `Signature` itself. That is, `Signature` could retain the ability for positional-only parameters to have names, and retain the type of default values, but we could ensure that whenever we create a `GeneralCallable` type, we erase the default types (replace with `Unknown`) and erase the names of positional-only parameters.

With LSP hover and with most function calls, you will have a specific known `FunctionLiteral` or `BoundMethod`, which are singletons and therefore have no equivalent, and can maintain full details. I think in the much less common case of calling a general callable type (this would mostly happen with callbacks or similar scenarios), it is fine if we don't have names of positional or variadic arguments. Most general callable types in practice will come from `Callable` annotations, which can't even represent named positional arguments or variadic arguments.

So I still think it would be preferable to implement this "erasing" of irrelevant details when we go from a function-literal to a general callable type, so that equivalent callable types are identical.

But I'm also OK with deferring that if you would rather land this approach for now.


created_at: 2025-03-20T23:20:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,4 +118,107 @@ class R: ...
 static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
 ```
 
+## Callable
+
+### Equivalent
+
+For an equivalence relationship, the default value does not necessarily need to be the same but if
+the parameter in one of the callable has a default value then the corresponding parameter in the
+other callable should also have a default value.
path: crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
user: login: dhruvmanila
body: > So I still think it would be preferable to implement this "erasing" of irrelevant details when we go from a function-literal to a general callable type, so that equivalent callable types are identical.
> 
> But I'm also OK with deferring that if you would rather land this approach for now.

Yeah, I think that makes sense. This does mean that (a) we'll need to make `name: Name` into `name: Option<Name>` to erase it or replace it with `Name("")` and (b) use something else for the default type as `Type::Unknown` is not a static type, maybe just replace it with `None`.

I'd prefer to go forward with this approach now, I'll open an issue with some of my thoughts regarding this.
created_at: 2025-03-21T03:08:08Z
author_association: MEMBER
diff_hunk: @@ -118,4 +118,107 @@ class R: ...
 static_assert(is_equivalent_to(Intersection[tuple[P | Q], R], Intersection[tuple[Q | P], R]))
 ```
 
+## Callable
+
+### Equivalent
+
+For an equivalence relationship, the default value does not necessarily need to be the same but if
+the parameter in one of the callable has a default value then the corresponding parameter in the
+other callable should also have a default value.
path: crates/red_knot_python_semantic/resources/mdtest/type_properties/is_equivalent_to.md
user: login: dhruvmanila
body: #16881 
created_at: 2025-03-21T08:48:57Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-13T05:33:04Z
body: <!-- generated-comment mypy_primer -->
## `mypy_primer` results
No ecosystem changes detected ✅

author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-20T06:57:01Z
body: <!-- generated-comment ecosystem -->
## `ruff-ecosystem` results
### Linter (stable)
✅ ecosystem check detected no linter changes.

### Linter (preview)
✅ ecosystem check detected no linter changes.

### Formatter (stable)
✅ ecosystem check detected no format changes.

### Formatter (preview)
✅ ecosystem check detected no format changes.



author_association: CONTRIBUTOR
user: login: dhruvmanila
created_at: 2025-03-20T12:19:05Z
body: **Update from the above discussion:**

1. I explored the option to maintain the equivalent relation at the type level but it has certain fallout for downstream usages which I've mentioned [here](https://github.com/astral-sh/ruff/pull/16698#discussion_r2004828071) and [here](https://github.com/astral-sh/ruff/pull/16698#discussion_r2004947916).
2. For now, I've updated the manual implementation account for the cases where name shouldn't be checked for positional-only, variadic and keyword-variadic parameter
3. It turns out `is_gradual_equivalent_to` is incorrect in that sense, refer to [this comment](https://github.com/astral-sh/ruff/pull/16698#discussion_r2004920075) and will fix it in a follow-up which will also simplify both `is_equivalent_to` and `is_gradual_equivalent_to`
author_association: MEMBER