Repository: GridTools/gt4py
Pull request #1503 (2024-03-20 13:10:31)

feat[next][dace]: Remove old dace backend (replaced by workflow infrastructure)
PR #1477 added the dace backend based on workflow infrastructure. The old backend was kept for legacy compatibility in icon4py. Now that icon4py has switched to the new backend, we can safely remove the old one in gt4py.


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1c2b7e4c..107397a4 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -112,15 +112,5 @@ repos:
       build/.* |
       ci/.* |
       docs/.* |
-      tests/conftest.py |
-      tests/cartesian_tests/.* |
-      tests/next_tests/unit_tests/.* |
-      tests/next_tests/integration_tests/multi_feature_tests/.* |
-      tests/next_tests/integration_tests/feature_tests/ffront_tests/.* |
-      tests/next_tests/integration_tests/feature_tests/iterator_tests/.* |
-      tests/next_tests/integration_tests/feature_tests/otf_tests/.* |
-      tests/next_tests/integration_tests/feature_tests/math_builtin_test_data.py |
-      tests/next_tests/past_common_fixtures.py |
-      tests/next_tests/toy_connectivity.py |
       tests/.*
       )$
diff --git a/pyproject.toml b/pyproject.toml
index 23051eec..43ebaa9d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -122,25 +122,16 @@ source_pkgs = ['gt4py']
 disallow_incomplete_defs = true
 exclude = [
   '^setup\.py$',
-  '^build/.*py$',
-  '^ci/*.py$',
-  '^tests/cartesian_tests/*.py$',
-  '^tests/storage_tests/*.py$',
-  '^tests/eve_tests/*.py$',
-  '^tests/next_tests/unit_tests/*.py$',
-  '^tests/next_tests/integration_tests/multi_feature_tests/*.py$',
-  '^tests/next_tests/integration_tests/feature_tests/ffront_tests/*.py$',
-  '^tests/next_tests/integration_tests/feature_tests/iterator_tests/*.py$',
-  '^tests/next_tests/integration_tests/feature_tests/otf_tests/*.py$',
-  '^tests/next_tests/integration_tests/feature_tests/math_builtin_test_data.py$',
-  '^tests/next_tests/past_common_fixtures.py$',
-  '^tests/next_tests/toy_connectivity.py$'
+  'build/.*$',
+  'ci/*.$',
+  'docs/.*$',
+  'tests/.*$'
 ]
 ignore_missing_imports = true
+implicit_optional = false
+implicit_reexport = false
 install_types = true
 namespace_packages = false
-no_implicit_optional = true
-no_implicit_reexport = true
 # pretty = true
 show_column_numbers = true
 show_error_codes = true
diff --git a/src/gt4py/eve/visitors.py b/src/gt4py/eve/visitors.py
index 750505be..ea76aea1 100644
--- a/src/gt4py/eve/visitors.py
+++ b/src/gt4py/eve/visitors.py
@@ -168,7 +168,7 @@ class NodeTranslator(NodeVisitor):
                 # access to `new_node.annex` implicitly creates the `__node_annex__` attribute in the property getter
                 new_annex_dict = new_node.annex.__dict__
                 for key in self.PRESERVED_ANNEX_ATTRS:
-                    if value := getattr(old_annex, key, None):
+                    if (value := getattr(old_annex, key, NOTHING)) is not NOTHING:
                         assert key not in new_annex_dict
                         new_annex_dict[key] = value
 
diff --git a/src/gt4py/next/backend.py b/src/gt4py/next/backend.py
index fc01123b..cfa4911b 100644
--- a/src/gt4py/next/backend.py
+++ b/src/gt4py/next/backend.py
@@ -15,17 +15,19 @@
 from __future__ import annotations
 
 import dataclasses
-from typing import Generic
+from typing import Any, Generic
 
 from gt4py._core import definitions as core_defs
 from gt4py.next import allocators as next_allocators
-from gt4py.next.ffront import past_process_args, past_to_itir
-from gt4py.next.otf import stages, workflow
+from gt4py.next.ffront import func_to_past, past_process_args, past_to_itir, stages as ffront_stages
+from gt4py.next.otf import recipes
 from gt4py.next.program_processors import processor_interface as ppi
 
 
