[ty] Fix divergent tuple unpacking when RHS sequence is the cycle head#23812
[ty] Fix divergent tuple unpacking when RHS sequence is the cycle head#23812charliermarsh merged 5 commits intomainfrom
Conversation
Typing conformance resultsNo changes detected ✅Current numbersThe percentage of diagnostics emitted that were expected errors held steady at 85.05%. The percentage of expected errors that received a diagnostic held steady at 78.05%. The number of fully passing files held steady at 63/132. |
|
Memory usage reportMemory usage unchanged ✅ |
| .insert(target.into(), value_inference.expression_type(value_expr)); | ||
| true | ||
| } | ||
| ast::Expr::Starred(_) => false, |
There was a problem hiding this comment.
You could delete this arm?
| fn unpack_fixed_sequence_from_inference( | ||
| &mut self, | ||
| targets: &[ast::Expr], | ||
| values: Option<&[ast::Expr]>, |
There was a problem hiding this comment.
A little awkward for this to be an Option just so this function can short-circuit. Probably cleaner to remove the Option here, and then have the caller short circuit instead if they have nothing to give us.
| // `self.targets` when a later element returns `false`. This is harmless: | ||
| // the fallback `unpack_inner` path will overwrite any partial entries. | ||
| targets.iter().zip(values).all(|(target, value_expr)| { | ||
| self.unpack_assignment_sequence_from_inference(target, value_expr, value_inference) |
There was a problem hiding this comment.
Is this for wacky recursive cases like a, (b, c) = 1, (2, 3)? That's probably worth a comment. (Looks like there's already a "Nested tuple with unpacking" test case.)
There was a problem hiding this comment.
Oh wait no even the base case a, b = 1, 2 ends up recursing to handle the individual expr::Names?
| /// | ||
| /// When the RHS sequence is the cycle head, its normalized sequence type can collapse | ||
| /// element-level unions like `None | Divergent` to `Divergent`. Reusing the already-inferred | ||
| /// subexpression types lets unpacking converge as if each element had been queried directly. |
There was a problem hiding this comment.
If my understanding here is still up-to-date, this is because recursive_type_normalized likes to collapse recursive structures down to a single level of nesting. (Or I maybe I should say "no nesting"?) So like the classic infinitely growing tuple from an assignment like x = (0, x) gets collapsed to (Literal[0], Divergent), which does make sense. But unfortunately in our unpacking assignments like a, b, c = (foo(), bar(), baz()), that RHS follows the same rule in cyclic cases and gets collapsed to something like (Divergent, Divergent, Divergent), so we lose all information about a, b, and c. We know that the outer/literal tuple structure itself doesn't participate in any recursion, but recursive_type_normalized doesn't know that.
Anyway, if that sounds right, maybe add something like that to the comment here?
| infer_expression_types(self.db(), value.expression(), TypeContext::default()) | ||
| .expression_type(value.expression().node_ref(self.db(), self.module())); | ||
| let value_inference = | ||
| infer_expression_types(self.db(), value.expression(), TypeContext::default()); |
There was a problem hiding this comment.
I really want to understand this part of the change better. If what I said about recursive_type_normalized below is right, we're trying to dodge inferring the RHS as a whole, but it seems like infer_expression_types is still going to aggressively do that. Clearly this works because it's passing tests. So...is there some subtlety here about exactly what ends up being the cycle head? Or am I just wrong that infer_expression_types does everything greedily?
There was a problem hiding this comment.
I think the key is that it returns ExpressionInference, not the type of the outer RHS node, which stores a type for every subexpression. So we then use value_inference.expression_type(child_expr) for each fixed element rather than unpacking from value_inference.expression_type(value_expr).
There was a problem hiding this comment.
If you look at ExpressionInference::expression_type, it's just a hash map lookup, because all the inference is already done by the time infer_expression_types returns. (Or maybe I'm totally wrong...)
There was a problem hiding this comment.
I'm learning that it does store an entry for every child expression separately before it builds the outer tuple... The outer tuple can lose its shape, but the child entries stay precise, I think?
There was a problem hiding this comment.
Yes, ok, Carl and I were discussing the same thing here on the plane :) So the key is that the whole tuple expression is still as gutted as it always was, but we're no longer going through that tuple expression to get the individual elements. Instead we're reaching for the values of the element expressions directly, and those are ... "one level less gutted" ... which ends up being enough for the cycle to stabilize at a useful value.
There was a problem hiding this comment.
Wow this sounds like a great flight!
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
6545ee2 to
d42b093
Compare
| true | ||
| } | ||
| ast::Expr::List(ast::ExprList { elts, .. }) | ||
| | ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => { |
There was a problem hiding this comment.
The recursion here is somewhat mirroring the recursion done in the regular unpack_inner codepath. My instinct was to unify them, though when I ask Claude to do it the result is maybe only a bit simpler, so I could see doing it either way. I'll ask you to pick :)
Co-authored-by: Jack O'Connor <oconnor663@gmail.com>
Summary
This PR adds a fast path in
Unpacker::unpackthat, for assignment unpacking with a fixed-length sequence RHS, structurally matches the LHS targets against the RHS subexpressions and pulls their types directly from the already-inferredExpressionInference.Closes astral-sh/ty#2872.