Replies: 2 comments
|
Thanks for the detailed writeup! I want to confirm my high-level understanding, then ask two clarifying questions. High-level modelMy reading is that the proposal supports two forms of deferred constraints. For a generic argument: def identity[T](x: T) -> T: ...
def apply[A, B](f: Callable[[A], B]) -> Callable[[A], B]: ...The current solver treats After removing the argument-local A free parameter can also survive inside a returned type, such as For an overloaded argument: @overload
def parse(x: int) -> str: ...
@overload
def parse(x: str) -> int: ...Checking Each disjunct is a table row. A later constraint is distributed into every row: The solver removes unsatisfiable rows and refines rows where Under this reading, free type parameters preserve unresolved equality classes Is this the intended model? Table-cell meaning and join compatibilityDo table cells contain exact solved types, lower and upper bounds, or some other For example: class Animal: ...
class Dog(Animal): ...
class Cat(Animal): ...
@overload
def make(x: int) -> Dog: ...
@overload
def make(x: str) -> Cat: ...
def make(x: int | str) -> Animal: ...
@overload
def encode(x: Animal) -> bytes: ...
@overload
def encode(x: bytes) -> str: ...
def encode(x: Animal | bytes) -> bytes | str: ...
def compose[A, B, C](
f: Callable[[A], B],
g: Callable[[B], C],
) -> Callable[[A], C]: ...
result = compose(make, encode)The The conjunction The RFC's The solver already tracks a lower and an upper bound for each variable. Is each Generic parameters inside overload rowsCan a table row contain free generic parameters or types that contain them? For example: @overload
def f(x: int) -> str: ...
@overload
def f[T](x: list[T]) -> T: ...Checking If a later argument solves The same issue occurs during a join. If another table has The current implementation already allows a generic residual inside an |
|
Thanks for the write-up. Proposed DesignI think this makes sense. Everything you propose is roughly what I was trying to do with I think your mental model of the solver is clearer than mine was at the time, plus I think we've also made some improvements since then. If this seems feasible, I'm 100% in support. The Thoughts on Jia's response
I think you are sort of right, but I think I don't think this is quite the same thing as true disjunctive constraints, which can happen in some circumstances (although we don't handle them, so we misbehave in those settings). Table-cell contents I'm not sure exactly what @rchen152 has planned, but in the existing setup as I remember it:
|
Uh oh!
There was an error while loading. Please reload this page.
What is this?
A proposed redesign of how Pyrefly models calls to higher-order functions.
Why?
Currently, Pyrefly tracks correlations between type parameters introduced by higher-order function calls through sets of variables that are individually solved to
Type::CallableResidualobjects, which are then used to reconstruct generic or overloaded types. This scheme is powerful and expressive, handling many cases that other type checkers cannot, but also terrifically complex, producing a slow drip of hard-to-fix bugs.This proposal presents an alternative design in which most of the complexity is localized to short-lived data structures in the variable solver.
Overview
The design has two halves:
The first half is an incremental improvement to how things work today; the second half is a ground-up redesign.
Generic functions
Consider this example of a generic function (
identity) passed to a higher-order function (apply):To determine the type returned from
apply(identity), Pyrefly checks the type of theidentityargument against the expected type:In the process, we create fresh variables -
@X,@P,@R- for the type parameters involved, then unify the variables. The order in which unification happens doesn’t matter too much, but let’s say@Xand@Pare solved to@R, so at the end of the check, we have one unsolved variable left,@R.Today, the variable is solved to a residual type,
GenericResidual@R. A finalization method runs on the returned type, converting every occurrence ofGenericResidual@Rto a type parameterRand making the callable generic overR.Proposed change: solve the variable directly to a type parameter and set a new
freeflag on the type parameter. The finalization method finds the innermost callable whose parameters reference a free type parameter, makes the callable generic over it, and unsetsfree.This gains us:
Type::CallableResidual.Aside: why a permanent
freeflag on a type rather than transient solver data? There are cases in which the type parameter needs to remain free in the returned type:Overloaded functions
Consider this example of an overloaded function (
parse) passed to a higher-order function (apply):I won’t go into detail about how Pyrefly evaluates
apply(parse)today, but the key observation is that it uses overload residuals, stand-ins for individual type parameters that record what each signature of the overload would solve that type parameter to. However, unlike generic residuals - for which we get correlation for free via variable unification - overload residuals essentially end up being free-floating function parts, and every residual has to carry enough data to reconstruct how it correlates with other residuals.I propose to instead have the solver build a table of correlations, in which the rows are overload signatures and the columns are type parameters. We can eliminate rows that solve type parameters to conflicting types, read off the returned types from the surviving rows, and combine the types for our final answer.
Here’s what the table would look like for
apply(parse)above:AB(int) -> strintstr(str) -> intstrintThere are no conflicting solutions, so we keep both rows. From the first row, we read off
A=int, B=str, giving a returned type of(int) -> str. From the second row, we read offA=str, B=int, giving a returned type of(str) -> int. Combine them, and we get our desired answer ofOverload[(int) -> str, (str) -> int].Here are a couple more involved examples showing when we would eliminate rows. This example demonstrates pruning:
AB(int) -> strintstr(str) -> intstrintPassing in
a=1solves type parameterAtoint, so we prune the row withA=str, leaving exactly one row that gives a returned type ofstr.Pruning has two interesting edge cases:
incompatible-overload-argumenterror, renamed fromincompatible-overload-residualbecause residuals no longer exist as a user-facing concept.Any.This example demonstrates joining:
ABCparse:(int) -> strintstrparse:(str) -> intstrintfmt:(str) -> bytesstrbytesfmt:(int) -> boolintboolWe join rows 1 and 3 on
B=str, and join rows 2 and 4 onB=int, producing the final table:ABCparse:(int) -> str,fmt:(str) -> bytesintstrbytesparse:(str) -> int,fmt:(int) -> boolstrintboolFrom there, we read off the rows and combine them as before.
This design gains us:
Type::CallableResidualand all of its associated machinery can be deleted. Complexity is (mostly) contained to table operations in the solver. (See Combining rows below for an important caveat.)Combining rows
Above, I intentionally picked examples in which the rows combine into an obvious final type. The table is not always so well-behaved. Consider:
The per-row returned types are
Wrapper[[int], str]andWrapper[[str], int]. How do we combine them? If we use a union, both of the legalwrapper.fn(...)calls shown above will fail. An intersection isn’t right, because intersections are unordered, whereas our rows are ordered because they are derived from an overloaded function. But we can’t use overloads either, becauseWrapperis not a callable.I propose adding a new type, tentatively called
Type::Overloaded, that holds a list of alternatives derived from overload signatures. Semantics:Overloadedis assignable to a type if any of its alternatives is. This is consistent with overloaded functions.In the above example, the type of
Wrapper(parse)would beOverloaded[Wrapper[[int], str], Wrapper[[str], int]]. Looking upwrapper.fnwould get usOverload[(int) -> str, (str) -> int], and the calls with42and""would succeed as expected.A nice property of
Type::Overloadedis that it can be an input to another correlation table, providing a natural way to preserve overloaded structure through multiple calls.When should combining rows produce a
Type::Overloaded? Proposed rules:Overloaded.Rule 3 is a heuristic that attempts to cover common cases that can preserve overloaded structure (wrapper classes, callback protocols). It does also catch cases that obviously do not contain overloaded structure (e.g., it will happily build
Overloaded[list[int], list[str]]), but the failure mode isn’t too bad - a few false negatives in edge cases. I also considered some more sophisticated heuristics involving things like variance or counting or comparing type arguments, but they were considerably more complicated to implement and still had obvious failure modes.Finally, the introduction of
Type::Overloadedmeans that this design does not reduce the total number ofTypevariants in Pyrefly. However, the fiddliest logic is now contained to short-lived correlation tables, andOverloaded’s alternatives are regular types. The only novel thing about it is its overloaded structure, and even that has precedence in how overloaded functions work.Performance
Joins are expensive. In a prototype of this design, I timed calls to a function that took N copies of an 8-signature overloaded function. Each additional copy made the call about 10x slower. Capping the number of rows in the table is a must.
With a cap of 64 rows, full check times on numpy and scipy-stubs - two overload-heavy projects - were indistinguishable from trunk. When the number of rows exceeds the cap, we use the same gradual fallback rule as if pruning could not eliminate rows.
All reactions