Skip to content

[ty] Infer ParamSpecs from call-wide constraints - #28828

Draft
dhruvmanila wants to merge 10 commits into
mainfrom
dhruv/paramspec-inference
Draft

dhruvmanila wants to merge 10 commits into
mainfrom
dhruv/paramspec-inference

Conversation

@dhruvmanila

Copy link
Copy Markdown
Member

Summary

Infer ParamSpecs from the call's pending constraint set, following the first-supplied-parameter-list policy described in astral-sh/ty#3555. Preserve ordinary type-variable inference alongside those parameter lists, use defaults for missing bindings, and retain known parameter lists when inference for another variable exceeds its work limit.

Also specialize callable receivers from captured parameter lists. Combining repeated ParamSpec bounds and both constructor parameter lists remains unsupported.

Test Plan

Add legacy and PEP 695 mdtests covering parameter shapes, consumers, variance, repeated ParamSpecs, defaults, incomplete inference, callable receivers, and constructor limitations. The full semantic test suite passed: 962 passed and 34 skipped.

@dhruvmanila dhruvmanila added the ty The ty type checker label Sep 23, 2026
@dhruvmanila
dhruvmanila added this pull request to stack #28829 September 23, 2026 12:37
@astral-sh-bot

astral-sh-bot Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 98.24%. The percentage of expected errors that received a diagnostic held steady at 98.24%. The number of fully passing files held steady at 134/146.

@astral-sh-bot

astral-sh-bot Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
trio 92.58MB 92.68MB +0.11% (102.59kB) ⏫
flake8 38.28MB 38.28MB -0.01% (5.33kB) ⬇️
sphinx 172.48MB 172.46MB -0.01% (21.13kB) ⬇️
prefect 475.76MB 475.58MB -0.04% (184.84kB) ⬇️

Significant changes

Click to expand detailed breakdown

trio

Name Old New Diff Outcome
for_constraint_pair_inner 807.70kB 866.52kB +7.28% (58.82kB) ⏫
infer_definition_types 4.68MB 4.64MB -0.81% (38.65kB) ⬇️
for_constraint_pair_inner::interned_arguments 564.12kB 599.62kB +6.29% (35.50kB) ⏫
CallableType 1.63MB 1.64MB +0.46% (7.63kB) ⏫
infer_expression_types_impl 5.96MB 5.96MB -0.12% (7.51kB) ⬇️
when_constraint_set_assignable_to_owned_impl 511.80kB 518.45kB +1.30% (6.65kB) ⏫
infer_scope_types_impl 2.83MB 2.83MB -0.22% (6.34kB) ⬇️
Type<'db>::apply_specialization_inner_::interned_arguments 929.45kB 924.22kB -0.56% (5.23kB) ⬇️
ProtocolInterface 197.36kB 201.90kB +2.30% (4.54kB) ⏫
VarianceVariable 103.97kB 108.00kB +3.88% (4.03kB) ⏫
TypePair 1.10MB 1.11MB +0.31% (3.47kB) ⏫
Type<'db>::cached_materialization_::interned_arguments 88.52kB 91.72kB +3.62% (3.20kB) ⏫
GenericAlias<'db>::variance_equation_::interned_arguments 46.92kB 50.10kB +6.78% (3.18kB) ⏫
Type<'db>::apply_specialization_inner_ 652.40kB 649.51kB -0.44% (2.89kB) ⬇️
IntersectionType 267.73kB 270.44kB +1.01% (2.71kB) ⏫
... 57 more

flake8

