Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e5b676a
fix[next]: 'concat_where' crashes on local (neighbor-list) fields
tehrengruber Aug 27, 2026
8643e6d
Add 'concat_where' test with nested tuple branches (moved from #2833)
tehrengruber Aug 27, 2026
7b21e9c
feat[next]: Add support for tuple comprehensions
tehrengruber Aug 27, 2026
4a117af
Address Fable review: builtin-constructor arity diagnostic and length…
tehrengruber Aug 27, 2026
666c8b4
Address review comments (unpack diagnostics, DSLError for heterogenei…
tehrengruber Sep 1, 2026
0f11a59
Add empty-tuple comprehension test, xfailed on compiled backends
tehrengruber Sep 1, 2026
681ce77
Add unit test for the generic-tuple constructor type
tehrengruber Sep 1, 2026
5da530e
test[next]: improve coverage for closure vars in tuple comprehension …
havogt Sep 11, 2026
dc655ee
Merge upstream/main into sf_n_tracer_support
havogt Sep 14, 2026
c1ccbad
Treat operators with variable-length tuple params as generic in '_is_…
tehrengruber Sep 1, 2026
68de5f4
Address review comments (unpack arity, nested vararg concretization, …
tehrengruber Sep 21, 2026
840b85e
Fold the unpack-arity check into 'process_target'
tehrengruber Sep 21, 2026
bbb5099
Apply pre-commit ruff formatting
tehrengruber Sep 22, 2026
97576ae
Add TODO about renaming 'VarArgType'
tehrengruber Oct 7, 2026
9d4d8f3
Merge remote-tracking branch 'origin/pr2931' into vararg_program_support
tehrengruber Oct 7, 2026
343ec9b
Fix imports after 'eve.extended_typing' was renamed to 'eve.xtyping' …
tehrengruber Oct 7, 2026
effbb2d
feat[next]: variable-length tuple ('tuple[T, ...]') support for programs
tehrengruber Oct 14, 2026
264e753
refactor[next]: rename 'VarArgType' to 'VarLenTupleType'
tehrengruber Oct 14, 2026
8bb2813
fix[next]: don't assert argument compatibility for operator wrappers
tehrengruber Oct 14, 2026
66dabce
fix[next]: lower uncalled programs with their declared parameter types
tehrengruber Oct 15, 2026
3c61f57
Cleanup comments
tehrengruber Oct 16, 2026
0d80c32
fix[next]: derive named-collection member types from runtime values
tehrengruber Oct 16, 2026
7bda923
fix[next]: support named collections inside variable-length tuples
tehrengruber Oct 16, 2026
e2d872e
fix[next]: handle variable-length tuple arguments of scan operators
tehrengruber Oct 16, 2026
78bdccb
fix[next]: proper error for static domains with variable-length tuples
tehrengruber Oct 16, 2026
a8dbef6
style[next]: fix code quality checks
tehrengruber Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
tags: []
---

# Homogeneous Tuple Comprehensions

- **Status**: valid
- **Authors**: Till Ehrengruber (@tehrengruber), Sara Faghih-Naini (@SF-N)
- **Created**: 2026-08-27
- **Updated**: 2026-08-27

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.

## Context

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.

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.

## Decision

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:

```python
tuple(2.0 * el for el in (a, b)) # supported
tuple(2.0 * el for el in (a(V2E), b(V2E))) # supported
tuple(local_el + el for local_el, el in ((a(V2E), b), (c(V2E), d))) # supported
tuple(2.0 * el for el in (a(V2E), b)) # rejected: local vs. non-local element
```

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.

## Consequences

- Typing and lowering stay simple: the element expression is visited once, with one type per node.
- Computations over differently-typed elements cannot be written as a comprehension; they must be spelled out per element.
- 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.

## References

- PR [#2833](https://github.com/GridTools/gt4py/pull/2833) (tuple comprehension support)
1 change: 1 addition & 0 deletions docs/development/ADRs/next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Writing a new ADR is simple:
- [0002 - Field View Lowering](0002-Field_View_Lowering.md)
- [0010 - Domain in Field View](0010-Domain_in_Field_View.md)
- [0013 - Scalar vs 0d-Fields](0013-Scalar_vs_0d_Fields.md)
- [0028 - Homogeneous Tuple Comprehensions](0028-Homogeneous_Tuple_Comprehensions.md)

### Iterator IR #iterator

Expand Down
17 changes: 14 additions & 3 deletions src/gt4py/next/ffront/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ def _field_domain_descriptor_mapping_from_func_type(func_type: ts.FunctionType)
for name, type_ in param_types.items():
for el_type_, path in type_info.primitive_constituents(type_, with_path_arg=True):
if isinstance(el_type_, ts.FieldType):
if any(isinstance(idx, type_info.VarLenTupleElementKey) for idx in path):
raise NotImplementedError(
f"Static domains are not supported for parameter '{name}': the "
"elements of a variable-length tuple can not be addressed before "
"the concrete length is known at the call."
)
path_as_expr = "".join(f"[{idx}]" for idx in path)
static_domain_args.append(f"{name}{path_as_expr}")
return static_domain_args
Expand Down Expand Up @@ -306,15 +312,20 @@ def _all_closure_vars(self) -> dict[str, Any]:

@functools.cached_property
def gtir(self) -> itir.Program:
no_args_past = workflow.ConcreteArtifact(
# The program is lowered without a call here, so use the declared parameter
# types as compile-time arguments.
declared_args_past = workflow.ConcreteArtifact(
data=ffront_stages.PASTProgramDef(
past_node=self.past_stage.past_node,
closure_vars=self.past_stage.closure_vars,
grid_type=self.definition_stage.grid_type,
),
args=arguments.CompileTimeArgs.empty(),
args=dataclasses.replace(
arguments.CompileTimeArgs.empty(),
args=tuple(param.type for param in self.past_stage.past_node.params),
),
)
return self._frontend_transforms.past_to_itir(no_args_past).data
return self._frontend_transforms.past_to_itir(declared_args_past).data

def with_grid_type(self, grid_type: common.GridType) -> Program:
return dataclasses.replace(
Expand Down
5 changes: 4 additions & 1 deletion src/gt4py/next/ffront/dialect_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@
),
ast.SetComp: ("set comprehension", ()),
ast.DictComp: ("dictionary comprehension", ()),
ast.GeneratorExp: ("generator expression", ()),
ast.GeneratorExp: (
"generator expression",
("Generator expressions are only supported as the argument of 'tuple(...)'.",),
),
ast.Lambda: (
"'lambda' expression",
("Define a separate function decorated with '@field_operator' instead.",),
Expand Down
30 changes: 30 additions & 0 deletions src/gt4py/next/ffront/field_operator_ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from gt4py.eve import Coerced, Node, SourceLocation, SymbolName, SymbolRef, datamodels
from gt4py.eve.traits import SymbolTableTrait
from gt4py.eve.type_definitions import StrEnum
from gt4py.eve.xtyping import MaybeNestedInTuple
from gt4py.next import utils
from gt4py.next.ffront import dialect_ast_enums, type_specifications as ts_ffront
from gt4py.next.type_system import type_specifications as ts
Expand Down Expand Up @@ -98,6 +99,35 @@ class TupleExpr(Expr):
elts: list[Expr]


# TODO(tehrengruber): extend this to supported nested tuple comprehension.
# e.g. `tuple(element_expr for child in nested_tuple for grand_child in child)`
# would be represented by:
# ```
# class TupleComprehension(Expr): # ruff: noqa: ERA001
# inner: TupleComprehensionMapper | NestedTupleCompr # ruff: noqa: ERA001
# class NestedTupleCompr(Expr, SymbolTableTrait): # ruff: noqa: ERA001
# params: tuple[DataSymbol] # ruff: noqa: ERA001
# body: TupleComprehension # ruff: noqa: ERA001
# ```
class TupleComprehension(Expr):
"""
tuple(element_expr for target in iterable)
Note: The structure here differs from the one in the Python AST. Here we group target and
element expression in order to cleanly nest by the symbols being introduced, whereas in
the Python AST target and iterable are grouped into generator nodes.
"""

inner: TupleComprehensionMapper
iterable: Expr


# This is essentially a lambda. The difference is that for a lambda we might not know the type of
# the args; therefore this is named differently at the moment.
class TupleComprehensionMapper(LocatedNode, SymbolTableTrait):
target: MaybeNestedInTuple[DataSymbol]
element_expr: Expr


class UnaryOp(Expr):
op: dialect_ast_enums.UnaryOperator
operand: Expr
Expand Down
128 changes: 128 additions & 0 deletions src/gt4py/next/ffront/foast_passes/type_deduction.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import gt4py.next.ffront.field_operator_ast as foast
from gt4py import eve
from gt4py.eve import NodeTranslator, NodeVisitor, traits
from gt4py.eve.xtyping import MaybeNestedInTuple
from gt4py.next import common, errors
from gt4py.next.common import Dimension, DimensionKind, promote_dims
from gt4py.next.ffront import (
Expand All @@ -24,6 +25,7 @@
from gt4py.next.ffront.foast_passes import utils as foast_utils
from gt4py.next.iterator import builtins
from gt4py.next.type_system import type_info, type_specifications as ts, type_translation
from gt4py.next.utils import tree_map


OperatorNodeT = TypeVar("OperatorNodeT", bound=foast.LocatedNode)
Expand Down Expand Up @@ -756,6 +758,132 @@ def visit_TupleExpr(self, node: foast.TupleExpr, **kwargs: Any) -> foast.TupleEx
new_type = ts.TupleType(types=[element.type for element in new_elts])
return foast.TupleExpr(elts=new_elts, type=new_type, location=node.location)

def _deduce_tuple_comprehension_target_type(
self,
target: MaybeNestedInTuple[foast.Symbol],
element_type: ts.TypeSpec,
**kwargs: Any,
) -> MaybeNestedInTuple[foast.Symbol]:
"""
Deduce the types of the loop target, e.g. `(a, b)` in `tuple(a + b for (a, b) in it)`.

Each target symbol starts out with a deferred type; revisiting it with `refine_type`
replaces that by the constituent of `element_type` at the symbol's unpacking position.
"""

@tree_map(with_path_arg=True)
def process_target(target_el: foast.Symbol, path: tuple[int, ...]) -> foast.Symbol:
# Walk the unpacking pattern and the element type side-by-side so that the
# number of names and values can be compared at every nesting level.
type_ = element_type
target_tree: MaybeNestedInTuple[foast.Symbol] = target
for i in path:
assert isinstance(target_tree, tuple)
if not isinstance(type_, ts.TupleType):
raise errors.DSLError(
target_el.location, f"Cannot unpack non-iterable '{type_}' object."
)
if len(type_.types) > len(target_tree):
raise errors.DSLError(
target_el.location,
f"Too many values to unpack (expected {len(target_tree)}).",
)
if len(type_.types) < len(target_tree):
raise errors.DSLError(
target_el.location,
f"Not enough values to unpack (expected {len(target_tree)}, "
f"got {len(type_.types)}).",
)
type_ = type_.types[i]
target_tree = target_tree[i]
return self.visit(target_el, refine_type=type_, **kwargs)

return process_target(target)

def _deduce_tuple_comprehension_mapper(
self,
node: foast.TupleComprehension,
target: MaybeNestedInTuple[foast.Symbol],
element_type: ts.DataType,
**kwargs: Any,
) -> foast.TupleComprehensionMapper:
"""
Deduce the per-element part, e.g. `a + b for (a, b)` in `tuple(a + b for (a, b) in it)`.

Refines the target symbols against `element_type` and then types the element
expression with those symbols in scope (a fresh child symbol table).
"""
inner_kwargs = {**kwargs, "symtable": kwargs["symtable"].new_child()}
new_target = self._deduce_tuple_comprehension_target_type(
target, element_type, **inner_kwargs
)
return foast.TupleComprehensionMapper(
target=new_target,
element_expr=self.visit(node.inner.element_expr, **inner_kwargs),
location=node.location,
)

def visit_TupleComprehension(
self, node: foast.TupleComprehension, **kwargs: Any
) -> foast.TupleComprehension:
# The target symbols are visited in `_deduce_tuple_comprehension_target_type`,
# where their types are refined against the iterable's element type.
target = node.inner.target
iterable = self.visit(node.iterable, **kwargs)

if isinstance(iterable.type, ts.TupleType):
if len(iterable.type.types) == 0:
raise errors.DSLError(
iterable.location,
"Cannot iterate over an empty tuple in a tuple comprehension.",
)
if not all(
isinstance(element_type, ts.DataType) for element_type in iterable.type.types
):
raise errors.DSLError(
iterable.location,
"Tuple comprehension iterable elements must be data types.",
)

element_types = cast(list[ts.DataType], iterable.type.types)
# Only homogeneous iterables are supported, see ADR 0028.
if not all(element_type == element_types[0] for element_type in element_types):
raise errors.DSLError(
iterable.location,
"Tuple comprehensions over fixed-length tuples with differently typed "
"iterable elements are not implemented (see ADR 0028).",
)
new_mapper = self._deduce_tuple_comprehension_mapper(
node, target, element_types[0], **kwargs
)
result = foast.TupleComprehension(
inner=new_mapper,
iterable=iterable,
location=node.location,
type=ts.TupleType(types=[new_mapper.element_expr.type for _ in element_types]),
)
return result
elif isinstance(iterable.type, ts.VarLenTupleType):
element_type = iterable.type.element_type
assert isinstance(element_type, ts.DataType) # 'DeferredType' can not occur here
new_mapper = self._deduce_tuple_comprehension_mapper(
node, target, element_type, **kwargs
)
element_expr = new_mapper.element_expr
return_type = ts.VarLenTupleType(element_type=element_expr.type)

return foast.TupleComprehension(
inner=new_mapper,
iterable=iterable,
location=node.location,
type=return_type,
)
else:
raise errors.DSLError(
iterable.location,
f"Iterable in generator expression must be a tuple, got '{iterable.type}'.",
)

def visit_Call(self, node: foast.Call, **kwargs: Any) -> foast.Call:
new_func = self.visit(node.func, **kwargs)
new_args = self.visit(node.args, **kwargs)
Expand Down
16 changes: 16 additions & 0 deletions src/gt4py/next/ffront/foast_pretty_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,22 @@ def apply(cls, node: foast.LocatedNode, **kwargs: Any) -> str: # type: ignore[o

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

def visit_TupleComprehensionMapper(
self, node: foast.TupleComprehensionMapper, **kwargs: Any
) -> str:
def format_target(target: Any) -> str:
if isinstance(target, tuple):
return f"({', '.join(format_target(el) for el in target)})"
return self.visit(target, **kwargs)

element_expr = self.visit(node.element_expr, **kwargs)
return f"{element_expr} for {format_target(node.target)}"

def visit_TupleComprehension(self, node: foast.TupleComprehension, **kwargs: Any) -> str:
mapper = self.visit(node.inner, **kwargs)
iterable = self.visit(node.iterable, **kwargs)
return f"tuple(({mapper} in {iterable}))"

def visit_UnaryOp(self, node: foast.UnaryOp, **kwargs: Any) -> str:
if node.op is dialect_ast_enums.UnaryOperator.NOT:
op = "not "
Expand Down
70 changes: 70 additions & 0 deletions src/gt4py/next/ffront/foast_to_gtir.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@


import dataclasses
import functools
from typing import Any, Callable, Never, Optional

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

def _bind_tuple_comprehension_target(
self,
comprehension_target: itir.Sym | tuple,
element_expr: itir.Expr,
iterable_element: itir.Expr | str,
) -> itir.Expr:
"""
Wrap `element_expr` in a `let` binding the `comprehension_target` to `iterable_element`.

For `2.0 * a + b for (a, b) in iterable`:
- `comprehension_target`: `(a, b)`
- `element_expr`: `2.0 * a + b`
- `iterable_element`: the current element of `iterable`

returns
`let a = iterable_element[0], b = iterable_element[1] in element_expr`.
"""
if isinstance(comprehension_target, itir.Sym):
return im.let(comprehension_target, iterable_element)(element_expr)

flat_targets = utils.flatten_nested_tuple(comprehension_target)
nested_target_values = utils.tree_map(
lambda _, path: functools.reduce(
lambda element, index: im.tuple_get(index, element), path, iterable_element
),
with_path_arg=True,
)(comprehension_target)

flat_target_values = utils.flatten_nested_tuple(nested_target_values) # type: ignore[arg-type]

target_bindings = tuple(zip(flat_targets, flat_target_values, strict=True))
return im.let(*target_bindings)(element_expr) # type: ignore[arg-type]

def visit_TupleComprehension(self, node: foast.TupleComprehension, **kwargs: Any) -> itir.Expr:
# Only homogeneous iterables — all elements of the same type — are supported;
# heterogeneous ones are rejected in the type deduction (see ADR 0028).
comprehension_target = self.visit(node.inner.target, **kwargs)
Comment thread
tehrengruber marked this conversation as resolved.
element_expr = self.visit(node.inner.element_expr, **kwargs)
iterable_expr = self.visit(node.iterable, **kwargs)
iterable_type = node.iterable.type

lower_body_for_iterable_element = functools.partial(
self._bind_tuple_comprehension_target, comprehension_target, element_expr
)

if isinstance(iterable_type, ts.TupleType):
assert isinstance(node.type, ts.TupleType)
iterable_value_name = next(self.uid_generator["__tuple_comprh"])

fixed_tuple_elements = [
lower_body_for_iterable_element(im.tuple_get(element_index, iterable_value_name))
for element_index in range(len(iterable_type.types))
]

result_tuple = im.make_tuple(*fixed_tuple_elements)
return im.let(iterable_value_name, iterable_expr)(result_tuple)

assert isinstance(iterable_type, ts.VarLenTupleType)
assert isinstance(node.type, ts.VarLenTupleType)
if isinstance(comprehension_target, itir.Sym):
map_tuple_lambda = im.lambda_(comprehension_target)(element_expr)
else:
iterable_element_param = next(self.uid_generator["__tuple_comprh"])
map_tuple_lambda = im.lambda_(iterable_element_param)(
lower_body_for_iterable_element(iterable_element_param)
)

return im.call(im.call("map_tuple")(map_tuple_lambda))(iterable_expr)

def visit_UnaryOp(self, node: foast.UnaryOp, **kwargs: Any) -> itir.Expr:
# TODO(tehrengruber): extend iterator ir to support unary operators
dtype = type_info.extract_dtype(node.type)
Expand Down
Loading
Loading