Skip to content

Commit 35bce19

Browse files
committed
feat[next]: Add support for tuple comprehensions
1 parent 3342813 commit 35bce19

21 files changed

Lines changed: 840 additions & 60 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
tags: []
3+
---
4+
5+
# Homogeneous Tuple Comprehensions
6+
7+
- **Status**: valid
8+
- **Authors**: Till Ehrengruber (@tehrengruber), Sara Faghih-Naini (@SF-N)
9+
- **Created**: 2026-08-27
10+
- **Updated**: 2026-08-27
11+
12+
In the context of tuple comprehensions in the field-view frontend, facing the constraint that every FOAST node carries exactly one type, we decided to support only homogeneous iterables — all elements of the same type — and reject heterogeneous ones in the type deduction.
13+
14+
## Context
15+
16+
Tuple comprehensions, e.g. `tuple(2.0 * el for el in (a, b))`, are typed and lowered with a single mapper: one target (`el`) and one element expression (`2.0 * el`), shared by all elements of the iterable. In FOAST every node has a single `type` attribute, so the target symbol — and consequently every node in the element expression — can only be typed once. If the iterable's elements had different types, the mapper would need a different type per element, i.e. per-element re-typing (monomorphization) of the element expression, which the FOAST type system does not support.
17+
18+
The same constraint exists at the GTIR level: the ITIR type inference also stores a single type per node (and asserts on conflicting re-assignment), so the single `map_tuple` lambda used to lower comprehensions over variable-length tuples can only have one function type. For variable-length iterables heterogeneity cannot occur in the first place, since `VarArgType` describes all elements with a single element type. Rejecting heterogeneous iterables in the FOAST type deduction just surfaces the error earliest, with a source location.
19+
20+
## Decision
21+
22+
Only homogeneous iterables are supported, both fixed-length and variable-length. Heterogeneous ones are rejected in the type deduction. E.g., with `a`, `b`, `c`, `d` of equal type:
23+
24+
```python
25+
tuple(2.0 * el for el in (a, b)) # supported
26+
tuple(2.0 * el for el in (a(V2E), b(V2E))) # supported
27+
tuple(local_el + el for local_el, el in ((a(V2E), b), (c(V2E), d))) # supported
28+
tuple(2.0 * el for el in (a(V2E), b)) # rejected: local vs. non-local element
29+
```
30+
31+
Note that homogeneity applies to the iterable's elements as a whole: in the third example each element is a pair of a local and a non-local field, but all elements share that same tuple type, so each target symbol still has a single consistent type.
32+
33+
## Consequences
34+
35+
- Typing and lowering stay simple: the element expression is visited once, with one type per node.
36+
- Computations over differently-typed elements cannot be written as a comprehension; they must be spelled out per element.
37+
- The restriction could be lifted for fixed-length iterables by typing the mapper generically: the target symbol gets a type variable bounded by the valid element types, so a single type per node still suffices during type deduction. After `map_tuple` expansion the mapper is instantiated once per element, and each instance can then be specialized to its concrete element type. This is possible as a follow-up without breaking existing code, since it only widens the set of accepted programs. For variable-length iterables there is nothing to lift: heterogeneity cannot occur, as `VarArgType` has a single element type by construction.
38+
39+
## References
40+
41+
- PR [#2833](https://github.com/GridTools/gt4py/pull/2833) (tuple comprehension support)

‎docs/development/ADRs/next/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Writing a new ADR is simple:
2626
- [0002 - Field View Lowering](0002-Field_View_Lowering.md)
2727
- [0010 - Domain in Field View](0010-Domain_in_Field_View.md)
2828
- [0013 - Scalar vs 0d-Fields](0013-Scalar_vs_0d_Fields.md)
29+
- [0028 - Homogeneous Tuple Comprehensions](0028-Homogeneous_Tuple_Comprehensions.md)
2930

3031
### Iterator IR #iterator
3132

‎src/gt4py/next/ffront/dialect_parser.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@
5555
),
5656
ast.SetComp: ("set comprehension", ()),
5757
ast.DictComp: ("dictionary comprehension", ()),
58-
ast.GeneratorExp: ("generator expression", ()),
58+
ast.GeneratorExp: (
59+
"generator expression",
60+
("Generator expressions are only supported as the argument of 'tuple(...)'.",),
61+
),
5962
ast.Lambda: (
6063
"'lambda' expression",
6164
("Define a separate function decorated with '@field_operator' instead.",),

‎src/gt4py/next/ffront/field_operator_ast.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from gt4py import eve
1414
from gt4py.eve import Coerced, Node, SourceLocation, SymbolName, SymbolRef, datamodels
15+
from gt4py.eve.extended_typing import MaybeNestedInTuple
1516
from gt4py.eve.traits import SymbolTableTrait
1617
from gt4py.eve.type_definitions import StrEnum
1718
from gt4py.next import utils
@@ -98,6 +99,35 @@ class TupleExpr(Expr):
9899
elts: list[Expr]
99100

100101

102+
# TODO(tehrengruber): extend this to supported nested tuple comprehension.
103+
# e.g. `tuple(element_expr for child in nested_tuple for grand_child in child)`
104+
# would be represented by:
105+
# ```
106+
# class TupleComprehension(Expr): # ruff: noqa: ERA001
107+
# inner: TupleComprehensionMapper | NestedTupleCompr # ruff: noqa: ERA001
108+
# class NestedTupleCompr(Expr, SymbolTableTrait): # ruff: noqa: ERA001
109+
# params: tuple[DataSymbol] # ruff: noqa: ERA001
110+
# body: TupleComprehension # ruff: noqa: ERA001
111+
# ```
112+
class TupleComprehension(Expr):
113+
"""
114+
tuple(element_expr for target in iterable)
115+
Note: The structure here differs from the one in the Python AST. Here we group target and
116+
element expression in order to cleanly nest by the symbols being introduced, whereas in
117+
the Python AST target and iterable are grouped into generator nodes.
118+
"""
119+
120+
inner: TupleComprehensionMapper
121+
iterable: Expr
122+
123+
124+
# This is essentially a lambda. The difference is that for a lambda we might not know the type of
125+
# the args; therefore this is named differently at the moment.
126+
class TupleComprehensionMapper(LocatedNode, SymbolTableTrait):
127+
target: MaybeNestedInTuple[DataSymbol]
128+
element_expr: Expr
129+
130+
101131
class UnaryOp(Expr):
102132
op: dialect_ast_enums.UnaryOperator
103133
operand: Expr

‎src/gt4py/next/ffront/foast_passes/type_deduction.py‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import gt4py.next.ffront.field_operator_ast as foast
1313
from gt4py import eve
1414
from gt4py.eve import NodeTranslator, NodeVisitor, traits
15+
from gt4py.eve.extended_typing import MaybeNestedInTuple
1516
from gt4py.next import common, errors
1617
from gt4py.next.common import Dimension, DimensionKind, promote_dims
1718
from gt4py.next.ffront import (
@@ -24,6 +25,7 @@
2425
from gt4py.next.ffront.foast_passes import utils as foast_utils
2526
from gt4py.next.iterator import builtins
2627
from gt4py.next.type_system import type_info, type_specifications as ts, type_translation
28+
from gt4py.next.utils import tree_map
2729

2830

2931
OperatorNodeT = TypeVar("OperatorNodeT", bound=foast.LocatedNode)
@@ -743,6 +745,117 @@ def visit_TupleExpr(self, node: foast.TupleExpr, **kwargs: Any) -> foast.TupleEx
743745
new_type = ts.TupleType(types=[element.type for element in new_elts])
744746
return foast.TupleExpr(elts=new_elts, type=new_type, location=node.location)
745747

748+
def _deduce_tuple_comprehension_target_type(
749+
self,
750+
target: MaybeNestedInTuple[foast.Symbol],
751+
element_type: ts.TypeSpec,
752+
**kwargs: Any,
753+
) -> MaybeNestedInTuple[foast.Symbol]:
754+
"""
755+
Deduce the types of the loop target, e.g. `(a, b)` in `tuple(a + b for (a, b) in it)`.
756+
757+
Each target symbol starts out with a deferred type; revisiting it with `refine_type`
758+
replaces that by the constituent of `element_type` at the symbol's unpacking position.
759+
"""
760+
761+
@tree_map(with_path_arg=True)
762+
def process_target(target_el: foast.Symbol, path: tuple[int, ...]) -> foast.Symbol:
763+
try:
764+
type_ = element_type
765+
for i in path:
766+
if not isinstance(type_, ts.TupleType) or len(type_.types) <= i:
767+
raise IndexError()
768+
type_ = type_.types[i]
769+
return self.visit(target_el, refine_type=type_, **kwargs)
770+
except IndexError:
771+
raise errors.DSLError(
772+
target_el.location, f"Cannot unpack non-iterable '{type_}' object."
773+
) from None
774+
775+
return process_target(target)
776+
777+
def _deduce_tuple_comprehension_mapper(
778+
self,
779+
node: foast.TupleComprehension,
780+
target: MaybeNestedInTuple[foast.Symbol],
781+
element_type: ts.DataType,
782+
**kwargs: Any,
783+
) -> foast.TupleComprehensionMapper:
784+
"""
785+
Deduce the per-element part, e.g. `a + b for (a, b)` in `tuple(a + b for (a, b) in it)`.
786+
787+
Refines the target symbols against `element_type` and then types the element
788+
expression with those symbols in scope (a fresh child symbol table).
789+
"""
790+
inner_kwargs = {**kwargs, "symtable": kwargs["symtable"].new_child()}
791+
new_target = self._deduce_tuple_comprehension_target_type(
792+
target, element_type, **inner_kwargs
793+
)
794+
return foast.TupleComprehensionMapper(
795+
target=new_target,
796+
element_expr=self.visit(node.inner.element_expr, **inner_kwargs),
797+
location=node.location,
798+
)
799+
800+
def visit_TupleComprehension(
801+
self, node: foast.TupleComprehension, **kwargs: Any
802+
) -> foast.TupleComprehension:
803+
# The target symbols are visited in `_deduce_tuple_comprehension_target_type`,
804+
# where their types are refined against the iterable's element type.
805+
target = node.inner.target
806+
iterable = self.visit(node.iterable, **kwargs)
807+
808+
if isinstance(iterable.type, ts.TupleType):
809+
if len(iterable.type.types) == 0:
810+
raise errors.DSLError(
811+
iterable.location,
812+
"Cannot iterate over an empty tuple in a tuple comprehension.",
813+
)
814+
if not all(
815+
isinstance(element_type, ts.DataType) for element_type in iterable.type.types
816+
):
817+
raise errors.DSLError(
818+
iterable.location,
819+
"Tuple comprehension iterable elements must be data types.",
820+
)
821+
822+
element_types = cast(list[ts.DataType], iterable.type.types)
823+
# Only homogeneous iterables are supported, see ADR 0028.
824+
if not all(element_type == element_types[0] for element_type in element_types):
825+
raise NotImplementedError(
826+
"Tuple comprehensions over fixed-length tuples require all iterable "
827+
"elements to have the same type (see ADR 0028)."
828+
)
829+
new_mapper = self._deduce_tuple_comprehension_mapper(
830+
node, target, element_types[0], **kwargs
831+
)
832+
result = foast.TupleComprehension(
833+
inner=new_mapper,
834+
iterable=iterable,
835+
location=node.location,
836+
type=ts.TupleType(types=[new_mapper.element_expr.type for _ in element_types]),
837+
)
838+
return result
839+
elif isinstance(iterable.type, ts.VarArgType):
840+
element_type = iterable.type.element_type
841+
new_mapper = self._deduce_tuple_comprehension_mapper(
842+
node, target, element_type, **kwargs
843+
)
844+
element_expr = new_mapper.element_expr
845+
return_type = ts.VarArgType(element_type=element_expr.type)
846+
847+
return foast.TupleComprehension(
848+
inner=new_mapper,
849+
iterable=iterable,
850+
location=node.location,
851+
type=return_type,
852+
)
853+
else:
854+
raise errors.DSLError(
855+
iterable.location,
856+
f"Iterable in generator expression must be a tuple, got '{iterable.type}'.",
857+
)
858+
746859
def visit_Call(self, node: foast.Call, **kwargs: Any) -> foast.Call:
747860
new_func = self.visit(node.func, **kwargs)
748861
new_args = self.visit(node.args, **kwargs)

‎src/gt4py/next/ffront/foast_pretty_printer.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,18 @@ def apply(cls, node: foast.LocatedNode, **kwargs: Any) -> str: # type: ignore[o
120120

121121
UnaryOp = as_fmt("{op}{operand}")
122122

123+
def visit_TupleComprehensionMapper(
124+
self, node: foast.TupleComprehensionMapper, **kwargs: Any
125+
) -> str:
126+
element_expr = self.visit(node.element_expr, **kwargs)
127+
target = self.visit(node.target, **kwargs)
128+
return f"{element_expr} for {target}"
129+
130+
def visit_TupleComprehension(self, node: foast.TupleComprehension, **kwargs: Any) -> str:
131+
mapper = self.visit(node.inner, **kwargs)
132+
iterable = self.visit(node.iterable, **kwargs)
133+
return f"tuple(({mapper} in {iterable}))"
134+
123135
def visit_UnaryOp(self, node: foast.UnaryOp, **kwargs: Any) -> str:
124136
if node.op is dialect_ast_enums.UnaryOperator.NOT:
125137
op = "not "

‎src/gt4py/next/ffront/foast_to_gtir.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99

1010
import dataclasses
11+
import functools
1112
from typing import Any, Callable, Optional
1213

1314
from gt4py import eve
@@ -259,6 +260,75 @@ def visit_Subscript(self, node: foast.Subscript, **kwargs: Any) -> itir.Expr:
259260
def visit_TupleExpr(self, node: foast.TupleExpr, **kwargs: Any) -> itir.Expr:
260261
return im.make_tuple(*[self.visit(el, **kwargs) for el in node.elts])
261262

263+
def _bind_tuple_comprehension_target(
264+
self,
265+
comprehension_target: itir.Sym | tuple,
266+
element_expr: itir.Expr,
267+
iterable_element: itir.Expr | str,
268+
) -> itir.Expr:
269+
"""
270+
Wrap `element_expr` in a `let` binding the `comprehension_target` to `iterable_element`.
271+
272+
For `2.0 * a + b for (a, b) in iterable`:
273+
- `comprehension_target`: `(a, b)`
274+
- `element_expr`: `2.0 * a + b`
275+
- `iterable_element`: the current element of `iterable`
276+
277+
returns
278+
`let a = iterable_element[0], b = iterable_element[1] in element_expr`.
279+
"""
280+
if isinstance(comprehension_target, itir.Sym):
281+
return im.let(comprehension_target, iterable_element)(element_expr)
282+
283+
flat_targets = utils.flatten_nested_tuple(comprehension_target)
284+
nested_target_values = utils.tree_map(
285+
lambda _, path: functools.reduce(
286+
lambda element, index: im.tuple_get(index, element), path, iterable_element
287+
),
288+
with_path_arg=True,
289+
)(comprehension_target)
290+
291+
flat_target_values = utils.flatten_nested_tuple(nested_target_values) # type: ignore[arg-type]
292+
293+
target_bindings = tuple(zip(flat_targets, flat_target_values, strict=True))
294+
return im.let(*target_bindings)(element_expr) # type: ignore[arg-type]
295+
296+
def visit_TupleComprehension(self, node: foast.TupleComprehension, **kwargs: Any) -> itir.Expr:
297+
# Only homogeneous iterables — all elements of the same type — are supported;
298+
# heterogeneous ones are rejected in the type deduction (see ADR 0028).
299+
comprehension_target = self.visit(node.inner.target, **kwargs)
300+
element_expr = self.visit(node.inner.element_expr, **kwargs)
301+
iterable_expr = self.visit(node.iterable, **kwargs)
302+
iterable_type = node.iterable.type
303+
304+
lower_body_for_iterable_element = functools.partial(
305+
self._bind_tuple_comprehension_target, comprehension_target, element_expr
306+
)
307+
308+
if isinstance(iterable_type, ts.TupleType):
309+
assert isinstance(node.type, ts.TupleType)
310+
iterable_value_name = next(self.uid_generator["__tuple_comprh"])
311+
312+
fixed_tuple_elements = [
313+
lower_body_for_iterable_element(im.tuple_get(element_index, iterable_value_name))
314+
for element_index in range(len(iterable_type.types))
315+
]
316+
317+
result_tuple = im.make_tuple(*fixed_tuple_elements)
318+
return im.let(iterable_value_name, iterable_expr)(result_tuple)
319+
320+
assert isinstance(iterable_type, ts.VarArgType)
321+
assert isinstance(node.type, ts.VarArgType)
322+
if isinstance(comprehension_target, itir.Sym):
323+
map_tuple_lambda = im.lambda_(comprehension_target)(element_expr)
324+
else:
325+
iterable_element_param = next(self.uid_generator["__tuple_comprh"])
326+
map_tuple_lambda = im.lambda_(iterable_element_param)(
327+
lower_body_for_iterable_element(iterable_element_param)
328+
)
329+
330+
return im.call(im.call("map_tuple")(map_tuple_lambda))(iterable_expr)
331+
262332
def visit_UnaryOp(self, node: foast.UnaryOp, **kwargs: Any) -> itir.Expr:
263333
# TODO(tehrengruber): extend iterator ir to support unary operators
264334
dtype = type_info.extract_dtype(node.type)

‎src/gt4py/next/ffront/foast_to_past.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,12 @@ def __call__(self, inp: ConcreteFOASTOperatorDef) -> ConcretePASTProgramDef:
113113
*partial_program_type.definition.kw_only_args.keys(),
114114
]
115115
assert isinstance(type_, ts.CallableType)
116-
assert arg_types[-1] == type_info.return_type(
116+
return_type = type_info.return_type(
117117
type_, with_args=list(arg_types), with_kwargs=kwarg_types
118118
)
119+
# Not equality: variadic comprehensions give a `VarArg[...]` return type,
120+
# while 'out' is a concrete tuple.
121+
assert type_info.is_concretizable(return_type, arg_types[-1])
119122
assert args_names[-1] == "out"
120123

121124
params_decl: list[past.Symbol] = [

0 commit comments

Comments
 (0)