Name Old New Diff Outcome
Type<'db>::apply_specialization_inner_::interned_arguments 146.64kB 144.77kB -1.28% (1.88kB) ⬇️
Type<'db>::apply_specialization_inner_ 96.03kB 94.91kB -1.17% (1.12kB) ⬇️
infer_definition_types 1.17MB 1.17MB -0.06% (784.00B) ⬇️
infer_scope_types_impl 536.76kB 536.34kB -0.08% (424.00B) ⬇️
infer_expression_types_impl 786.21kB 785.81kB -0.05% (408.00B) ⬇️
infer_unpack_types 27.09kB 26.81kB -1.04% (288.00B) ⬇️
all_narrowing_constraints_for_expression 74.72kB 74.61kB -0.15% (112.00B) ⬇️
TypeVarInference 56.71kB 56.61kB -0.18% (104.00B) ⬇️
member_lookup_with_policy_and_receiver_inner 58.24kB 58.20kB -0.07% (40.00B) ⬇️
infer_expression_type_impl 20.34kB 20.30kB -0.19% (40.00B) ⬇️
merged_specialization_inner 19.38kB 19.34kB -0.20% (40.00B) ⬇️
member_lookup_with_policy_inner 287.20kB 287.16kB -0.01% (40.00B) ⬇️
infer_statement_types_impl 30.47kB 30.44kB -0.10% (32.00B) ⬇️
StaticClassLiteral<'db>::implicit_attribute_inner_ 54.84kB 54.82kB -0.04% (24.00B) ⬇️
loop_header_reachability 10.74kB 10.73kB -0.15% (16.00B) ⬇️
... 2 more

sphinx

Name Old New Diff Outcome
for_constraint_pair_inner 932.08kB 951.34kB +2.07% (19.26kB) ⏫
Type<'db>::apply_specialization_inner_::interned_arguments 1.16MB 1.14MB -1.56% (18.52kB) ⬇️
infer_definition_types 11.71MB 11.70MB -0.10% (12.03kB) ⬇️
for_constraint_pair_inner::interned_arguments 686.75kB 698.12kB +1.66% (11.38kB) ⏫
Type<'db>::apply_specialization_inner_ 775.34kB 764.04kB -1.46% (11.30kB) ⬇️
infer_expression_types_impl 11.66MB 11.65MB -0.05% (5.63kB) ⬇️
infer_scope_types_impl 6.20MB 6.19MB -0.07% (4.52kB) ⬇️
infer_unpack_types 259.52kB 255.04kB -1.72% (4.48kB) ⬇️
all_narrowing_constraints_for_expression 1.66MB 1.65MB -0.22% (3.71kB) ⬇️
when_constraint_set_assignable_to_owned_impl 922.01kB 924.30kB +0.25% (2.30kB) ⏫
VarianceVariable 67.22kB 67.97kB +1.12% (768.00B) ⏫
TypePair 2.67MB 2.67MB +0.03% (768.00B) ⏫
IntersectionType 601.55kB 602.27kB +0.12% (736.00B) ⏫
GenericAlias<'db>::variance_equation_::interned_arguments 27.33kB 28.02kB +2.52% (704.00B) ⏫
when_constraint_set_equivalent_to_impl 71.87kB 72.45kB +0.80% (588.00B) ⏫
... 33 more

prefect

Name Old New Diff Outcome
CallableType 8.96MB 8.55MB -4.57% (419.56kB) ⬇️
for_constraint_pair_inner 3.42MB 3.60MB +5.37% (188.01kB) ⏫
for_constraint_pair_inner::interned_arguments 2.27MB 2.39MB +5.29% (123.00kB) ⏫
Type<'db>::cached_materialization_::interned_arguments 404.22kB 342.89kB -15.17% (61.33kB) ⬇️
Type<'db>::cached_materialization_ 340.12kB 293.34kB -13.76% (46.79kB) ⬇️
Type<'db>::apply_specialization_inner_::interned_arguments 4.10MB 4.06MB -0.94% (39.61kB) ⬇️
infer_definition_types 37.82MB 37.79MB -0.08% (29.19kB) ⬇️
VarianceVariable 388.88kB 413.72kB +6.39% (24.84kB) ⏫
Type<'db>::apply_specialization_inner_ 2.96MB 2.93MB -0.82% (24.70kB) ⬇️
when_constraint_set_assignable_to_owned_impl 3.58MB 3.60MB +0.55% (20.09kB) ⏫
GenericAlias<'db>::variance_equation_::interned_arguments 170.50kB 189.32kB +11.04% (18.82kB) ⏫
VarianceVariable<'db>::equation_ 212.15kB 225.34kB +6.22% (13.20kB) ⏫
VarianceVariable<'db>::effective_variance_ 262.28kB 273.77kB +4.38% (11.48kB) ⏫
GenericAlias<'db>::variance_equation_ 100.88kB 111.91kB +10.94% (11.04kB) ⏫
VarianceProduct 86.02kB 94.38kB +9.72% (8.36kB) ⏫
... 72 more