-DEFAULT_TRANSFORMS: workflow.Workflow[stages.PastClosure, stages.ProgramCall] = (
-    past_process_args.past_process_args.chain(past_to_itir.PastToItirFactory())
+DEFAULT_TRANSFORMS = recipes.ProgramTransformWorkflow(
+    func_to_past=func_to_past.OptionalFuncToPastFactory(cached=True),
+    past_transform_args=past_process_args.past_process_args,
+    past_to_itir=past_to_itir.PastToItirFactory(),
 )
 
 
@@ -33,10 +35,13 @@ DEFAULT_TRANSFORMS: workflow.Workflow[stages.PastClosure, stages.ProgramCall] =
 class Backend(Generic[core_defs.DeviceTypeT]):
     executor: ppi.ProgramExecutor
     allocator: next_allocators.FieldBufferAllocatorProtocol[core_defs.DeviceTypeT]
-    transformer: workflow.Workflow[stages.PastClosure, stages.ProgramCall] = DEFAULT_TRANSFORMS
+    transformer: recipes.ProgramTransformWorkflow = DEFAULT_TRANSFORMS
 
-    def __call__(self, program: stages.PastClosure) -> None:
-        program_call = self.transformer(program)
+    def __call__(
+        self, program: ffront_stages.ProgramDefinition, *args: tuple[Any], **kwargs: dict[str, Any]
+    ) -> None:
+        transformer = self.transformer.replace(args=args, kwargs=kwargs)
+        program_call = transformer(program)
         self.executor(program_call.program, *program_call.args, **program_call.kwargs)
 
     @property
diff --git a/src/gt4py/next/common.py b/src/gt4py/next/common.py
index 7f4d5b7b..2936e416 100644
--- a/src/gt4py/next/common.py
+++ b/src/gt4py/next/common.py
@@ -34,6 +34,7 @@ from gt4py.eve.extended_typing import (
     ClassVar,
     Final,
     Generic,
+    NamedTuple,
     Never,
     Optional,
     ParamSpec,
@@ -84,7 +85,7 @@ class Dimension:
         return f"{self.value}[{self.kind}]"
 
     def __call__(self, val: int) -> NamedIndex:
-        return self, val
+        return NamedIndex(self, val)
 
 
 class Infinity(enum.Enum):
@@ -248,9 +249,16 @@ class UnitRange(Sequence[int], Generic[_Left, _Right]):
 
 FiniteUnitRange: TypeAlias = UnitRange[int, int]
 
+_Rng = TypeVar(
+    "_Rng",
+    FiniteUnitRange,
+    UnitRange[Infinity, int],
+    UnitRange[int, Infinity],
+    UnitRange[Infinity, Infinity],
+)
 
 RangeLike: TypeAlias = (
-    UnitRange
+    _Rng
     | range
     | tuple[core_defs.IntegralScalar, core_defs.IntegralScalar]
     | core_defs.IntegralScalar
@@ -282,10 +290,26 @@ def unit_range(r: RangeLike) -> UnitRange:
     raise ValueError(f"'{r!r}' cannot be interpreted as 'UnitRange'.")
 
 
+class NamedRange(NamedTuple, Generic[_Rng]):
+    dim: Dimension
+    unit_range: _Rng
+
+    def __str__(self) -> str:
+        return f"{self.dim}={self.unit_range}"
+
+
 IntIndex: TypeAlias = int | core_defs.IntegralScalar
-NamedIndex: TypeAlias = tuple[Dimension, IntIndex]  # TODO: convert to NamedTuple
-NamedRange: TypeAlias = tuple[Dimension, UnitRange]  # TODO: convert to NamedTuple
-FiniteNamedRange: TypeAlias = tuple[Dimension, FiniteUnitRange]  # TODO: convert to NamedTuple
+
+
+class NamedIndex(NamedTuple):
+    dim: Dimension
+    value: IntIndex
+
+    def __str__(self) -> str:
+        return f"{self.dim}={self.value}"
+
+
+FiniteNamedRange: TypeAlias = NamedRange[FiniteUnitRange]
 RelativeIndexElement: TypeAlias = IntIndex | slice | types.EllipsisType
 NamedSlice: TypeAlias = slice  # once slice is generic we should do: slice[NamedIndex, NamedIndex, Literal[1]], see https://peps.python.org/pep-0696/
 AbsoluteIndexElement: TypeAlias = NamedIndex | NamedRange | NamedSlice
@@ -304,41 +328,22 @@ def is_int_index(p: Any) -> TypeGuard[IntIndex]:
     return isinstance(p, (int, core_defs.INTEGRAL_TYPES))
 
 
-def is_named_range(v: AnyIndexSpec) -> TypeGuard[NamedRange]:
-    return (
-        isinstance(v, tuple)
-        and len(v) == 2
-        and isinstance(v[0], Dimension)
-        and isinstance(v[1], UnitRange)
-    )
-
-
 def is_finite_named_range(v: NamedRange) -> TypeGuard[FiniteNamedRange]:
-    return UnitRange.is_finite(v[1])
+    return UnitRange.is_finite(v.unit_range)
 
 
-def is_named_index(v: AnyIndexSpec) -> TypeGuard[NamedRange]:
-    return (
-        isinstance(v, tuple) and len(v) == 2 and isinstance(v[0], Dimension) and is_int_index(v[1])
+def is_named_slice(obj: AnyIndexSpec) -> TypeGuard[slice]:
+    return isinstance(obj, slice) and (
+        isinstance(obj.start, NamedIndex) and isinstance(obj.stop, NamedIndex)
     )
 
 
-def is_named_slice(obj: AnyIndexSpec) -> TypeGuard[NamedRange]:
-    return isinstance(obj, slice) and (is_named_index(obj.start) and is_named_index(obj.stop))
-
-
 def is_any_index_element(v: AnyIndexSpec) -> TypeGuard[AnyIndexElement]:
-    return (
-        is_int_index(v)
-        or is_named_range(v)
-        or is_named_index(v)
-        or isinstance(v, slice)
-        or v is Ellipsis
-    )
+    return is_int_index(v) or isinstance(v, (NamedRange, NamedIndex, slice)) or v is Ellipsis
 
 
 def is_absolute_index_sequence(v: AnyIndexSequence) -> TypeGuard[AbsoluteIndexSequence]:
-    return isinstance(v, Sequence) and all(is_named_range(e) or is_named_index(e) for e in v)
+    return isinstance(v, Sequence) and all(isinstance(e, (NamedRange, NamedIndex)) for e in v)
 
 
 def is_relative_index_sequence(v: AnyIndexSequence) -> TypeGuard[RelativeIndexSequence]:
@@ -356,20 +361,13 @@ def as_any_index_sequence(index: AnyIndexSpec) -> AnyIndexSequence:
 
 
 def named_range(v: tuple[Dimension, RangeLike]) -> NamedRange:
-    return (v[0], unit_range(v[1]))
-
-
-_Rng = TypeVar(
-    "_Rng",
-    UnitRange[int, int],
-    UnitRange[Infinity, int],
-    UnitRange[int, Infinity],
-    UnitRange[Infinity, Infinity],
-)
+    if isinstance(v, NamedRange):
+        return v
+    return NamedRange(v[0], unit_range(v[1]))
 
 
 @dataclasses.dataclass(frozen=True, init=False)
-class Domain(Sequence[tuple[Dimension, _Rng]], Generic[_Rng]):
+class Domain(Sequence[NamedRange[_Rng]], Generic[_Rng]):
     """Describes the `Domain` of a `Field` as a `Sequence` of `NamedRange` s."""
 
     dims: tuple[Dimension, ...]
@@ -377,7 +375,7 @@ class Domain(Sequence[tuple[Dimension, _Rng]], Generic[_Rng]):
 
     def __init__(
         self,
-        *args: tuple[Dimension, _Rng],
+        *args: NamedRange[_Rng],
         dims: Optional[Sequence[Dimension]] = None,
         ranges: Optional[Sequence[_Rng]] = None,
     ) -> None:
@@ -406,7 +404,7 @@ class Domain(Sequence[tuple[Dimension, _Rng]], Generic[_Rng]):
             object.__setattr__(self, "dims", tuple(dims))
             object.__setattr__(self, "ranges", tuple(ranges))
         else:
-            if not all(is_named_range(arg) for arg in args):
+            if not all(isinstance(arg, NamedRange) for arg in args):
                 raise ValueError(
                     f"Elements of 'Domain' need to be instances of 'NamedRange', got '{args}'."
                 )
@@ -437,17 +435,17 @@ class Domain(Sequence[tuple[Dimension, _Rng]], Generic[_Rng]):
         return any(rng.is_empty() for rng in self.ranges)
 
     @overload
-    def __getitem__(self, index: int) -> tuple[Dimension, _Rng]: ...
+    def __getitem__(self, index: int) -> NamedRange: ...
 
     @overload
     def __getitem__(self, index: slice) -> Self: ...
 
     @overload
-    def __getitem__(self, index: Dimension) -> tuple[Dimension, _Rng]: ...
+    def __getitem__(self, index: Dimension) -> NamedRange: ...
 
     def __getitem__(self, index: int | slice | Dimension) -> NamedRange | Domain:
         if isinstance(index, int):
-            return self.dims[index], self.ranges[index]
+            return NamedRange(dim=self.dims[index], unit_range=self.ranges[index])
         elif isinstance(index, slice):
             dims_slice = self.dims[index]
             ranges_slice = self.ranges[index]
@@ -455,7 +453,7 @@ class Domain(Sequence[tuple[Dimension, _Rng]], Generic[_Rng]):
         elif isinstance(index, Dimension):
             try:
                 index_pos = self.dims.index(index)
-                return self.dims[index_pos], self.ranges[index_pos]
+                return NamedRange(dim=self.dims[index_pos], unit_range=self.ranges[index_pos])
             except ValueError as ex:
                 raise KeyError(f"No Dimension of type '{index}' is present in the Domain.") from ex
         else:
@@ -470,10 +468,12 @@ class Domain(Sequence[tuple[Dimension, _Rng]], Generic[_Rng]):
         >>> I = Dimension("I")
         >>> J = Dimension("J")
 
-        >>> Domain((I, UnitRange(-1, 3))) & Domain((I, UnitRange(1, 6)))
+        >>> Domain(NamedRange(I, UnitRange(-1, 3))) & Domain(NamedRange(I, UnitRange(1, 6)))
         Domain(dims=(Dimension(value='I', kind=<DimensionKind.HORIZONTAL: 'horizontal'>),), ranges=(UnitRange(1, 3),))
 
-        >>> Domain((I, UnitRange(-1, 3)), (J, UnitRange(2, 4))) & Domain((I, UnitRange(1, 6)))
+        >>> Domain(NamedRange(I, UnitRange(-1, 3)), NamedRange(J, UnitRange(2, 4))) & Domain(
+        ...     NamedRange(I, UnitRange(1, 6))
+        ... )
         Domain(dims=(Dimension(value='I', kind=<DimensionKind.HORIZONTAL: 'horizontal'>), Dimension(value='J', kind=<DimensionKind.HORIZONTAL: 'horizontal'>)), ranges=(UnitRange(1, 3), UnitRange(2, 4)))
         """
         broadcast_dims = tuple(promote_dims(self.dims, other.dims))
@@ -487,7 +487,7 @@ class Domain(Sequence[tuple[Dimension, _Rng]], Generic[_Rng]):
         return Domain(dims=broadcast_dims, ranges=intersected_ranges)
 
     def __str__(self) -> str:
-        return f"Domain({', '.join(f'{e[0]}={e[1]}' for e in self)})"
+        return f"Domain({', '.join(f'{e}' for e in self)})"
 
     def dim_index(self, dim: Dimension) -> Optional[int]:
         return self.dims.index(dim) if dim in self.dims else None
@@ -503,7 +503,7 @@ class Domain(Sequence[tuple[Dimension, _Rng]], Generic[_Rng]):
             return self.replace(index, *named_ranges)
 
     def replace(self, index: int | Dimension, *named_ranges: NamedRange) -> Domain:
-        assert all(is_named_range(nr) for nr in named_ranges)
+        assert all(isinstance(nr, NamedRange) for nr in named_ranges)
         if isinstance(index, Dimension):
             dim_index = self.dim_index(index)
             if dim_index is None:
@@ -515,9 +515,10 @@ class Domain(Sequence[tuple[Dimension, _Rng]], Generic[_Rng]):
             )
         if index < 0:
             index += len(self.dims)
-        new_dims, new_ranges = zip(*named_ranges) if len(named_ranges) > 0 else ((), ())
-        dims = self.dims[:index] + new_dims + self.dims[index + 1 :]
-        ranges = self.ranges[:index] + new_ranges + self.ranges[index + 1 :]
+        new_dims = (arg.dim for arg in named_ranges) if len(named_ranges) > 0 else ()
+        new_ranges = (arg.unit_range for arg in named_ranges) if len(named_ranges) > 0 else ()
+        dims = self.dims[:index] + tuple(new_dims) + self.dims[index + 1 :]
+        ranges = self.ranges[:index] + tuple(new_ranges) + self.ranges[index + 1 :]
 
         return Domain(dims=dims, ranges=ranges)
 
@@ -559,10 +560,7 @@ def domain(domain_like: DomainLike) -> Domain:
         if all(isinstance(elem, core_defs.INTEGRAL_TYPES) for elem in domain_like.values()):
             return Domain(
                 dims=tuple(domain_like.keys()),
-                ranges=tuple(
-                    UnitRange(0, s)  # type: ignore[arg-type] # type of `s` is checked in condition
-                    for s in domain_like.values()
-                ),
+                ranges=tuple(UnitRange(0, s) for s in domain_like.values()),
             )
         return Domain(
             dims=tuple(domain_like.keys()),
@@ -949,15 +947,15 @@ class CartesianConnectivity(ConnectivityField[DimsT, DimT]):
 
     def inverse_image(self, image_range: UnitRange | NamedRange) -> Sequence[NamedRange]:
         if not isinstance(image_range, UnitRange):
-            if image_range[0] != self.codomain:
+            if image_range.dim != self.codomain:
                 raise ValueError(
-                    f"Dimension '{image_range[0]}' does not match the codomain dimension '{self.codomain}'."
+                    f"Dimension '{image_range.dim}' does not match the codomain dimension '{self.codomain}'."
                 )
 
-            image_range = image_range[1]
+            image_range = image_range.unit_range
 
         assert isinstance(image_range, UnitRange)
-        return ((self.codomain, image_range - self.offset),)
+        return (named_range((self.codomain, image_range - self.offset)),)
 
     def remap(self, index_field: ConnectivityField | fbuiltins.FieldOffset) -> ConnectivityField:
         raise NotImplementedError()
diff --git a/src/gt4py/next/embedded/common.py b/src/gt4py/next/embedded/common.py
index 6642d9a0..cdb0d3a5 100644
--- a/src/gt4py/next/embedded/common.py
+++ b/src/gt4py/next/embedded/common.py
@@ -51,7 +51,7 @@ def _relative_sub_domain(
         if isinstance(idx, slice):
             try:
                 sliced = _slice_range(rng, idx)
-                named_ranges.append((dim, sliced))
+                named_ranges.append(common.NamedRange(dim, sliced))
             except IndexError as ex:
                 raise embedded_exceptions.IndexOutOfBounds(
                     domain=domain, indices=index, index=idx, dim=dim
@@ -76,14 +76,14 @@ def _absolute_sub_domain(
     for i, (dim, rng) in enumerate(domain):
         if (pos := _find_index_of_dim(dim, index)) is not None:
             named_idx = index[pos]
-            idx = named_idx[1]
+            _, idx = named_idx
             if isinstance(idx, common.UnitRange):
                 if not idx <= rng:
                     raise embedded_exceptions.IndexOutOfBounds(
                         domain=domain, indices=index, index=named_idx, dim=dim
                     )
 
-                named_ranges.append((dim, idx))
+                named_ranges.append(common.NamedRange(dim, idx))
             else:
                 # not in new domain
                 assert common.is_int_index(idx)
@@ -93,7 +93,7 @@ def _absolute_sub_domain(
                     )
         else:
             # dimension not mentioned in slice
-            named_ranges.append((dim, domain.ranges[i]))
+            named_ranges.append(common.NamedRange(dim, domain.ranges[i]))
 
     return common.Domain(*named_ranges)
 
@@ -137,13 +137,13 @@ def restrict_to_intersection(
     """
     ignore_dims_tuple = ignore_dims if isinstance(ignore_dims, tuple) else (ignore_dims,)
     intersection_without_ignore_dims = domain_intersection(*[
-        common.Domain(*[(d, r) for d, r in domain if d not in ignore_dims_tuple])
+        common.Domain(*[nr for nr in domain if nr.dim not in ignore_dims_tuple])
         for domain in domains
     ])
     return tuple(
         common.Domain(*[
-            (d, r if d in ignore_dims_tuple else intersection_without_ignore_dims[d][1])
-            for d, r in domain
+            (nr if nr.dim in ignore_dims_tuple else intersection_without_ignore_dims[nr.dim])
+            for nr in domain
         ])
         for domain in domains
     )
@@ -151,9 +151,9 @@ def restrict_to_intersection(
 
 def iterate_domain(
     domain: common.Domain,
-) -> Iterator[tuple[tuple[common.Dimension, int]]]:
-    for i in itertools.product(*[list(r) for r in domain.ranges]):
-        yield tuple(zip(domain.dims, i))  # type: ignore[misc] # trust me, `i` is `tuple[int, ...]`
+) -> Iterator[tuple[common.NamedIndex]]:
+    for idx in itertools.product(*(list(r) for r in domain.ranges)):
+        yield tuple(common.NamedIndex(d, i) for d, i in zip(domain.dims, idx))  # type: ignore[misc] # trust me, `idx` is `tuple[int, ...]`
 
 
 def _expand_ellipsis(
@@ -169,7 +169,7 @@ def _expand_ellipsis(
 
 def _slice_range(input_range: common.UnitRange, slice_obj: slice) -> common.UnitRange:
     if slice_obj == slice(None):
-        return common.UnitRange(input_range.start, input_range.stop)
+        return input_range
 
     start = (
         input_range.start if slice_obj.start is None or slice_obj.start >= 0 else input_range.stop
@@ -209,20 +209,16 @@ def _named_slice_to_named_range(
 ) -> common.NamedRange | common.NamedSlice:
     assert hasattr(idx, "start") and hasattr(idx, "stop")
     if common.is_named_slice(idx):
-        idx_start_0, idx_start_1, idx_stop_0, idx_stop_1 = (
-            idx.start[0],  # type: ignore[attr-defined]
-            idx.start[1],  # type: ignore[attr-defined]
-            idx.stop[0],  # type: ignore[attr-defined]
-            idx.stop[1],  # type: ignore[attr-defined]
-        )
-        if idx_start_0 != idx_stop_0:
+        start_dim, start_value = idx.start
+        stop_dim, stop_value = idx.stop
+        if start_dim != stop_dim:
             raise IndexError(
-                f"Dimensions slicing mismatch between '{idx_start_0.value}' and '{idx_stop_0.value}'."
+                f"Dimensions slicing mismatch between '{start_dim.value}' and '{stop_dim.value}'."
             )
-        assert isinstance(idx_start_1, int) and isinstance(idx_stop_1, int)
-        return (idx_start_0, common.UnitRange(idx_start_1, idx_stop_1))
-    if common.is_named_index(idx.start) and idx.stop is None:
+        assert isinstance(start_value, int) and isinstance(stop_value, int)
+        return common.NamedRange(start_dim, common.UnitRange(start_value, stop_value))
+    if isinstance(idx.start, common.NamedIndex) and idx.stop is None:
         raise IndexError(f"Upper bound needs to be specified for {idx}.")
-    if common.is_named_index(idx.stop) and idx.start is None:
+    if isinstance(idx.stop, common.NamedIndex) and idx.start is None:
         raise IndexError(f"Lower bound needs to be specified for {idx}.")
     return idx
diff --git a/src/gt4py/next/embedded/nd_array_field.py b/src/gt4py/next/embedded/nd_array_field.py
index e884c61f..e7f34bb2 100644
--- a/src/gt4py/next/embedded/nd_array_field.py
+++ b/src/gt4py/next/embedded/nd_array_field.py
@@ -24,7 +24,7 @@ import numpy as np
 from numpy import typing as npt
 
 from gt4py._core import definitions as core_defs
-from gt4py.eve.extended_typing import Any, Never, Optional, ParamSpec, TypeAlias, TypeVar
+from gt4py.eve.extended_typing import Never, Optional, ParamSpec, TypeAlias, TypeVar
 from gt4py.next import common
 from gt4py.next.embedded import (
     common as embedded_common,
@@ -122,7 +122,7 @@ class NdArrayField(
     @property
     def __gt_origin__(self) -> tuple[int, ...]:
         assert common.Domain.is_finite(self._domain)
-        return tuple(-r.start for _, r in self._domain)
+        return tuple(-r.start for r in self._domain.ranges)
 
     @property
     def ndarray(self) -> core_defs.NDArrayObject:
@@ -137,7 +137,7 @@ class NdArrayField(
     def as_scalar(self) -> core_defs.ScalarT:
         if self.domain.ndim != 0:
             raise ValueError(
-                "'as_scalar' is only valid on 0-dimensional 'Field's, got a {self.domain.ndim}-dimensional 'Field'."
+                f"'as_scalar' is only valid on 0-dimensional 'Field's, got a {self.domain.ndim}-dimensional 'Field'."
             )
         return self.ndarray.item()
 
@@ -196,7 +196,7 @@ class NdArrayField(
         if dim_idx is None:
             raise ValueError(f"Incompatible index field, expected a field with dimension '{dim}'.")
 
-        current_range: common.UnitRange = self.domain[dim_idx][1]
+        current_range: common.UnitRange = self.domain[dim_idx].unit_range
         new_ranges = connectivity.inverse_image(current_range)
         new_domain = self.domain.replace(dim_idx, *new_ranges)
 
@@ -413,12 +413,12 @@ class NdArrayConnectivityField(  # type: ignore[misc] # for __ne__, __eq__
             if not isinstance(
                 image_range, common.UnitRange
             ):  # TODO(havogt): cleanup duplication with CartesianConnectivity
-                if image_range[0] != self.codomain:
+                if image_range.dim != self.codomain:
                     raise ValueError(
-                        f"Dimension '{image_range[0]}' does not match the codomain dimension '{self.codomain}'."
+                        f"Dimension '{image_range.dim}' does not match the codomain dimension '{self.codomain}'."
                     )
 
-                image_range = image_range[1]
+                image_range = image_range.unit_range
 
             assert isinstance(image_range, common.UnitRange)
 
@@ -603,14 +603,14 @@ def _intersect_fields(
 def _stack_domains(*domains: common.Domain, dim: common.Dimension) -> Optional[common.Domain]:
     if not domains:
         return common.Domain()
-    dim_start = domains[0][dim][1].start
+    dim_start = domains[0][dim].unit_range.start
     dim_stop = dim_start
     for domain in domains:
-        if not domain[dim][1].start == dim_stop:
+        if not domain[dim].unit_range.start == dim_stop:
             return None
         else:
-            dim_stop = domain[dim][1].stop
-    return domains[0].replace(dim, (dim, common.UnitRange(dim_start, dim_stop)))
+            dim_stop = domain[dim].unit_range.stop
+    return domains[0].replace(dim, common.NamedRange(dim, common.UnitRange(dim_start, dim_stop)))
 
 
 def _concat(*fields: common.Field, dim: common.Dimension) -> common.Field:
@@ -688,7 +688,7 @@ def _concat_where(
     if transformed:
         return _concat(*transformed, dim=mask_dim)
     else:
-        result_domain = common.Domain((mask_dim, common.UnitRange(0, 0)))
+        result_domain = common.Domain(common.NamedRange(mask_dim, common.UnitRange(0, 0)))
         result_array = xp.empty(result_domain.shape)
     return cls_.from_array(result_array, domain=result_domain)
 
@@ -722,7 +722,7 @@ def _make_reduction(
             axis.value
         ]  # assumes offset and local dimension have same name
         assert isinstance(offset_definition, itir_embedded.NeighborTableOffsetProvider)
-        new_domain = common.Domain(*[nr for nr in field.domain if nr[0] != axis])
+        new_domain = common.Domain(*[nr for nr in field.domain if nr.dim != axis])
 
         broadcast_slice = tuple(
             slice(None) if d in [axis, offset_definition.origin_axis] else xp.newaxis
@@ -820,10 +820,10 @@ def _broadcast(field: common.Field, new_dimensions: Sequence[common.Dimension])
     for dim in new_dimensions:
         if (pos := embedded_common._find_index_of_dim(dim, field.domain)) is not None:
             domain_slice.append(slice(None))
-            named_ranges.append((dim, field.domain[pos][1]))
+            named_ranges.append(common.NamedRange(dim, field.domain[pos].unit_range))
         else:
             domain_slice.append(None)  # np.newaxis
-            named_ranges.append((dim, common.UnitRange.infinite()))
+            named_ranges.append(common.NamedRange(dim, common.UnitRange.infinite()))
     return common._field(field.ndarray[tuple(domain_slice)], domain=common.Domain(*named_ranges))
 
 
@@ -849,7 +849,7 @@ NdArrayField.register_builtin_func(fbuiltins.astype, _astype)
 
 def _get_slices_from_domain_slice(
     domain: common.Domain,
-    domain_slice: common.Domain | Sequence[common.NamedRange | common.NamedIndex | Any],
+    domain_slice: common.Domain | Sequence[common.NamedRange | common.NamedIndex],
 ) -> common.RelativeIndexSequence:
     """Generate slices for sub-array extraction based on named ranges or named indices within a Domain.
 
@@ -869,7 +869,7 @@ def _get_slices_from_domain_slice(
 
     for pos_old, (dim, _) in enumerate(domain):
         if (pos := embedded_common._find_index_of_dim(dim, domain_slice)) is not None:
-            index_or_range = domain_slice[pos][1]
+            _, index_or_range = domain_slice[pos]
             slice_indices.append(_compute_slice(index_or_range, domain, pos_old))
         else:
             slice_indices.append(slice(None))
diff --git a/src/gt4py/next/embedded/operators.py b/src/gt4py/next/embedded/operators.py
index c5f5fd05..b88083e7 100644
--- a/src/gt4py/next/embedded/operators.py
+++ b/src/gt4py/next/embedded/operators.py
@@ -51,14 +51,14 @@ class ScanOperator(EmbeddedOperator[core_defs.ScalarT | tuple[core_defs.ScalarT
         | tuple[common.Field[Any, core_defs.ScalarT] | tuple, ...]
     ):
         scan_range = embedded_context.closure_column_range.get()
-        assert self.axis == scan_range[0]
-        scan_axis = scan_range[0]
+        assert self.axis == scan_range.dim
+        scan_axis = scan_range.dim
         all_args = [*args, *kwargs.values()]
         domain_intersection = _intersect_scan_args(*all_args)
-        non_scan_domain = common.Domain(*[nr for nr in domain_intersection if nr[0] != scan_axis])
+        non_scan_domain = common.Domain(*[nr for nr in domain_intersection if nr.dim != scan_axis])
 
         out_domain = common.Domain(*[
-            scan_range if nr[0] == scan_axis else nr for nr in domain_intersection
+            scan_range if nr.dim == scan_axis else nr for nr in domain_intersection
         ])
         if scan_axis not in out_domain.dims:
             # even if the scan dimension is not in the input, we can scan over it
@@ -69,8 +69,8 @@ class ScanOperator(EmbeddedOperator[core_defs.ScalarT | tuple[core_defs.ScalarT
 
         def scan_loop(hpos: Sequence[common.NamedIndex]) -> None:
             acc: core_defs.ScalarT | tuple[core_defs.ScalarT | tuple, ...] = self.init
-            for k in scan_range[1] if self.forward else reversed(scan_range[1]):
-                pos = (*hpos, (scan_axis, k))
+            for k in scan_range.unit_range if self.forward else reversed(scan_range.unit_range):
+                pos = (*hpos, common.NamedIndex(scan_axis, k))
                 new_args = [_tuple_at(pos, arg) for arg in args]
                 new_kwargs = {k: _tuple_at(pos, v) for k, v in kwargs.items()}
                 acc = self.fun(acc, *new_args, **new_kwargs)  # type: ignore[arg-type] # need to express that the first argument is the same type as the return
@@ -134,7 +134,7 @@ def field_operator_call(op: EmbeddedOperator[_R, _P], args: Any, kwargs: Any) ->
 
 
 def _get_vertical_range(domain: common.Domain) -> common.NamedRange | eve.NothingType:
-    vertical_dim_filtered = [nr for nr in domain if nr[0].kind == common.DimensionKind.VERTICAL]
+    vertical_dim_filtered = [nr for nr in domain if nr.dim.kind == common.DimensionKind.VERTICAL]
     assert len(vertical_dim_filtered) <= 1
     return vertical_dim_filtered[0] if vertical_dim_filtered else eve.NOTHING
 
diff --git a/src/gt4py/next/ffront/decorator.py b/src/gt4py/next/ffront/decorator.py
index 2e101d93..36503989 100644
--- a/src/gt4py/next/ffront/decorator.py
+++ b/src/gt4py/next/ffront/decorator.py
@@ -44,13 +44,13 @@ from gt4py.next.ffront import (
     past_process_args,
     past_to_itir,
     program_ast as past,
+    stages as ffront_stages,
     transform_utils,
     type_specifications as ts_ffront,
 )
 from gt4py.next.ffront.foast_passes.type_deduction import FieldOperatorTypeDeduction
 from gt4py.next.ffront.foast_to_itir import FieldOperatorLowering
 from gt4py.next.ffront.func_to_foast import FieldOperatorParser
-from gt4py.next.ffront.func_to_past import ProgramParser
 from gt4py.next.ffront.gtcallable import GTCallable
 from gt4py.next.ffront.past_passes.closure_var_type_deduction import (
     ClosureVarTypeDeduction as ProgramClosureVarTypeDeduction,
@@ -64,7 +64,6 @@ from gt4py.next.iterator.ir_utils.ir_makers import (
     ref,
     sym,
 )
-from gt4py.next.otf import stages
 from gt4py.next.program_processors import processor_interface as ppi
 from gt4py.next.type_system import type_info, type_specifications as ts, type_translation
 
@@ -92,11 +91,8 @@ class Program:
             it will be deduced from actually occurring dimensions.
     """
 
-    past_node: past.Program
-    closure_vars: dict[str, Any]
-    definition: Optional[types.FunctionType]
+    definition_stage: ffront_stages.ProgramDefinition
     backend: Optional[next_backend.Backend]
-    grid_type: Optional[GridType]
 
     @classmethod
     def from_function(
@@ -105,21 +101,26 @@ class Program:
         backend: Optional[next_backend],
         grid_type: Optional[GridType] = None,
     ) -> Program:
-        source_def = SourceDefinition.from_function(definition)
-        closure_vars = get_closure_vars_from_function(definition)
-        annotations = typing.get_type_hints(definition)
-        past_node = ProgramParser.apply(source_def, closure_vars, annotations)
+        program_def = ffront_stages.ProgramDefinition(definition=definition, grid_type=grid_type)
         return cls(
-            past_node=past_node,
-            closure_vars=closure_vars,
+            definition_stage=program_def,
             backend=backend,
-            definition=definition,
-            grid_type=grid_type,
         )
 
+    # needed in testing
+    @property
+    def definition(self):
+        return self.definition_stage.definition
+
+    @functools.cached_property
+    def past_stage(self):
+        if self.backend is not None and self.backend.transformer is not None:
+            return self.backend.transformer.func_to_past(self.definition_stage)
+        return next_backend.DEFAULT_TRANSFORMS.func_to_past(self.definition_stage)
+
     def __post_init__(self):
         function_closure_vars = transform_utils._filter_closure_vars_by_type(
-            self.closure_vars, GTCallable
+            self.past_stage.closure_vars, GTCallable
         )
         misnamed_functions = [
             f"{name} vs. {func.id}"
@@ -133,8 +134,8 @@ class Program:
 
         undefined_symbols = [
             symbol.id
-            for symbol in self.past_node.closure_vars
-            if symbol.id not in self.closure_vars
+            for symbol in self.past_stage.past_node.closure_vars
+            if symbol.id not in self.past_stage.closure_vars
         ]
         if undefined_symbols:
             raise RuntimeError(
@@ -143,7 +144,7 @@ class Program:
 
     @property
     def __name__(self) -> str:
-        return self.definition.__name__
+        return self.definition_stage.definition.__name__
 
     @functools.cached_property
     def __gt_allocator__(
@@ -158,7 +159,9 @@ class Program:
         return dataclasses.replace(self, backend=backend)
 
     def with_grid_type(self, grid_type: GridType) -> Program:
-        return dataclasses.replace(self, grid_type=grid_type)
+        return dataclasses.replace(
+            self, definition_stage=dataclasses.replace(self.definition_stage, grid_type=grid_type)
+        )
 
     def with_bound_args(self, **kwargs: Any) -> ProgramWithBoundArgs:
         """
@@ -186,7 +189,7 @@ class Program:
         >>> program_with_bound_arg(out, offset_provider={})  # doctest: +SKIP
         """
         for key in kwargs.keys():
-            if all(key != param.id for param in self.past_node.params):
+            if all(key != param.id for param in self.past_stage.past_node.params):
                 raise TypeError(f"Keyword argument '{key}' is not a valid program parameter.")
 
         return ProgramWithBoundArgs(
@@ -196,19 +199,20 @@ class Program:
 
     @functools.cached_property
     def _all_closure_vars(self) -> dict[str, Any]:
-        return transform_utils._get_closure_vars_recursively(self.closure_vars)
+        return transform_utils._get_closure_vars_recursively(self.past_stage.closure_vars)
 
     @functools.cached_property
     def itir(self) -> itir.FencilDefinition:
-        return past_to_itir.PastToItirFactory()(
-            stages.PastClosure(
-                past_node=self.past_node,
-                closure_vars=self.closure_vars,
-                grid_type=self.grid_type,
-                args=[],
-                kwargs={},
-            )
-        ).program
+        no_args_past = ffront_stages.PastClosure(
+            past_node=self.past_stage.past_node,
+            closure_vars=self.past_stage.closure_vars,
+            grid_type=self.definition_stage.grid_type,
+            args=[],
+            kwargs={},
+        )
+        if self.backend is not None and self.backend.transformer is not None:
+            return self.backend.transformer.past_to_itir(no_args_past)
+        return past_to_itir.PastToItirFactory()(no_args_past).program
 
     def __call__(self, *args, offset_provider: dict[str, Dimension], **kwargs: Any) -> None:
         if self.backend is None:
@@ -221,30 +225,40 @@ class Program:
             with next_embedded.context.new_context(offset_provider=offset_provider) as ctx:
                 # TODO(ricoh): check if rewriting still needed
                 rewritten_args, size_args, kwargs = past_process_args._process_args(
-                    self.past_node, args, kwargs
+                    self.past_stage.past_node, args, kwargs
                 )
-                ctx.run(self.definition, *rewritten_args, **kwargs)
+                ctx.run(self.definition_stage.definition, *rewritten_args, **kwargs)
             return
 
         ppi.ensure_processor_kind(self.backend.executor, ppi.ProgramExecutor)
 
         self.backend(
-            stages.PastClosure(
-                closure_vars=self.closure_vars,
-                past_node=self.past_node,
-                grid_type=self.grid_type,
-                args=args,
-                kwargs=kwargs | {"offset_provider": offset_provider},
-            )
+            self.definition_stage,
+            *args,
+            **(kwargs | {"offset_provider": offset_provider}),
         )
 
 
+@dataclasses.dataclass(frozen=True)
+class ProgramFromPast(Program):
+    past_stage: ffront_stages.PastProgramDefinition
+
+    def __call__(self, *args, offset_provider: dict[str, Dimension], **kwargs):
+        if self.backend is None:
+            raise NotImplementedError(
+                "Programs created from a PAST node (without a function definition) can not be executed in embedded mode"
+            )
+
+        ppi.ensure_processor_kind(self.backend.executor, ppi.ProgramExecutor)
+        self.backend(self.past_stage, *args, **(kwargs | {"offset_provider": offset_provider}))
+
+
 @dataclasses.dataclass(frozen=True)
 class ProgramWithBoundArgs(Program):
     bound_args: dict[str, typing.Union[float, int, bool]] = None
 
     def __call__(self, *args, offset_provider: dict[str, Dimension], **kwargs):
-        type_ = self.past_node.type
+        type_ = self.past_stage.past_node.type
         new_type = ts_ffront.ProgramType(
             definition=ts.FunctionType(
                 kw_only_args={
@@ -281,13 +295,13 @@ class ProgramWithBoundArgs(Program):
         except ValueError as err:
             bound_arg_names = ", ".join([f"'{bound_arg}'" for bound_arg in self.bound_args.keys()])
             raise TypeError(
-                f"Invalid argument types in call to program '{self.past_node.id}' with "
+                f"Invalid argument types in call to program '{self.past_stage.past_node.id}' with "
                 f"bound arguments '{bound_arg_names}'."
             ) from err
 
         full_args = [*args]
         full_kwargs = {**kwargs}
-        for index, param in enumerate(self.past_node.params):
+        for index, param in enumerate(self.past_stage.past_node.params):
             if param.id in self.bound_args.keys():
                 if index < len(full_args):
                     full_args.insert(index, self.bound_args[param.id])
@@ -534,12 +548,14 @@ class FieldOperator(GTCallable, Generic[OperatorNodeT]):
         untyped_past_node = ProgramClosureVarTypeDeduction.apply(untyped_past_node, closure_vars)
         past_node = ProgramTypeDeduction.apply(untyped_past_node)
 
-        self._program_cache[hash_] = Program(
-            past_node=past_node,
-            closure_vars=closure_vars,
-            definition=None,
+        self._program_cache[hash_] = ProgramFromPast(
+            definition_stage=None,
+            past_stage=ffront_stages.PastProgramDefinition(
+                past_node=past_node,
+                closure_vars=closure_vars,
+                grid_type=self.grid_type,
+            ),
             backend=self.backend,
-            grid_type=self.grid_type,
         )
 
         return self._program_cache[hash_]
diff --git a/src/gt4py/next/ffront/fbuiltins.py b/src/gt4py/next/ffront/fbuiltins.py
index 5c0da54a..34562ffd 100644
--- a/src/gt4py/next/ffront/fbuiltins.py
+++ b/src/gt4py/next/ffront/fbuiltins.py
@@ -344,7 +344,7 @@ class FieldOffset(runtime.Offset):
         ) or common.is_connectivity_field(offset_definition):
             unrestricted_connectivity = self.as_connectivity_field()
             assert unrestricted_connectivity.domain.ndim > 1
-            named_index = (self.target[-1], offset)
+            named_index = common.NamedIndex(self.target[-1], offset)
             connectivity = unrestricted_connectivity[named_index]
         else:
             raise NotImplementedError()
diff --git a/src/gt4py/next/ffront/func_to_past.py b/src/gt4py/next/ffront/func_to_past.py
index 101e3a23..dac3f8d8 100644
--- a/src/gt4py/next/ffront/func_to_past.py
+++ b/src/gt4py/next/ffront/func_to_past.py
@@ -15,22 +15,72 @@
 from __future__ import annotations
 
 import ast
-from dataclasses import dataclass
+import dataclasses
+import typing
 from typing import Any, cast
 
+import factory
+
+from gt4py.eve import utils as eve_utils
 from gt4py.next import errors
 from gt4py.next.ffront import (
     dialect_ast_enums,
     program_ast as past,
+    source_utils,
+    stages as ffront_stages,
     type_specifications as ts_ffront,
 )
 from gt4py.next.ffront.dialect_parser import DialectParser
 from gt4py.next.ffront.past_passes.closure_var_type_deduction import ClosureVarTypeDeduction
 from gt4py.next.ffront.past_passes.type_deduction import ProgramTypeDeduction
+from gt4py.next.otf import workflow
 from gt4py.next.type_system import type_specifications as ts, type_translation
 
 
-@dataclass(frozen=True, kw_only=True)
+@workflow.make_step
+def func_to_past(inp: ffront_stages.ProgramDefinition) -> ffront_stages.PastProgramDefinition:
+    source_def = source_utils.SourceDefinition.from_function(inp.definition)
+    closure_vars = source_utils.get_closure_vars_from_function(inp.definition)
+    annotations = typing.get_type_hints(inp.definition)
+    return ffront_stages.PastProgramDefinition(
+        past_node=ProgramParser.apply(source_def, closure_vars, annotations),
+        closure_vars=closure_vars,
+        grid_type=inp.grid_type,
+    )
+
+
+@dataclasses.dataclass(frozen=True)
+class OptionalFuncToPast(workflow.SkippableStep):
+    step: workflow.Workflow[
+        ffront_stages.ProgramDefinition, ffront_stages.PastProgramDefinition
+    ] = func_to_past
+
+    def skip_condition(
+        self, inp: ffront_stages.PastProgramDefinition | ffront_stages.ProgramDefinition
+    ) -> bool:
+        match inp:
+            case ffront_stages.ProgramDefinition():
+                return False
+            case ffront_stages.PastProgramDefinition():
+                return True
+
+
+class OptionalFuncToPastFactory(factory.Factory):
+    class Meta:
+        model = OptionalFuncToPast
+
+    class Params:
+        workflow = func_to_past
+        cached = factory.Trait(
+            step=factory.LazyAttribute(
+                lambda o: workflow.CachedStep(step=o.workflow, hash_function=eve_utils.content_hash)
+            )
+        )
+
+        step = factory.LazyAttribute(lambda o: o.workflow)
+
+
+@dataclasses.dataclass(frozen=True, kw_only=True)
 class ProgramParser(DialectParser[past.Program]):
     """Parse program definition from Python source code into PAST."""
 
diff --git a/src/gt4py/next/ffront/past_process_args.py b/src/gt4py/next/ffront/past_process_args.py
index 9dcdc313..2fde8dd3 100644
--- a/src/gt4py/next/ffront/past_process_args.py
+++ b/src/gt4py/next/ffront/past_process_args.py
@@ -15,20 +15,24 @@
 from typing import Any, Iterator, Optional
 
 from gt4py.next import common, errors
-from gt4py.next.ffront import program_ast as past, type_specifications as ts_ffront
-from gt4py.next.otf import stages, workflow
+from gt4py.next.ffront import (
+    program_ast as past,
+    stages as ffront_stages,
+    type_specifications as ts_ffront,
+)
+from gt4py.next.otf import workflow
 from gt4py.next.type_system import type_info, type_specifications as ts, type_translation
 
 
 @workflow.make_step
-def past_process_args(inp: stages.PastClosure) -> stages.PastClosure:
+def past_process_args(inp: ffront_stages.PastClosure) -> ffront_stages.PastClosure:
     extra_kwarg_names = ["offset_provider", "column_axis"]
     extra_kwargs = {k: v for k, v in inp.kwargs.items() if k in extra_kwarg_names}
     kwargs = {k: v for k, v in inp.kwargs.items() if k not in extra_kwarg_names}
     rewritten_args, size_args, kwargs = _process_args(
         past_node=inp.past_node, args=list(inp.args), kwargs=kwargs
     )
-    return stages.PastClosure(
+    return ffront_stages.PastClosure(
         past_node=inp.past_node,
         closure_vars=inp.closure_vars,
         grid_type=inp.grid_type,
diff --git a/src/gt4py/next/ffront/past_to_itir.py b/src/gt4py/next/ffront/past_to_itir.py
index eb4d07c8..7d59e6fd 100644
--- a/src/gt4py/next/ffront/past_to_itir.py
+++ b/src/gt4py/next/ffront/past_to_itir.py
@@ -27,6 +27,7 @@ from gt4py.next.ffront import (
     gtcallable,
     lowering_utils,
     program_ast as past,
+    stages as ffront_stages,
     transform_utils,
     type_specifications as ts_ffront,
 )
@@ -38,7 +39,7 @@ from gt4py.next.type_system import type_info, type_specifications as ts
 
 @dataclasses.dataclass(frozen=True)
 class PastToItir(workflow.ChainableWorkflowMixin):
-    def __call__(self, inp: stages.PastClosure) -> stages.ProgramCall:
+    def __call__(self, inp: ffront_stages.PastClosure) -> stages.ProgramCall:
         all_closure_vars = transform_utils._get_closure_vars_recursively(inp.closure_vars)
         offsets_and_dimensions = transform_utils._filter_closure_vars_by_type(
             all_closure_vars, fbuiltins.FieldOffset, common.Dimension
diff --git a/src/gt4py/next/ffront/stages.py b/src/gt4py/next/ffront/stages.py
new file mode 100644
index 00000000..ed7c65c0
--- /dev/null
+++ b/src/gt4py/next/ffront/stages.py
@@ -0,0 +1,44 @@
+# GT4Py - GridTools Framework
+#
+# Copyright (c) 2014-2023, ETH Zurich
+# All rights reserved.
+#
+# This file is part of the GT4Py project and the GridTools framework.
+# GT4Py is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or any later
+# version. See the LICENSE.txt file at the top-level directory of this
+# distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from __future__ import annotations
+
+import dataclasses
+import types
+from typing import Any, Optional
+
+from gt4py.next import common
+from gt4py.next.ffront import program_ast as past
+
+
+@dataclasses.dataclass(frozen=True)
+class ProgramDefinition:
+    definition: types.FunctionType
+    grid_type: Optional[common.GridType] = None
+
+
+@dataclasses.dataclass(frozen=True)
+class PastProgramDefinition:
+    past_node: past.Program
+    closure_vars: dict[str, Any]
+    grid_type: Optional[common.GridType] = None
+
+
+@dataclasses.dataclass(frozen=True)
+class PastClosure:
+    closure_vars: dict[str, Any]
+    past_node: past.Program
+    grid_type: Optional[common.GridType]
+    args: tuple[Any, ...]
+    kwargs: dict[str, Any]
diff --git a/src/gt4py/next/iterator/embedded.py b/src/gt4py/next/iterator/embedded.py
index b7cf3618..c9552e71 100644
--- a/src/gt4py/next/iterator/embedded.py
+++ b/src/gt4py/next/iterator/embedded.py
@@ -208,7 +208,9 @@ class Column(np.lib.mixins.NDArrayOperatorsMixin):
         self.kstart = kstart
         assert isinstance(data, (np.ndarray, Scalar))  # type: ignore # mypy bug #11673
         column_range: common.NamedRange = column_range_cvar.get()
-        self.data = data if isinstance(data, np.ndarray) else np.full(len(column_range[1]), data)
+        self.data = (
+            data if isinstance(data, np.ndarray) else np.full(len(column_range.unit_range), data)
+        )
 
     def __getitem__(self, i: int) -> Any:
         result = self.data[i - self.kstart]
@@ -749,7 +751,7 @@ def _make_tuple(
             except embedded_exceptions.IndexOutOfBounds:
                 return _UNDEFINED
     else:
-        column_range = column_range_cvar.get()[1]
+        column_range = column_range_cvar.get().unit_range
         assert column_range is not None
 
         col: list[
@@ -826,7 +828,7 @@ class MDIterator:
             assert isinstance(k_pos, int)
             # the following range describes a range in the field
             # (negative values are relative to the origin, not relative to the size)
-            slice_column[self.column_axis] = range(k_pos, k_pos + len(column_range[1]))
+            slice_column[self.column_axis] = range(k_pos, k_pos + len(column_range.unit_range))
 
         assert _is_concrete_position(shifted_pos)
         position = {**shifted_pos, **slice_column}
@@ -867,7 +869,7 @@ def make_in_iterator(
         init = [None] * sparse_dimensions.count(sparse_dim)
         new_pos[sparse_dim] = init  # type: ignore[assignment] # looks like mypy is confused
     if column_axis is not None:
-        column_range = column_range_cvar.get()[1]
+        column_range = column_range_cvar.get().unit_range
         # if we deal with column stencil the column position is just an offset by which the whole column needs to be shifted
         assert column_range is not None
         new_pos[column_axis] = column_range.start
@@ -909,16 +911,16 @@ class NDArrayLocatedFieldWrapper(MutableLocatedField):
         domain_slice: list[common.NamedRange | common.NamedIndex] = []
         for d, v in named_indices.items():
             if isinstance(v, range):
-                domain_slice.append((d, common.UnitRange(v.start, v.stop)))
+                domain_slice.append(common.NamedRange(d, common.UnitRange(v.start, v.stop)))
             elif isinstance(v, list):
                 assert len(v) == 1  # only 1 sparse dimension is supported
                 assert common.is_int_index(
                     v[0]
                 )  # derefing a concrete element in a sparse field, not a slice
-                domain_slice.append((d, v[0]))
+                domain_slice.append(common.NamedIndex(d, v[0]))
             else:
                 assert common.is_int_index(v)
-                domain_slice.append((d, v))
+                domain_slice.append(common.NamedIndex(d, v))
         return tuple(domain_slice)
 
     def field_getitem(self, named_indices: NamedFieldIndices) -> Any:
@@ -1060,7 +1062,7 @@ class IndexField(common.Field):
     @property
     def domain(self) -> common.Domain:
         if self._cur_index is None:
-            return common.Domain((self._dimension, common.UnitRange.infinite()))
+            return common.Domain(common.NamedRange(self._dimension, common.UnitRange.infinite()))
         else:
             return common.Domain()
 
@@ -1092,11 +1094,12 @@ class IndexField(common.Field):
         raise NotImplementedError()
 
     def restrict(self, item: common.AnyIndexSpec) -> Self:
-        if common.is_absolute_index_sequence(item) and all(common.is_named_index(e) for e in item):  # type: ignore[arg-type] # we don't want to pollute the typing of `is_absolute_index_sequence` for this temporary code # fmt: off
+        if isinstance(item, Sequence) and all(isinstance(e, common.NamedIndex) for e in item):
+            assert isinstance(item[0], common.NamedIndex)  # for mypy errors on multiple lines below
             d, r = item[0]
             assert d == self._dimension
             assert isinstance(r, core_defs.INTEGRAL_TYPES)
-            return self.__class__(self._dimension, r)  # type: ignore[arg-type] # not sure why the assert above does not work
+            return self.__class__(self._dimension, r)
         # TODO set a domain...
         raise NotImplementedError()
 
@@ -1492,7 +1495,7 @@ def _column_dtype(elem: Any) -> np.dtype:
 @builtins.scan.register(EMBEDDED)
 def scan(scan_pass, is_forward: bool, init):
     def impl(*iters: ItIterator):
-        column_range = column_range_cvar.get()[1]
+        column_range = column_range_cvar.get().unit_range
         if column_range is None:
             raise RuntimeError("Column range is not defined, cannot scan.")
 
@@ -1545,7 +1548,7 @@ def fendef_embedded(fun: Callable[..., None], *args: Any, **kwargs: Any):
             column = ColumnDescriptor(column_axis.value, domain[column_axis.value])
             del domain[column_axis.value]
 
-            column_range = (
+            column_range = common.NamedRange(
                 column_axis,
                 common.UnitRange(column.col_range.start, column.col_range.stop),
             )
diff --git a/src/gt4py/next/iterator/pretty_printer.py b/src/gt4py/next/iterator/pretty_printer.py
index 710d5c6b..c4ac84dd 100644
--- a/src/gt4py/next/iterator/pretty_printer.py
+++ b/src/gt4py/next/iterator/pretty_printer.py
@@ -35,7 +35,9 @@ BINARY_OPS: Final = {
     "divides": "/",
     "eq": "==",
     "less": "<",
+    "less_equal": "<=",
     "greater": ">",
+    "greater_equal": ">=",
     "and_": "∧",
     "or_": "∨",  # noqa: RUF001 [ambiguous-unicode-character-string]
 }
@@ -55,7 +57,9 @@ PRECEDENCE: Final = {
     "and_": 3,
     "eq": 4,
     "less": 4,
+    "less_equal": 4,
     "greater": 4,
+    "greater_equal": 4,
     "plus": 5,
     "minus": 5,
     "multiplies": 6,
diff --git a/src/gt4py/next/iterator/transforms/constant_folding.py b/src/gt4py/next/iterator/transforms/constant_folding.py
index 696a87a1..2d7248cf 100644
--- a/src/gt4py/next/iterator/transforms/constant_folding.py
+++ b/src/gt4py/next/iterator/transforms/constant_folding.py
@@ -26,6 +26,13 @@ class ConstantFolding(PreserveLocationVisitor, NodeTranslator):
         # visit depth-first such that nested constant expressions (e.g. `(1+2)+3`) are properly folded
         new_node = self.generic_visit(node)
 
+        if (
+            isinstance(new_node.fun, ir.SymRef)
+            and new_node.fun.id in ["minimum", "maximum"]
+            and new_node.args[0] == new_node.args[1]
+        ):  # `minimum(a, a)` -> `a`
+            return new_node.args[0]
+
         if (
             isinstance(new_node.fun, ir.SymRef)
             and new_node.fun.id == "if_"
diff --git a/src/gt4py/next/iterator/transforms/global_tmps.py b/src/gt4py/next/iterator/transforms/global_tmps.py
index 1d675bee..c344face 100644
--- a/src/gt4py/next/iterator/transforms/global_tmps.py
+++ b/src/gt4py/next/iterator/transforms/global_tmps.py
@@ -642,6 +642,15 @@ def collect_tmps_info(node: FencilWithTemporaries, *, offset_provider) -> Fencil
     )
 
 
+def validate_no_dynamic_offsets(node: ir.Node):
+    """Vaidate we have no dynamic offsets, e.g. `shift(Ioff, deref(...))(...)`"""
+    for call_node in node.walk_values().if_isinstance(ir.FunCall):
+        assert isinstance(call_node, ir.FunCall)
+        if call_node.fun == im.ref("shift"):
+            if any(not isinstance(arg, ir.OffsetLiteral) for arg in call_node.args):
+                raise NotImplementedError("Dynamic offsets not supported in temporary pass.")
+
+
 # TODO(tehrengruber): Add support for dynamic shifts (e.g. the distance is a symbol). This can be
 #  tricky: For every lift statement that is dynamically shifted we can not compute bounds anymore
 #  and hence also not extract as a temporary.
@@ -661,6 +670,8 @@ class CreateGlobalTmps(PreserveLocationVisitor, NodeTranslator):
         ] = None,
         symbolic_sizes: Optional[dict[str, str]],
     ) -> FencilWithTemporaries:
+        # Vaidate we have no dynamic offsets, e.g. `shift(Ioff, deref(...))(...)`
+        validate_no_dynamic_offsets(node)
         # Split closures on lifted function calls and introduce temporaries
         res = split_closures(
             node, offset_provider=offset_provider, extraction_heuristics=extraction_heuristics
diff --git a/src/gt4py/next/otf/recipes.py b/src/gt4py/next/otf/recipes.py
index 4c6cdc27..702d0ebb 100644
--- a/src/gt4py/next/otf/recipes.py
+++ b/src/gt4py/next/otf/recipes.py
@@ -14,10 +14,44 @@
 from __future__ import annotations
 
 import dataclasses
+from typing import Any
 
+from gt4py.next.ffront import stages as ffront_stages
 from gt4py.next.otf import stages, step_types, workflow
 
 
+@dataclasses.dataclass(frozen=True)
+class ProgramTransformWorkflow(workflow.NamedStepSequence):
+    """Modular workflow for transformations with access to intermediates."""
+
+    func_to_past: workflow.SkippableStep[
+        ffront_stages.ProgramDefinition | ffront_stages.PastProgramDefinition,
+        ffront_stages.PastProgramDefinition,
+    ]
+    past_transform_args: workflow.Workflow[ffront_stages.PastClosure, ffront_stages.PastClosure]
+    past_to_itir: workflow.Workflow[ffront_stages.PastClosure, stages.ProgramCall]
+
+    args: tuple[Any, ...] = dataclasses.field(default_factory=tuple)
+    kwargs: dict[str, Any] = dataclasses.field(default_factory=dict)
+
+    def __call__(
+        self,
+        inp: ffront_stages.ProgramDefinition | ffront_stages.PastProgramDefinition,
+    ) -> stages.ProgramCall:
+        past_stage = self.func_to_past(inp)
+        return self.past_to_itir(
+            self.past_transform_args(
+                ffront_stages.PastClosure(
+                    past_node=past_stage.past_node,
+                    closure_vars=past_stage.closure_vars,
+                    grid_type=past_stage.grid_type,
+                    args=self.args,
+                    kwargs=self.kwargs,
+                )
+            )
+        )
+
+
 @dataclasses.dataclass(frozen=True)
 class OTFCompileWorkflow(workflow.NamedStepSequence):
     """The typical compiled backend steps composed into a workflow."""
diff --git a/src/gt4py/next/otf/stages.py b/src/gt4py/next/otf/stages.py
index 6609ea52..5e2ac8aa 100644
--- a/src/gt4py/next/otf/stages.py
+++ b/src/gt4py/next/otf/stages.py
@@ -17,8 +17,6 @@ from __future__ import annotations
 import dataclasses
 from typing import Any, Generic, Optional, Protocol, TypeVar
 
-from gt4py.next import common
-from gt4py.next.ffront import program_ast as past
 from gt4py.next.iterator import ir as itir
 from gt4py.next.otf import languages
 from gt4py.next.otf.binding import interface
@@ -32,15 +30,6 @@ TgtL_co = TypeVar("TgtL_co", bound=languages.LanguageTag, covariant=True)
 SettingT_co = TypeVar("SettingT_co", bound=languages.LanguageSettings, covariant=True)
 
 
-@dataclasses.dataclass(frozen=True)
-class PastClosure:
-    closure_vars: dict[str, Any]
-    past_node: past.Program
-    grid_type: common.GridType
-    args: tuple[Any, ...]
-    kwargs: dict[str, Any]
-
-
 @dataclasses.dataclass(frozen=True)
 class ProgramCall:
     """Iterator IR representaion of a program together with arguments to be passed to it."""
diff --git a/src/gt4py/next/otf/workflow.py b/src/gt4py/next/otf/workflow.py
index 2ea2fe92..c7d84fc7 100644
--- a/src/gt4py/next/otf/workflow.py
+++ b/src/gt4py/next/otf/workflow.py
@@ -249,3 +249,19 @@ class CachedStep(
         except KeyError:
             result = self._cache[hash_] = self.step(inp)
         return result
+
+
+@dataclasses.dataclass(frozen=True)
+class SkippableStep(
+    ChainableWorkflowMixin[StartT, EndT],
+    ReplaceEnabledWorkflowMixin[StartT, EndT],
+):
+    step: Workflow[StartT, EndT]
+
+    def __call__(self, inp: StartT) -> EndT:
+        if not self.skip_condition(inp):
+            return self.step(inp)
+        return inp  # type: ignore[return-value]  # up to the implementer to make sure StartT == EndT
+
+    def skip_condition(self, inp: StartT) -> bool:
+        raise NotImplementedError()
diff --git a/src/gt4py/next/program_processors/codegens/gtfn/codegen.py b/src/gt4py/next/program_processors/codegens/gtfn/codegen.py
index fde41182..987fe6ca 100644
--- a/src/gt4py/next/program_processors/codegens/gtfn/codegen.py
+++ b/src/gt4py/next/program_processors/codegens/gtfn/codegen.py
@@ -98,6 +98,7 @@ class GTFNCodegen(codegen.TemplatedGenerator):
         return value
 
     def visit_Literal(self, node: gtfn_ir.Literal, **kwargs: Any) -> str:
+        # TODO(tehrengruber): isn't this wrong and int32 should be casted to an actual int32?
         match pytype_to_cpptype(node.type):
             case "float":
                 return self.asfloat(node.value) + "f"
@@ -111,7 +112,9 @@ class GTFNCodegen(codegen.TemplatedGenerator):
     IntegralConstant = as_fmt("{value}_c")
 
     UnaryExpr = as_fmt("{op}({expr})")
-    BinaryExpr = as_fmt("({lhs}{op}{rhs})")
+    # add an extra space between the operators is needed such that `minus(1, -1)` does not get
+    # translated into `1--1`, but `1 - -1`
+    BinaryExpr = as_fmt("({lhs} {op} {rhs})")
     TernaryExpr = as_fmt("({cond}?{true_expr}:{false_expr})")
     CastExpr = as_fmt("static_cast<{new_dtype}>({obj_expr})")
 
diff --git a/src/gt4py/next/program_processors/runners/dace_iterator/__init__.py b/src/gt4py/next/program_processors/runners/dace_iterator/__init__.py
index 0b6f18f5..a7a30ed7 100644
--- a/src/gt4py/next/program_processors/runners/dace_iterator/__init__.py
+++ b/src/gt4py/next/program_processors/runners/dace_iterator/__init__.py
@@ -11,7 +11,6 @@
 # distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
-import hashlib
 import warnings
 from inspect import currentframe, getframeinfo
 from pathlib import Path
@@ -19,17 +18,13 @@ from typing import Any, Mapping, Optional, Sequence
 
 import dace
 import numpy as np
-from dace.codegen.compiled_sdfg import CompiledSDFG
 from dace.sdfg import utils as sdutils
 from dace.transformation.auto import auto_optimize as autoopt
 
-import gt4py.next.allocators as next_allocators
 import gt4py.next.iterator.ir as itir
-import gt4py.next.program_processors.processor_interface as ppi
-from gt4py.next import backend as next_backend, common
+from gt4py.next import common
 from gt4py.next.iterator import transforms as itir_transforms
-from gt4py.next.otf.compilation import cache as compilation_cache
-from gt4py.next.type_system import type_specifications as ts, type_translation
+from gt4py.next.type_system import type_translation
 
 from .itir_to_sdfg import ItirToSDFG
 from .utility import connectivity_identifier, filter_neighbor_tables, get_sorted_dims
@@ -41,12 +36,6 @@ except ImportError:
     cp = None
 
 
-""" Default build configuration in DaCe backend """
-_build_type = "Release"
-_default_on_gpu = False
-_default_use_field_canonical_representation = False
-
-
 def convert_arg(arg: Any, sdfg_param: str, use_field_canonical_representation: bool):
     if not common.is_field(arg):
         return arg
@@ -175,64 +164,22 @@ def get_stride_args(
     return stride_args
 
 
-_build_cache: dict[str, CompiledSDFG] = {}
-
-
-def get_cache_id(
-    build_type: str,
-    build_for_gpu: bool,
-    lift_mode: itir_transforms.LiftMode,
-    program: itir.FencilDefinition,
-    arg_types: Sequence[ts.TypeSpec],
-    column_axis: Optional[common.Dimension],
-    offset_provider: Mapping[str, Any],
-) -> str:
-    def offset_invariants(offset):
-        if isinstance(offset, common.Connectivity):
-            return (
-                offset.origin_axis,
-                offset.neighbor_axis,
-                offset.has_skip_values,
-                offset.max_neighbors,
-            )
-        if isinstance(offset, common.Dimension):
-            return (offset,)
-        return tuple()
-
-    offset_cache_keys = [
-        (name, *offset_invariants(offset)) for name, offset in offset_provider.items()
-    ]
-    cache_id_args = [
-        str(arg)
-        for arg in (
-            build_type,
-            build_for_gpu,
-            lift_mode,
-            program,
-            *arg_types,
-            column_axis,
-            *offset_cache_keys,
-        )
-    ]
-    m = hashlib.sha256()
-    for s in cache_id_args:
-        m.update(s.encode())
-    return m.hexdigest()
-
-
-def get_sdfg_args(sdfg: dace.SDFG, *args, check_args: bool = False, **kwargs) -> dict[str, Any]:
+def get_sdfg_args(
+    sdfg: dace.SDFG,
+    *args,
+    check_args: bool = False,
+    on_gpu: bool = False,
+    use_field_canonical_representation: bool = True,
+    **kwargs,
+) -> dict[str, Any]:
     """Extracts the arguments needed to call the SDFG.
 
-    This function can handle the same arguments that are passed to `run_dace_iterator()`.
+    This function can handle the same arguments that are passed to dace runner.
 
     Args:
         sdfg:               The SDFG for which we want to get the arguments.
     """
     offset_provider = kwargs["offset_provider"]
-    on_gpu = kwargs.get("on_gpu", _default_on_gpu)
-    use_field_canonical_representation = kwargs.get(
-        "use_field_canonical_representation", _default_use_field_canonical_representation
-    )
 
     neighbor_tables = filter_neighbor_tables(offset_provider)
     device = dace.DeviceType.GPU if on_gpu else dace.DeviceType.CPU
@@ -270,7 +217,6 @@ def build_sdfg_from_itir(
     column_axis: Optional[common.Dimension] = None,
     lift_mode: itir_transforms.LiftMode = itir_transforms.LiftMode.FORCE_INLINE,
     load_sdfg_from_file: bool = False,
-    cache_id: Optional[str] = None,
     save_sdfg: bool = True,
     use_field_canonical_representation: bool = True,
 ) -> dace.SDFG:
@@ -285,17 +231,14 @@ def build_sdfg_from_itir(
         column_axis:         The column axis to be used, defaults to `None`.
         lift_mode:           Which lift mode should be used, defaults `FORCE_INLINE`.
         load_sdfg_from_file: Allows to read the SDFG from file, instead of generating it, for debug only.
-        cache_id:            The id of the cache entry, used to disambiguate stored sdfgs.
         save_sdfg:           If `True`, the default the SDFG is stored as a file and can be loaded, this allows to skip the lowering step, requires `load_sdfg_from_file` set to `True`.
         use_field_canonical_representation: If `True`,  assume that the fields dimensions are sorted alphabetically.
 
     Notes:
         Currently only the `FORCE_INLINE` liftmode is supported and the value of `lift_mode` is ignored.
     """
-    # Test if we can go through the cache?
-    sdfg_filename = (
-        f"_dacegraphs/gt4py/{cache_id if cache_id is not None else '.'}/{program.id}.sdfg"
-    )
+
+    sdfg_filename = f"_dacegraphs/gt4py/{program.id}.sdfg"
     if load_sdfg_from_file and Path(sdfg_filename).exists():
         sdfg: dace.SDFG = dace.SDFG.from_file(sdfg_filename)
         sdfg.validate()
@@ -351,116 +294,3 @@ def build_sdfg_from_itir(
         sdfg.save(sdfg_filename)
 
     return sdfg
-
-
-def run_dace_iterator(program: itir.FencilDefinition, *args, **kwargs):
-    # build parameters
-    build_cache = kwargs.get("build_cache", None)
-    # `None` will take default.
-    compiler_args = kwargs.get("compiler_args", None)
-    build_type = kwargs.get("build_type", "RelWithDebInfo")
-    on_gpu = kwargs.get("on_gpu", _default_on_gpu)
-    auto_optimize = kwargs.get("auto_optimize", True)
-    lift_mode = kwargs.get("lift_mode", itir_transforms.LiftMode.FORCE_INLINE)
-    use_field_canonical_representation = kwargs.get(
-        "use_field_canonical_representation", _default_use_field_canonical_representation
-    )
-    # ITIR parameters
-    column_axis = kwargs.get("column_axis", None)
-    offset_provider = kwargs["offset_provider"]
-    # debug option to store SDFGs on filesystem and skip lowering ITIR to SDFG at each run
-    load_sdfg_from_file = kwargs.get("load_sdfg_from_file", False)
-    save_sdfg = kwargs.get("save_sdfg", True)
-
-    arg_types = [type_translation.from_value(arg) for arg in args]
-
-    cache_id = get_cache_id(
-        build_type, on_gpu, lift_mode, program, arg_types, column_axis, offset_provider
-    )
-    if build_cache is not None and cache_id in build_cache:
-        # retrieve SDFG program from build cache
-        sdfg_program = build_cache[cache_id]
-        sdfg = sdfg_program.sdfg
-    else:
-        sdfg = build_sdfg_from_itir(
-            program,
-            *args,
-            offset_provider=offset_provider,
-            auto_optimize=auto_optimize,
-            on_gpu=on_gpu,
-            column_axis=column_axis,
-            lift_mode=lift_mode,
-            load_sdfg_from_file=load_sdfg_from_file,
-            cache_id=cache_id,
-            save_sdfg=save_sdfg,
-            use_field_canonical_representation=use_field_canonical_representation,
-        )
-
-        sdfg.build_folder = compilation_cache._session_cache_dir_path / ".dacecache"
-        with dace.config.temporary_config():
-            dace.config.Config.set("compiler", "build_type", value=build_type)
-            if compiler_args is not None:
-                dace.config.Config.set(
-                    "compiler", "cuda" if on_gpu else "cpu", "args", value=compiler_args
-                )
-            sdfg_program = sdfg.compile(validate=False)
-
-        # store SDFG program in build cache
-        if build_cache is not None:
-            build_cache[cache_id] = sdfg_program
-
-    sdfg_args = get_sdfg_args(sdfg, *args, **kwargs)
-
-    with dace.config.temporary_config():
-        dace.config.Config.set("compiler", "allow_view_arguments", value=True)
-        dace.config.Config.set("frontend", "check_args", value=True)
-        sdfg_program(**sdfg_args)
-
-
-def _run_dace_cpu(program: itir.FencilDefinition, *args, **kwargs) -> None:
-    compiler_args = dace.config.Config.get("compiler", "cpu", "args")
-
-    # disable finite-math-only in order to support isfinite/isinf/isnan builtins
-    if "-ffast-math" in compiler_args:
-        compiler_args += " -fno-finite-math-only"
-    if "-ffinite-math-only" in compiler_args:
-        compiler_args.replace("-ffinite-math-only", "")
-
-    run_dace_iterator(
-        program,
-        *args,
-        **kwargs,
-        build_cache=_build_cache,
-        build_type=_build_type,
-        compiler_args=compiler_args,
-        on_gpu=False,
-    )
-
-
-run_dace_cpu = next_backend.Backend(
-    executor=ppi.program_executor(_run_dace_cpu, name="run_dace_cpu"),
-    allocator=next_allocators.StandardCPUFieldBufferAllocator(),
-)
-
-if cp:
-
-    def _run_dace_gpu(program: itir.FencilDefinition, *args, **kwargs) -> None:
-        run_dace_iterator(
-            program,
-            *args,
-            **kwargs,
-            build_cache=_build_cache,
-            build_type=_build_type,
-            on_gpu=True,
-        )
-
-else:
-
-    def _run_dace_gpu(program: itir.FencilDefinition, *args, **kwargs) -> None:
-        raise RuntimeError("Missing 'cupy' dependency for GPU execution.")
-
-
-run_dace_gpu = next_backend.Backend(
-    executor=ppi.program_executor(_run_dace_gpu, name="run_dace_gpu"),
-    allocator=next_allocators.StandardGPUFieldBufferAllocator(),
-)


Test patch:
diff --git a/docs/development/ADRs/0015-Test_Exclusion_Matrices.md b/docs/development/ADRs/0015-Test_Exclusion_Matrices.md
index 387170a6..ffa63bfe 100644
--- a/docs/development/ADRs/0015-Test_Exclusion_Matrices.md
+++ b/docs/development/ADRs/0015-Test_Exclusion_Matrices.md
@@ -47,8 +47,8 @@ by calling `next_tests.get_processor_id()`, which returns the so-called processo
 The following backend processors are defined:
 
 ```python
-DACE_CPU = "dace_iterator.run_dace_cpu"
-DACE_GPU = "dace_iterator.run_dace_gpu"
+DACE_CPU = "gt4py.next.program_processors.runners.dace.run_dace_cpu"
+DACE_GPU = "gt4py.next.program_processors.runners.dace.run_dace_gpu"
 GTFN_CPU = "gt4py.next.program_processors.runners.gtfn.run_gtfn"
 GTFN_CPU_IMPERATIVE = "gt4py.next.program_processors.runners.gtfn.run_gtfn_imperative"
 GTFN_CPU_WITH_TEMPORARIES = "gt4py.next.program_processors.runners.gtfn.run_gtfn_with_temporaries"
diff --git a/tests/eve_tests/unit_tests/test_visitors.py b/tests/eve_tests/unit_tests/test_visitors.py
index 70b7e2e4..1b52243d 100644
--- a/tests/eve_tests/unit_tests/test_visitors.py
+++ b/tests/eve_tests/unit_tests/test_visitors.py
@@ -19,12 +19,14 @@ from gt4py import eve
 
 def test_annex_preservation(compound_node: eve.Node):
     compound_node.annex.foo = 1
-    compound_node.annex.bar = 2
+    compound_node.annex.bar = None  # None is easily forgotten so test seperately
+    compound_node.annex.baz = 2
 
     class SampleTranslator(eve.NodeTranslator):
-        PRESERVED_ANNEX_ATTRS = ("foo",)
+        PRESERVED_ANNEX_ATTRS = ("foo", "bar")
 
     translated_node = SampleTranslator().visit(compound_node)
 
     assert translated_node.annex.foo == 1
-    assert not hasattr(translated_node.annex, "bar")
+    assert translated_node.annex.bar is None
+    assert not hasattr(translated_node.annex, "baz")
diff --git a/tests/next_tests/integration_tests/feature_tests/ffront_tests/ffront_test_utils.py b/tests/next_tests/integration_tests/feature_tests/ffront_tests/ffront_test_utils.py
index 7260ef15..04fbb583 100644
--- a/tests/next_tests/integration_tests/feature_tests/ffront_tests/ffront_test_utils.py
+++ b/tests/next_tests/integration_tests/feature_tests/ffront_tests/ffront_test_utils.py
@@ -45,7 +45,7 @@ def no_exec(program: itir.FencilDefinition, *args: Any, **kwargs: Any) -> None:
 
 
 class NoBackend(next_backend.Backend):
-    def __call__(self, program) -> None:
+    def __call__(self, program, *args, **kwargs) -> None:
         raise ValueError("No backend selected! Backend selection is mandatory in tests.")
 
 
diff --git a/tests/next_tests/unit_tests/embedded_tests/test_common.py b/tests/next_tests/unit_tests/embedded_tests/test_common.py
index 9765273f..111622ac 100644
--- a/tests/next_tests/unit_tests/embedded_tests/test_common.py
+++ b/tests/next_tests/unit_tests/embedded_tests/test_common.py
@@ -17,7 +17,7 @@ from typing import Sequence
 import pytest
 
 from gt4py.next import common
-from gt4py.next.common import UnitRange
+from gt4py.next.common import UnitRange, NamedIndex, NamedRange
 from gt4py.next.embedded import exceptions as embedded_exceptions
 from gt4py.next.embedded.common import (
     _slice_range,
@@ -53,12 +53,12 @@ K = common.Dimension("K")
     [
         ([(I, (2, 5))], 1, []),
         ([(I, (2, 5))], slice(1, 2), [(I, (3, 4))]),
-        ([(I, (2, 5))], (I, 2), []),
-        ([(I, (2, 5))], (I, UnitRange(2, 3)), [(I, (2, 3))]),
+        ([(I, (2, 5))], NamedIndex(I, 2), []),
+        ([(I, (2, 5))], NamedRange(I, UnitRange(2, 3)), [(I, (2, 3))]),
         ([(I, (-2, 3))], 1, []),
         ([(I, (-2, 3))], slice(1, 2), [(I, (-1, 0))]),
-        ([(I, (-2, 3))], (I, 1), []),
-        ([(I, (-2, 3))], (I, UnitRange(2, 3)), [(I, (2, 3))]),
+        ([(I, (-2, 3))], NamedIndex(I, 1), []),
+        ([(I, (-2, 3))], NamedRange(I, UnitRange(2, 3)), [(I, (2, 3))]),
         ([(I, (-2, 3))], -5, []),
         ([(I, (-2, 3))], -6, IndexError),
         ([(I, (-2, 3))], slice(-7, -6), IndexError),
@@ -67,10 +67,10 @@ K = common.Dimension("K")
         ([(I, (-2, 3))], 5, IndexError),
         ([(I, (-2, 3))], slice(4, 5), [(I, (2, 3))]),
         ([(I, (-2, 3))], slice(5, 6), IndexError),
-        ([(I, (-2, 3))], (I, -3), IndexError),
-        ([(I, (-2, 3))], (I, UnitRange(-3, -2)), IndexError),
-        ([(I, (-2, 3))], (I, 3), IndexError),
-        ([(I, (-2, 3))], (I, UnitRange(3, 4)), IndexError),
+        ([(I, (-2, 3))], NamedIndex(I, -3), IndexError),
+        ([(I, (-2, 3))], NamedRange(I, UnitRange(-3, -2)), IndexError),
+        ([(I, (-2, 3))], NamedIndex(I, 3), IndexError),
+        ([(I, (-2, 3))], NamedRange(I, UnitRange(3, 4)), IndexError),
         (
             [(I, (2, 5)), (J, (3, 6)), (K, (4, 7))],
             2,
@@ -83,32 +83,32 @@ K = common.Dimension("K")
         ),
         (
             [(I, (2, 5)), (J, (3, 6)), (K, (4, 7))],
-            (I, 2),
+            NamedIndex(I, 2),
             [(J, (3, 6)), (K, (4, 7))],
         ),
         (
             [(I, (2, 5)), (J, (3, 6)), (K, (4, 7))],
-            (I, UnitRange(2, 3)),
+            NamedRange(I, UnitRange(2, 3)),
             [(I, (2, 3)), (J, (3, 6)), (K, (4, 7))],
         ),
         (
             [(I, (2, 5)), (J, (3, 6)), (K, (4, 7))],
-            (J, 3),
+            NamedIndex(J, 3),
             [(I, (2, 5)), (K, (4, 7))],
         ),
         (
             [(I, (2, 5)), (J, (3, 6)), (K, (4, 7))],
-            (J, UnitRange(4, 5)),
+            NamedRange(J, UnitRange(4, 5)),
             [(I, (2, 5)), (J, (4, 5)), (K, (4, 7))],
         ),
         (
             [(I, (2, 5)), (J, (3, 6)), (K, (4, 7))],
-            ((J, 3), (I, 2)),
+            (NamedIndex(J, 3), NamedIndex(I, 2)),
             [(K, (4, 7))],
         ),
         (
             [(I, (2, 5)), (J, (3, 6)), (K, (4, 7))],
-            ((J, UnitRange(4, 5)), (I, 2)),
+            (NamedRange(J, UnitRange(4, 5)), NamedIndex(I, 2)),
             [(J, (4, 5)), (K, (4, 7))],
         ),
         (
@@ -147,8 +147,8 @@ def test_sub_domain(domain, index, expected):
 def test_iterate_domain():
     domain = common.domain({I: 2, J: 3})
     ref = []
-    for i in domain[I][1]:
-        for j in domain[J][1]:
+    for i in domain[I].unit_range:
+        for j in domain[J].unit_range:
             ref.append(((I, i), (J, j)))
 
     testee = list(iterate_domain(domain))
@@ -159,10 +159,10 @@ def test_iterate_domain():
 @pytest.mark.parametrize(
     "slices, expected",
     [
-        [slice(I(3), I(4)), ((I, common.UnitRange(3, 4)),)],
+        [slice(I(3), I(4)), (NamedRange(I, common.UnitRange(3, 4)),)],
         [
             (slice(J(3), J(6)), slice(I(3), I(5))),
-            ((J, common.UnitRange(3, 6)), (I, common.UnitRange(3, 5))),
+            (NamedRange(J, common.UnitRange(3, 6)), NamedRange(I, common.UnitRange(3, 5))),
         ],
         [slice(I(1), J(7)), IndexError],
         [
diff --git a/tests/next_tests/unit_tests/embedded_tests/test_nd_array_field.py b/tests/next_tests/unit_tests/embedded_tests/test_nd_array_field.py
index 7c932533..7171bb5e 100644
--- a/tests/next_tests/unit_tests/embedded_tests/test_nd_array_field.py
+++ b/tests/next_tests/unit_tests/embedded_tests/test_nd_array_field.py
@@ -21,7 +21,7 @@ import pytest
 
 from gt4py._core import definitions as core_defs
 from gt4py.next import common
-from gt4py.next.common import Dimension, Domain, UnitRange
+from gt4py.next.common import Dimension, Domain, UnitRange, NamedRange, NamedIndex
 from gt4py.next.embedded import exceptions as embedded_exceptions, nd_array_field
 from gt4py.next.embedded.nd_array_field import _get_slices_from_domain_slice
 from gt4py.next.ffront import fbuiltins
@@ -430,7 +430,7 @@ def test_field_broadcast(new_dims, field, expected_domain):
 @pytest.mark.parametrize(
     "domain_slice",
     [
-        ((D0, UnitRange(0, 10)),),
+        (NamedRange(D0, UnitRange(0, 10)),),
         common.Domain(dims=(D0,), ranges=(UnitRange(0, 10),)),
     ],
 )
@@ -446,7 +446,7 @@ def test_get_slices_with_named_index():
     field_domain = common.Domain(
         dims=(D0, D1, D2), ranges=(UnitRange(0, 10), UnitRange(0, 10), UnitRange(0, 10))
     )
-    named_index = ((D0, UnitRange(0, 10)), (D1, 2), (D2, 3))
+    named_index = (NamedRange(D0, UnitRange(0, 10)), (D1, 2), (D2, 3))
     slices = _get_slices_from_domain_slice(field_domain, named_index)
     assert slices == (slice(0, 10, None), 2, 3)
 
@@ -465,34 +465,34 @@ def test_get_slices_invalid_type():
     [
         (
             (
-                (D0, UnitRange(7, 9)),
-                (D1, UnitRange(8, 10)),
+                NamedRange(D0, UnitRange(7, 9)),
+                NamedRange(D1, UnitRange(8, 10)),
             ),
             (D0, D1, D2),
             (2, 2, 15),
         ),
         (
             (
-                (D0, UnitRange(7, 9)),
-                (D2, UnitRange(12, 20)),
+                NamedRange(D0, UnitRange(7, 9)),
+                NamedRange(D2, UnitRange(12, 20)),
             ),
             (D0, D1, D2),
             (2, 10, 8),
         ),
         (common.Domain(dims=(D0,), ranges=(UnitRange(7, 9),)), (D0, D1, D2), (2, 10, 15)),
-        (((D0, 8),), (D1, D2), (10, 15)),
-        (((D1, 9),), (D0, D2), (5, 15)),
-        (((D2, 11),), (D0, D1), (5, 10)),
+        ((NamedIndex(D0, 8),), (D1, D2), (10, 15)),
+        ((NamedIndex(D1, 9),), (D0, D2), (5, 15)),
+        ((NamedIndex(D2, 11),), (D0, D1), (5, 10)),
         (
             (
-                (D0, 8),
-                (D1, UnitRange(8, 10)),
+                NamedIndex(D0, 8),
+                NamedRange(D1, UnitRange(8, 10)),
             ),
             (D1, D2),
             (2, 15),
         ),
-        ((D0, 5), (D1, D2), (10, 15)),
-        ((D0, UnitRange(5, 7)), (D0, D1, D2), (2, 10, 15)),
+        (NamedIndex(D0, 5), (D1, D2), (10, 15)),
+        (NamedRange(D0, UnitRange(5, 7)), (D0, D1, D2), (2, 10, 15)),
     ],
 )
 def test_absolute_indexing(domain_slice, expected_dimensions, expected_shape):
@@ -513,7 +513,10 @@ def test_absolute_indexing_dim_sliced():
     )
     field = common._field(np.ones((5, 10, 15)), domain=domain)
     indexed_field_1 = field[D1(8) : D1(10), D0(5) : D0(9)]
-    expected = field[(D0, UnitRange(5, 9)), (D1, UnitRange(8, 10))]
+    expected = field[
+        NamedRange(dim=D0, unit_range=UnitRange(5, 9)),
+        NamedRange(dim=D1, unit_range=UnitRange(8, 10)),
+    ]
 
     assert common.is_field(indexed_field_1)
     assert indexed_field_1 == expected
@@ -525,7 +528,7 @@ def test_absolute_indexing_dim_sliced_single_slice():
     )
     field = common._field(np.ones((5, 10, 15)), domain=domain)
     indexed_field_1 = field[D2(11)]
-    indexed_field_2 = field[(D2, 11)]
+    indexed_field_2 = field[NamedIndex(D2, 11)]
 
     assert common.is_field(indexed_field_1)
     assert indexed_field_1 == indexed_field_2
@@ -554,7 +557,7 @@ def test_absolute_indexing_value_return():
     domain = common.Domain(dims=(D0, D1), ranges=(UnitRange(10, 20), UnitRange(5, 15)))
     field = common._field(np.reshape(np.arange(100, dtype=np.int32), (10, 10)), domain=domain)
 
-    named_index = ((D0, 12), (D1, 6))
+    named_index = (NamedIndex(D0, 12), NamedIndex(D1, 6))
     assert common.is_field(field)
     value = field[named_index]
 
@@ -568,23 +571,27 @@ def test_absolute_indexing_value_return():
         (
             (slice(None, 5), slice(None, 2)),
             (5, 2),
-            Domain((D0, UnitRange(5, 10)), (D1, UnitRange(2, 4))),
+            Domain(NamedRange(D0, UnitRange(5, 10)), NamedRange(D1, UnitRange(2, 4))),
+        ),
+        (
+            (slice(None, 5),),
+            (5, 10),
+            Domain(NamedRange(D0, UnitRange(5, 10)), NamedRange(D1, UnitRange(2, 12))),
         ),
-        ((slice(None, 5),), (5, 10), Domain((D0, UnitRange(5, 10)), (D1, UnitRange(2, 12)))),
         (
             (Ellipsis, 1),
             (10,),
-            Domain((D0, UnitRange(5, 15))),
+            Domain(NamedRange(D0, UnitRange(5, 15))),
         ),
         (
             (slice(2, 3), slice(5, 7)),
             (1, 2),
-            Domain((D0, UnitRange(7, 8)), (D1, UnitRange(7, 9))),
+            Domain(NamedRange(D0, UnitRange(7, 8)), NamedRange(D1, UnitRange(7, 9))),
         ),
         (
             (slice(1, 2), 0),
             (1,),
-            Domain((D0, UnitRange(6, 7))),
+            Domain(NamedRange(D0, UnitRange(6, 7))),
         ),
     ],
 )
@@ -693,7 +700,9 @@ def test_field_unsupported_index(index):
         ((1, slice(None)), np.ones((10,)) * 42.0),
         (
             (1, slice(None)),
-            common._field(np.ones((10,)) * 42.0, domain=common.Domain((D1, UnitRange(0, 10)))),
+            common._field(
+                np.ones((10,)) * 42.0, domain=common.Domain(NamedRange(D1, UnitRange(0, 10)))
+            ),
         ),
     ],
 )
@@ -718,7 +727,7 @@ def test_setitem_wrong_domain():
     )
 
     value_incompatible = common._field(
-        np.ones((10,)) * 42.0, domain=common.Domain((D1, UnitRange(-5, 5)))
+        np.ones((10,)) * 42.0, domain=common.Domain(NamedRange(D1, UnitRange(-5, 5)))
     )
 
     with pytest.raises(ValueError, match=r"Incompatible 'Domain'.*"):
@@ -751,7 +760,7 @@ def test_connectivity_field_inverse_image():
 
     # Test codomain
     with pytest.raises(ValueError, match="does not match the codomain dimension"):
-        e2v_conn.inverse_image((E, UnitRange(1, 2)))
+        e2v_conn.inverse_image(NamedRange(E, UnitRange(1, 2)))
 
 
 def test_connectivity_field_inverse_image_2d_domain():
diff --git a/tests/next_tests/unit_tests/iterator_tests/test_embedded_internals.py b/tests/next_tests/unit_tests/iterator_tests/test_embedded_internals.py
index 9238cd4f..ec6e6135 100644
--- a/tests/next_tests/unit_tests/iterator_tests/test_embedded_internals.py
+++ b/tests/next_tests/unit_tests/iterator_tests/test_embedded_internals.py
@@ -62,7 +62,9 @@ def test_column_ufunc():
     embedded.column_range_cvar.set(range(2, 999))
     _run_within_context(
         lambda: test_func(2, 3),
-        column_range=(common.Dimension("K", kind=common.DimensionKind.VERTICAL), range(0, 3)),
+        column_range=common.NamedRange(
+            common.Dimension("K", kind=common.DimensionKind.VERTICAL), range(0, 3)
+        ),
     )
 
 
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_constant_folding.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_constant_folding.py
index 275412a5..00e95418 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_constant_folding.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_constant_folding.py
@@ -45,3 +45,10 @@ def test_constant_folding_if():
     )
     actual = ConstantFolding.apply(testee)
     assert actual == expected
+
+
+def test_constant_folding_minimum():
+    testee = im.call("minimum")("a", "a")
+    expected = im.ref("a")
+    actual = ConstantFolding.apply(testee)
+    assert actual == expected
diff --git a/tests/next_tests/unit_tests/test_common.py b/tests/next_tests/unit_tests/test_common.py
index ce940131..1aeb51cb 100644
--- a/tests/next_tests/unit_tests/test_common.py
+++ b/tests/next_tests/unit_tests/test_common.py
@@ -24,6 +24,7 @@ from gt4py.next.common import (
     UnitRange,
     domain,
     named_range,
+    NamedRange,
     promote_dims,
     unit_range,
 )
@@ -37,7 +38,11 @@ KDim = Dimension("KDim", kind=DimensionKind.VERTICAL)
 
 @pytest.fixture
 def a_domain():
-    return Domain((IDim, UnitRange(0, 10)), (JDim, UnitRange(5, 15)), (KDim, UnitRange(20, 30)))
+    return Domain(
+        NamedRange(IDim, UnitRange(0, 10)),
+        NamedRange(JDim, UnitRange(5, 15)),
+        NamedRange(KDim, UnitRange(20, 30)),
+    )
 
 
 @pytest.fixture(params=[Infinity.POSITIVE, Infinity.NEGATIVE])
@@ -261,10 +266,10 @@ def test_domain_length(a_domain):
     "empty_domain, expected",
     [
         (Domain(), False),
-        (Domain((IDim, UnitRange(0, 10))), False),
-        (Domain((IDim, UnitRange(0, 0))), True),
-        (Domain((IDim, UnitRange(0, 0)), (JDim, UnitRange(0, 1))), True),
-        (Domain((IDim, UnitRange(0, 1)), (JDim, UnitRange(0, 0))), True),
+        (Domain(NamedRange(IDim, UnitRange(0, 10))), False),
+        (Domain(NamedRange(IDim, UnitRange(0, 0))), True),
+        (Domain(NamedRange(IDim, UnitRange(0, 0)), NamedRange(JDim, UnitRange(0, 1))), True),
+        (Domain(NamedRange(IDim, UnitRange(0, 1)), NamedRange(JDim, UnitRange(0, 0))), True),
     ],
 )
 def test_empty_domain(empty_domain, expected):
@@ -436,89 +441,92 @@ def test_domain_pop():
         # Valid index and named ranges
         (
             0,
-            [(Dimension("X"), UnitRange(100, 110))],
+            [NamedRange(Dimension("X"), UnitRange(100, 110))],
             Domain(
-                (Dimension("I"), UnitRange(0, 10)),
-                (Dimension("J"), UnitRange(0, 10)),
-                (Dimension("K"), UnitRange(0, 10)),
+                NamedRange(Dimension("I"), UnitRange(0, 10)),
+                NamedRange(Dimension("J"), UnitRange(0, 10)),
+                NamedRange(Dimension("K"), UnitRange(0, 10)),
             ),
             Domain(
-                (Dimension("X"), UnitRange(100, 110)),
-                (Dimension("J"), UnitRange(0, 10)),
-                (Dimension("K"), UnitRange(0, 10)),
+                NamedRange(Dimension("X"), UnitRange(100, 110)),
+                NamedRange(Dimension("J"), UnitRange(0, 10)),
+                NamedRange(Dimension("K"), UnitRange(0, 10)),
             ),
         ),
         (
             1,
-            [(Dimension("X"), UnitRange(100, 110))],
+            [NamedRange(Dimension("X"), UnitRange(100, 110))],
             Domain(
-                (Dimension("I"), UnitRange(0, 10)),
-                (Dimension("J"), UnitRange(0, 10)),
-                (Dimension("K"), UnitRange(0, 10)),
+                NamedRange(Dimension("I"), UnitRange(0, 10)),
+                NamedRange(Dimension("J"), UnitRange(0, 10)),
+                NamedRange(Dimension("K"), UnitRange(0, 10)),
             ),
             Domain(
-                (Dimension("I"), UnitRange(0, 10)),
-                (Dimension("X"), UnitRange(100, 110)),
-                (Dimension("K"), UnitRange(0, 10)),
+                NamedRange(Dimension("I"), UnitRange(0, 10)),
+                NamedRange(Dimension("X"), UnitRange(100, 110)),
+                NamedRange(Dimension("K"), UnitRange(0, 10)),
             ),
         ),
         (
             -1,
-            [(Dimension("X"), UnitRange(100, 110))],
+            [NamedRange(Dimension("X"), UnitRange(100, 110))],
             Domain(
-                (Dimension("I"), UnitRange(0, 10)),
-                (Dimension("J"), UnitRange(0, 10)),
-                (Dimension("K"), UnitRange(0, 10)),
+                NamedRange(Dimension("I"), UnitRange(0, 10)),
+                NamedRange(Dimension("J"), UnitRange(0, 10)),
+                NamedRange(Dimension("K"), UnitRange(0, 10)),
             ),
             Domain(
-                (Dimension("I"), UnitRange(0, 10)),
-                (Dimension("J"), UnitRange(0, 10)),
-                (Dimension("X"), UnitRange(100, 110)),
+                NamedRange(Dimension("I"), UnitRange(0, 10)),
+                NamedRange(Dimension("J"), UnitRange(0, 10)),
+                NamedRange(Dimension("X"), UnitRange(100, 110)),
             ),
         ),
         (
             Dimension("J"),
-            [(Dimension("X"), UnitRange(100, 110)), (Dimension("Z"), UnitRange(100, 110))],
+            [
+                NamedRange(Dimension("X"), UnitRange(100, 110)),
+                NamedRange(Dimension("Z"), UnitRange(100, 110)),
+            ],
             Domain(
-                (Dimension("I"), UnitRange(0, 10)),
-                (Dimension("J"), UnitRange(0, 10)),
-                (Dimension("K"), UnitRange(0, 10)),
+                NamedRange(Dimension("I"), UnitRange(0, 10)),
+                NamedRange(Dimension("J"), UnitRange(0, 10)),
+                NamedRange(Dimension("K"), UnitRange(0, 10)),
             ),
             Domain(
-                (Dimension("I"), UnitRange(0, 10)),
-                (Dimension("X"), UnitRange(100, 110)),
-                (Dimension("Z"), UnitRange(100, 110)),
-                (Dimension("K"), UnitRange(0, 10)),
+                NamedRange(Dimension("I"), UnitRange(0, 10)),
+                NamedRange(Dimension("X"), UnitRange(100, 110)),
+                NamedRange(Dimension("Z"), UnitRange(100, 110)),
+                NamedRange(Dimension("K"), UnitRange(0, 10)),
             ),
         ),
         # Invalid indices
         (
             3,
-            [(Dimension("X"), UnitRange(100, 110))],
+            [NamedRange(Dimension("X"), UnitRange(100, 110))],
             Domain(
-                (Dimension("I"), UnitRange(0, 10)),
-                (Dimension("J"), UnitRange(0, 10)),
-                (Dimension("K"), UnitRange(0, 10)),
+                NamedRange(Dimension("I"), UnitRange(0, 10)),
+                NamedRange(Dimension("J"), UnitRange(0, 10)),
+                NamedRange(Dimension("K"), UnitRange(0, 10)),
             ),
             IndexError,
         ),
         (
             -4,
-            [(Dimension("X"), UnitRange(100, 110))],
+            [NamedRange(Dimension("X"), UnitRange(100, 110))],
             Domain(
-                (Dimension("I"), UnitRange(0, 10)),
-                (Dimension("J"), UnitRange(0, 10)),
-                (Dimension("K"), UnitRange(0, 10)),
+                NamedRange(Dimension("I"), UnitRange(0, 10)),
+                NamedRange(Dimension("J"), UnitRange(0, 10)),
+                NamedRange(Dimension("K"), UnitRange(0, 10)),
             ),
             IndexError,
         ),
         (
             Dimension("Foo"),
-            [(Dimension("X"), UnitRange(100, 110))],
+            [NamedRange(Dimension("X"), UnitRange(100, 110))],
             Domain(
-                (Dimension("I"), UnitRange(0, 10)),
-                (Dimension("J"), UnitRange(0, 10)),
-                (Dimension("K"), UnitRange(0, 10)),
+                NamedRange(Dimension("I"), UnitRange(0, 10)),
+                NamedRange(Dimension("J"), UnitRange(0, 10)),
+                NamedRange(Dimension("K"), UnitRange(0, 10)),
             ),
             ValueError,
         ),