@astral-sh-bot

astral-sh-bot Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

ecosystem-analyzer results

Lint rule Added Removed Changed
invalid-argument-type 2 1 2
invalid-assignment 0 3 0
type-assertion-failure 0 0 2
unsound-return-statement 1 0 1
unused-type-ignore-comment 2 0 0
Total 5 4 5

Large timing changes:

Project Old Time New Time Change
pytest-autoprofile 0.10s 0.26s +155%

Raw diff:

discord.py (https://github.com/Rapptz/discord.py)
- discord/app_commands/checks.py:390:42 error[invalid-assignment] Object of type `Coroutine[Any, Any, Cooldown | None] | Cooldown | None` is not assignable to `Cooldown | None`
+ discord/app_commands/commands.py:2074:16 warning[unsound-return-statement] Unsound return statement: `Command[GroupT@decorator, (...), T@decorator]` is not a subtype of `Command[GroupT@decorator, P@decorator, T@decorator]`
+ discord/ext/commands/core.py:1063:71 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive
+ discord/ext/commands/core.py:1641:75 warning[unused-type-ignore-comment] Unused blanket `type: ignore` directive

prefect (https://github.com/PrefectHQ/prefect)
- src/integrations/prefect-dask/prefect_dask/task_runners.py:474:28 error[invalid-argument-type] Argument to bound method `TaskRunner.map` is incorrect: Expected `Task[P@map, R@map | Any | CoroutineType[Any, Any, R@map | Any]]`, found `Task[P@map, R@map]`
+ src/integrations/prefect-dask/prefect_dask/task_runners.py:474:28 error[invalid-argument-type] Argument to bound method `TaskRunner.map` is incorrect: Expected `Task[P@map, Any | CoroutineType[Any, Any, Any]]`, found `Task[P@map, R@map]`
- src/integrations/prefect-ray/prefect_ray/task_runners.py:315:28 error[invalid-argument-type] Argument to bound method `TaskRunner.map` is incorrect: Expected `Task[P@map, R@RayTaskRunner | Any | CoroutineType[Any, Any, R@RayTaskRunner | Any]]`, found `Task[P@map, R@RayTaskRunner]`
+ src/integrations/prefect-ray/prefect_ray/task_runners.py:315:28 error[invalid-argument-type] Argument to bound method `TaskRunner.map` is incorrect: Expected `Task[P@map, Any | CoroutineType[Any, Any, Any]]`, found `Task[P@map, R@RayTaskRunner]`
- src/prefect/server/database/dependencies.py:289:16 warning[unsound-return-statement] Unsound return statement: `_DBInjectorMethod[T@DBInjector & ~None, P@DBInjector, Unknown]` is not a subtype of `Self@__get__ | _DBInjectorMethod[T@DBInjector, P@DBInjector, R@DBInjector]`
+ src/prefect/server/database/dependencies.py:289:16 warning[unsound-return-statement] Unsound return statement: `_DBInjectorMethod[T@DBInjector, P@DBInjector, R@DBInjector | Unknown]` is not a subtype of `Self@__get__ | _DBInjectorMethod[T@DBInjector, P@DBInjector, R@DBInjector]`

pyochain (https://github.com/OutSquareCapital/pyochain)
- tests/test_typing/check_abcs.py:46:42 error[invalid-assignment] Object of type `Set[Vec[Dog]]` is not assignable to `PyoSet[Animal]`
- tests/test_typing/check_abcs.py:47:35 error[invalid-assignment] Object of type `Seq[Vec[Dog]]` is not assignable to `Seq[Animal]`
+ tests/test_concatenate_unsafe.py:17:25 error[invalid-argument-type] Argument to bound method `Pipe.pipe` is incorrect: Expected `(Null[Literal[5]] | Some[int], /) -> Unknown`, found `(opt: Some[int]) -> Unknown`
+ tests/test_result.py:28:24 error[invalid-argument-type] Argument to bound method `Pipe.pipe` is incorrect: Expected `(Err[Literal[42], Any] | Ok[int, Any], /) -> str`, found `(res: Ok[int, Any]) -> str`
- tests/test_typing/check_mixins.py:24:18 error[type-assertion-failure] Type `Seq[Seq[int] | (Top[Pipe] & Iterable[Seq[int] | (Top[Pipe] & Iterable[Seq[int] | (Top[Pipe] & Iterable[Seq[int]])])])]` does not match asserted type `Seq[int]`
+ tests/test_typing/check_mixins.py:24:18 error[type-assertion-failure] Type `Seq[int | Seq[int]]` does not match asserted type `Seq[int]`
- tests/test_typing/check_mixins.py:24:37 error[invalid-argument-type] Argument to bound method `Pipe.pipe` is incorrect: Expected `(Seq[int] | (Iterable[Seq[int] | (Top[Pipe] & Iterable[Seq[int] | (Top[Pipe] & Iterable[Seq[int] | (Top[Pipe] & Iterable[Seq[int]])])])] & Top[Pipe]), /) -> Seq[Seq[int] | (Top[Pipe] & Iterable[Seq[int] | (Top[Pipe] & Iterable[Seq[int] | (Top[Pipe] & Iterable[Seq[int]])])])]`, found `<class 'Seq[Seq[int] | (Top[Pipe] & Iterable[Seq[int] | (Top[Pipe] & Iterable[Seq[int] | (Top[Pipe] & Iterable[Seq[int]])])])]'>`
- tests/test_typing/check_mixins.py:30:19 error[type-assertion-failure] Type `Vec[int | Seq[int]]` does not match asserted type `Vec[int]`
+ tests/test_typing/check_mixins.py:30:19 error[type-assertion-failure] Type `Unknown` does not match asserted type `Vec[int]`

Full report with detailed diff (timing results)

@codspeed

codspeed Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 5.81%

⚡ 1 improved benchmark
✅ 161 untouched benchmarks
⏩ 60 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
⚡ WallTime multithreaded 1.2 s 1.1 s +5.81%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing dhruv/paramspec-inference (26d0d7a) with main (57a6e86)

Open in CodSpeed

Footnotes

  1. 60 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@dhruvmanila
dhruvmanila force-pushed the dhruv/paramspec-inference branch from 23eeae5 to 5f29031 Compare September 30, 2026 09:42
@dhruvmanila
dhruvmanila force-pushed the dhruv/paramspec-inference branch from 5f29031 to e6ac6e3 Compare October 1, 2026 07:35
Captured parameter lists can share type variables with the surrounding call's pending return constraints. Treating their stored generic context as local to a constraint-set comparison freshens and quantifies away those variables, allowing conditional relationships to appear unconditional. For two callback occurrences, this can discard the condition T_run = T_keep and incorrectly connect their independent ParamSpecs.

Preserve the captured variables during lazy signature comparisons. Keep the stored generic context available for callback invocation.

The deferred-quantification proposal in #25559 changes when callable-local variables are removed, not which variables belong to the comparison. Its is_always_satisfied check still applies deferred quantification, so marking captured variables for later removal would still lose the condition when testing unconditional equivalence. These variables must remain outside the comparison's cleanup set even when cleanup is deferred.

Retain eager comparison behavior for compatibility inference while both checking paths exist. Once eager evaluation is removed, the captured-parameter-list distinction should remain without the TypeVarEvaluation condition.

Add a focused regression for equality between captured parameter lists. The existing callback tests cover independent invocations and preserved generics.
Cover concrete parameter lists, consumers, variance, repeated ParamSpecs, defaults, constructor signatures, and callable receivers in both generic syntaxes. Specify the first-seen policy for repeated ParamSpecs.

Exercise parameter-list retention and defaults in Python when inference for another type variable exceeds its budget.

Document the existing limitation when capturing parameter lists from both __new__ and __init__, including the missing argument diagnostic that combining those lists should enable.
Solve ParamSpecs from pending call constraints while keeping TypeVarTuple inference on the compatibility path. Preserve the existing merged specialization behavior: keep the first available parameter list and union-merge ordinary type variables across alternatives, including incomplete inference. Missing bindings use the usual defaults.

Use the streaming projection for merged specializations. Preserve incomplete solution metadata and whole-projection budget recovery. Distinguishing a ParamSpec binding lost to budget exhaustion from missing evidence remains a documented limitation.

Keep the first supplied parameter list while support for combining repeated ParamSpec bounds is deferred. Apply this policy when recording individual mappings and complete constraint sets, preserving requirements on other type variables. Combining the parameter lists from both __new__ and __init__ remains a documented limitation in the constructor tests. Specialize callable receivers from captured parameter-list lower bounds.

Follow the first-seen policy described in astral-sh/ty#3555.
@dhruvmanila
dhruvmanila force-pushed the dhruv/paramspec-inference branch from e6ac6e3 to 9b63b52 Compare October 6, 2026 06:21
@dhruvmanila
dhruvmanila deployed to automations October 6, 2026 06:22 — with GitHub Actions Active
Base automatically changed from dhruv/paramspec-captured-variables to main October 6, 2026 06:35
Cover concrete parameter lists, consumers, variance, repeated ParamSpecs, defaults, constructor signatures, and callable receivers in both generic syntaxes. Specify the first-seen policy for repeated ParamSpecs.

Exercise parameter-list retention and defaults in Python when inference for another type variable exceeds its budget.

Document the existing limitation when capturing parameter lists from both __new__ and __init__, including the missing argument diagnostic that combining those lists should enable.
Solve ParamSpecs from pending call constraints while keeping TypeVarTuple inference on the compatibility path. Preserve the existing merged specialization behavior: keep the first available parameter list and union-merge ordinary type variables across alternatives, including incomplete inference. Missing bindings use the usual defaults.

Use the streaming projection for merged specializations. Preserve incomplete solution metadata and whole-projection budget recovery. Distinguishing a ParamSpec binding lost to budget exhaustion from missing evidence remains a documented limitation.

Keep the first supplied parameter list while support for combining repeated ParamSpec bounds is deferred. Apply this policy when recording individual mappings and complete constraint sets, preserving requirements on other type variables. Combining the parameter lists from both __new__ and __init__ remains a documented limitation in the constructor tests. Specialize callable receivers from captured parameter-list lower bounds.

Follow the first-seen policy described in astral-sh/ty#3555.
@dhruvmanila
dhruvmanila deployed to automations October 6, 2026 06:44 — with GitHub Actions Active
Binding a method can remove its only type variable and leave Some(empty),
while an otherwise identical nongeneric callable has no generic context.
The differing signature identities duplicate ParamSpec constraint choices
and can exhaust the solution budget before overload expansion.

Drop empty contexts after removing Self. Restore the direct combine(x, x, x)
ParamSpec example so it checks all three parameter lists in one inference
operation and retains the existing 64/80-alternative expansion assertions.

This branch was successfully deployed

1 active deployment
automations — 26d0d7a1 Deployed Oct 6, 2026 by dhruvmanila via security-review / security review #90785
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty The ty type checker

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant