Repository: GridTools/gt4py
Pull request #1055 (2022-11-18 09:30:46)

ITIR pass improvements
- Fixed bug in eve.NodeVisitor leading to outdated symbol tables (independently discovered by @jdahm and fixed the same way here -> no merge conflicts)
- Incomplete offsets for lifted stencil call in embedded backend (allows using them in reductions)
- Factored out `CountSymbolRefs` pass from `inline_lambdas` module (now also used in new `MergeLet` pass)
- Fixed several bugs in the common subexpression elimination (BFS instead of DFS to collect larger expressions + refactoring of `CollectSubexpressions` subpass)
- Factored out lambda inlining into visitor and actual inliner
- Fixes for corner cases in collision resolution of lambda inliner
- Allow force inling lifted stencil arguments to lambda functions in lambda inliner
- Reworked lift inliner
  - Allow inlining nested lifts without a deref
  - Stateless inlining heuristics predicate
  - Directly use lambda inliner for nested lifts to reduce ping-pong between inline lift and inline lambda pass
- New MergeLet pass to reduce tree depth and readability of ir


Patch:
diff --git a/.gitignore b/.gitignore
index 3c86600c..b4f2b1c8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -104,7 +104,7 @@ venv.bak/
 .idea/
 
 # VSCode project settings
-/.vscode/
+/.vscode
 
 # SublimeText project settings
 *.sublime-project
diff --git a/setup.cfg b/setup.cfg
index 35b16039..0d7d76a4 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -50,6 +50,7 @@ install_requires =
     devtools>=0.5
     deepdiff>=5.8
     frozendict>=2.3
+    gridtools-cpp>=2.2.2,==2.*
     jinja2>=2.10
     lark>=1.1.2
     mako>=1.1
@@ -232,9 +233,6 @@ ignore_errors = True
 [mypy-functional.ffront.dialect_parser]
 ignore_errors = True
 
-[mypy-functional.ffront.func_to_foast]
-ignore_errors = True
-
 [mypy-functional.ffront.past_to_itir]
 ignore_errors = True
 
diff --git a/src/eve/visitors.py b/src/eve/visitors.py
index 982c445c..905dbeee 100644
--- a/src/eve/visitors.py
+++ b/src/eve/visitors.py
@@ -165,7 +165,6 @@ class NodeTranslator(NodeVisitor):
                     if (new_child := self.visit(child, **kwargs)) is not NOTHING
                 },
             )
-            new_node.annex.reset(copy.deepcopy(node.annex.__dict__, memo=memo))
             return new_node
 
         if isinstance(node, (list, tuple, set, collections.abc.Set)) or (
diff --git a/src/functional/ffront/fbuiltins.py b/src/functional/ffront/fbuiltins.py
index 9c010752..c570a526 100644
--- a/src/functional/ffront/fbuiltins.py
+++ b/src/functional/ffront/fbuiltins.py
@@ -75,6 +75,17 @@ where = BuiltInFunction(
     )
 )
 
+astype = BuiltInFunction(
+    ct.FunctionType(
+        args=[
+            ct.DeferredSymbolType(constraint=ct.FieldType),
+            ct.DeferredSymbolType(constraint=ct.FunctionType),
+        ],
+        kwargs={},
+        returns=ct.DeferredSymbolType(constraint=ct.FieldType),
+    )
+)
+
 _unary_math_builtin = BuiltInFunction(
     ct.FunctionType(
         args=[ct.DeferredSymbolType(constraint=(ct.ScalarType, ct.FieldType))],
@@ -180,6 +191,7 @@ FUN_BUILTIN_NAMES = [
     "min_over",
     "broadcast",
     "where",
+    "astype",
 ] + MATH_BUILTIN_NAMES
 
 BUILTIN_NAMES = TYPE_BUILTIN_NAMES + FUN_BUILTIN_NAMES
diff --git a/src/functional/ffront/foast_passes/closure_var_folding.py b/src/functional/ffront/foast_passes/closure_var_folding.py
index da91c924..82785181 100644
--- a/src/functional/ffront/foast_passes/closure_var_folding.py
+++ b/src/functional/ffront/foast_passes/closure_var_folding.py
@@ -32,7 +32,9 @@ class ClosureVarFolding(NodeTranslator, traits.VisitorWithSymbolTableTrait):
     closure_vars: dict[str, Any]
 
     @classmethod
-    def apply(cls, node: foast.LocatedNode, closure_vars: dict[str, Any]) -> foast.LocatedNode:
+    def apply(
+        cls, node: foast.FunctionDefinition | foast.FieldOperator, closure_vars: dict[str, Any]
+    ) -> foast.FunctionDefinition:
         return cls(closure_vars=closure_vars).visit(node)
 
     def visit_Name(
diff --git a/src/functional/ffront/foast_passes/closure_var_type_deduction.py b/src/functional/ffront/foast_passes/closure_var_type_deduction.py
index ab2ab84c..801880e1 100644
--- a/src/functional/ffront/foast_passes/closure_var_type_deduction.py
+++ b/src/functional/ffront/foast_passes/closure_var_type_deduction.py
@@ -32,7 +32,9 @@ class ClosureVarTypeDeduction(NodeTranslator, traits.VisitorWithSymbolTableTrait
     closure_vars: dict[str, Any]
 
     @classmethod
-    def apply(cls, node: foast.LocatedNode, closure_vars: dict[str, Any]) -> foast.LocatedNode:
+    def apply(
+        cls, node: foast.FunctionDefinition, closure_vars: dict[str, Any]
+    ) -> foast.FunctionDefinition:
         return cls(closure_vars=closure_vars).visit(node)
 
     def visit_FunctionDefinition(
diff --git a/src/functional/ffront/foast_passes/dead_closure_var_elimination.py b/src/functional/ffront/foast_passes/dead_closure_var_elimination.py
index 2480c10f..0d5a684a 100644
--- a/src/functional/ffront/foast_passes/dead_closure_var_elimination.py
+++ b/src/functional/ffront/foast_passes/dead_closure_var_elimination.py
@@ -23,7 +23,7 @@ class DeadClosureVarElimination(NodeTranslator, traits.VisitorWithSymbolTableTra
     _referenced_symbols: list[foast.Symbol]
 
     @classmethod
-    def apply(cls, node: foast.LocatedNode) -> foast.LocatedNode:
+    def apply(cls, node: foast.FunctionDefinition) -> foast.FunctionDefinition:
         return cls().visit(node)
 
     def visit_Name(self, node: foast.Name, symtable, **kwargs: Any) -> foast.Name:
diff --git a/src/functional/ffront/foast_passes/iterable_unpack.py b/src/functional/ffront/foast_passes/iterable_unpack.py
index 7b97faa7..a45f8ccd 100644
--- a/src/functional/ffront/foast_passes/iterable_unpack.py
+++ b/src/functional/ffront/foast_passes/iterable_unpack.py
@@ -30,7 +30,7 @@ class UnpackedAssignPass(NodeTranslator, traits.VisitorWithSymbolTableTrait):
     unique_tuple_symbol_id: int = 0
 
     @classmethod
-    def apply(cls, node: foast.FieldOperator) -> foast.FieldOperator:
+    def apply(cls, node: foast.FunctionDefinition) -> foast.FunctionDefinition:
         node = cls().visit(node)
         return node
 
diff --git a/src/functional/ffront/foast_passes/type_deduction.py b/src/functional/ffront/foast_passes/type_deduction.py
index 0deacc12..3a13149f 100644
--- a/src/functional/ffront/foast_passes/type_deduction.py
+++ b/src/functional/ffront/foast_passes/type_deduction.py
@@ -159,7 +159,7 @@ class FieldOperatorTypeDeduction(traits.VisitorWithSymbolTableTrait, NodeTransla
     """
 
     @classmethod
-    def apply(cls, node: foast.FieldOperator) -> foast.FieldOperator:
+    def apply(cls, node: foast.FunctionDefinition) -> foast.FunctionDefinition:
         typed_foast_node = cls().visit(node)
 
         FieldOperatorTypeDeductionCompletnessValidator.apply(typed_foast_node)
@@ -461,6 +461,12 @@ class FieldOperatorTypeDeduction(traits.VisitorWithSymbolTableTrait, NodeTransla
         if node.op == ct.BinaryOperator.POW:
             return left_type
 
+        if node.op == ct.BinaryOperator.MOD and not type_info.is_integral(right_type):
+            raise FieldOperatorTypeDeductionError.from_foast_node(
+                arg,
+                msg=f"Type {right_type} can not be used in operator `{node.op}`, it can only accept ints",
+            )
+
         try:
             return type_info.promote(left_type, right_type)
         except GTTypeError as ex:
@@ -658,6 +664,28 @@ class FieldOperatorTypeDeduction(traits.VisitorWithSymbolTableTrait, NodeTransla
     def _visit_min_over(self, node: foast.Call, **kwargs) -> foast.Call:
         return self._visit_reduction(node, **kwargs)
 
+    def _visit_astype(self, node: foast.Call, **kwargs) -> foast.Call:
+        casted_obj_type = node.args[0].type
+        dtype_obj = node.args[1]
+        assert isinstance(dtype_obj, foast.Name)
+        dtype_obj_type = dtype_obj.type
+        assert isinstance(dtype_obj_type, ct.FunctionType)
+        assert dtype_obj.id in fbuiltins.TYPE_BUILTIN_NAMES
+        assert isinstance(casted_obj_type, ct.FieldType)
+        assert type_info.is_arithmetic(casted_obj_type) or type_info.is_logical(casted_obj_type)
+
+        return_type = ct.FieldType(
+            dims=casted_obj_type.dims,
+            dtype=self.visit(dtype_obj_type).returns,
+        )
+        return foast.Call(
+            func=node.func,
+            args=node.args,
+            kwargs=node.kwargs,
+            type=return_type,
+            location=node.location,
+        )
+
     def _visit_where(self, node: foast.Call, **kwargs) -> foast.Call:
         mask_type = cast(ct.FieldType, node.args[0].type)
         true_branch_type = node.args[1].type
diff --git a/src/functional/ffront/foast_to_itir.py b/src/functional/ffront/foast_to_itir.py
index 21418a01..21c8bc7a 100644
--- a/src/functional/ffront/foast_to_itir.py
+++ b/src/functional/ffront/foast_to_itir.py
@@ -416,6 +416,11 @@ class FieldOperatorLowering(NodeTranslator):
             f"Call to object of type {type(node.func.type).__name__} not understood."
         )
 
+    def _visit_astype(self, node: foast.Call, **kwargs) -> itir.FunCall:
+        obj = (to_value(node.args[0]))(self.visit(node.args[0], **kwargs))
+        dtype = self.visit(node.args[1]).id
+        return self._lift_lambda(node)(im.call_("cast_")(obj, dtype))
+
     def _visit_where(self, node: foast.Call, **kwargs) -> itir.FunCall:
         mask, left, right = (to_value(arg)(self.visit(arg, **kwargs)) for arg in node.args)
         # since the if_ builtin expects a value for the condition we need to
diff --git a/src/functional/ffront/func_to_foast.py b/src/functional/ffront/func_to_foast.py
index 9e7b83d9..7a3bbd09 100644
--- a/src/functional/ffront/func_to_foast.py
+++ b/src/functional/ffront/func_to_foast.py
@@ -96,10 +96,10 @@ class FieldOperatorParser(DialectParser[foast.FunctionDefinition]):
     @classmethod
     def _postprocess_dialect_ast(
         cls,
-        foast_node: foast.FieldOperator,
+        foast_node: foast.FunctionDefinition | foast.FieldOperator,
         closure_vars: dict[str, Any],
         annotations: dict[str, Any],
-    ) -> foast.FieldOperator:
+    ) -> foast.FunctionDefinition:
         foast_node = ClosureVarFolding.apply(foast_node, closure_vars)
         foast_node = DeadClosureVarElimination.apply(foast_node)
         foast_node = ClosureVarTypeDeduction.apply(foast_node, closure_vars)
@@ -113,9 +113,9 @@ class FieldOperatorParser(DialectParser[foast.FunctionDefinition]):
             )
             # TODO(tehrengruber): use `type_info.return_type` when the type of the
             #  arguments becomes available here
-            if annotated_return_type != foast_node.type.returns:
+            if annotated_return_type != foast_node.type.returns:  # type: ignore[union-attr] # revisit when `type_info.return_type` is implemented
                 raise common.GTTypeError(
-                    f"Annotated return type does not match deduced return type. Expected `{foast_node.type.returns}`"
+                    f"Annotated return type does not match deduced return type. Expected `{foast_node.type.returns}`"  # type: ignore[union-attr] # revisit when `type_info.return_type` is implemented
                     f", but got `{annotated_return_type}`."
                 )
         return foast_node
@@ -193,7 +193,7 @@ class FieldOperatorParser(DialectParser[foast.FunctionDefinition]):
             )
         return foast.DataSymbol(id=node.arg, location=self._make_loc(node), type=new_type)
 
-    def visit_Assign(self, node: ast.Assign, **kwargs) -> foast.Assign:
+    def visit_Assign(self, node: ast.Assign, **kwargs) -> foast.Assign | foast.TupleTargetAssign:
         target = node.targets[0]  # there is only one element after assignment passes
 
         if isinstance(target, ast.Tuple):
@@ -206,7 +206,7 @@ class FieldOperatorParser(DialectParser[foast.FunctionDefinition]):
                     new_targets.append(
                         foast.Starred(
                             id=foast.DataSymbol(
-                                id=elt.value.id,
+                                id=self.visit(elt.value).id,
                                 location=self._make_loc(elt),
                                 type=ct.DeferredSymbolType(constraint=ct.DataType),
                             ),
@@ -217,7 +217,7 @@ class FieldOperatorParser(DialectParser[foast.FunctionDefinition]):
                 else:
                     new_targets.append(
                         foast.DataSymbol(
-                            id=elt.id,
+                            id=self.visit(elt).id,
                             location=self._make_loc(elt),
                             type=ct.DeferredSymbolType(constraint=ct.DataType),
                         )
diff --git a/src/functional/ffront/past_passes/type_deduction.py b/src/functional/ffront/past_passes/type_deduction.py
index 0868d0a6..0552e5a2 100644
--- a/src/functional/ffront/past_passes/type_deduction.py
+++ b/src/functional/ffront/past_passes/type_deduction.py
@@ -143,6 +143,12 @@ class ProgramTypeDeduction(traits.VisitorWithSymbolTableTrait, NodeTranslator):
         if node.op == ct.BinaryOperator.POW:
             return left_type
 
+        if node.op == ct.BinaryOperator.MOD and not type_info.is_integral(right_type):
+            raise ProgramTypeError.from_past_node(
+                arg,
+                msg=f"Type {right_type} can not be used in operator `{node.op}`, it can only accept ints",
+            )
+
         try:
             return type_info.promote(left_type, right_type)
         except GTTypeError as ex:
diff --git a/src/functional/ffront/symbol_makers.py b/src/functional/ffront/symbol_makers.py
index f507d0b0..61df66fc 100644
--- a/src/functional/ffront/symbol_makers.py
+++ b/src/functional/ffront/symbol_makers.py
@@ -51,7 +51,7 @@ def make_scalar_kind(dtype: npt.DTypeLike) -> ct.ScalarKind:
                 return ct.ScalarKind.FLOAT32
             case np.float64:
                 return ct.ScalarKind.FLOAT64
-            case np.str:
+            case np.str_:
                 return ct.ScalarKind.STRING
             case _:
                 raise common.GTTypeError(f"Impossible to map '{dtype}' value to a ScalarKind")
diff --git a/src/functional/ffront/type_info.py b/src/functional/ffront/type_info.py
index d0effa71..c8739766 100644
--- a/src/functional/ffront/type_info.py
+++ b/src/functional/ffront/type_info.py
@@ -299,6 +299,8 @@ def is_concretizable(symbol_type: ct.SymbolType, to_type: ct.SymbolType) -> bool
     True
 
     """
+    if isinstance(symbol_type, ct.FunctionType):
+        raise NotImplementedError("`FunctionType` is not concretizable")
     if isinstance(symbol_type, ct.DeferredSymbolType) and (
         symbol_type.constraint is None or issubclass(type_class(to_type), symbol_type.constraint)
     ):
diff --git a/src/functional/iterator/builtins.py b/src/functional/iterator/builtins.py
index ba183e26..fc3eff44 100644
--- a/src/functional/iterator/builtins.py
+++ b/src/functional/iterator/builtins.py
@@ -59,6 +59,11 @@ def if_(*args):
     raise BackendNotSelectedError()
 
 
+@builtin_dispatch
+def cast_(*args):
+    raise BackendNotSelectedError()
+
+
 @builtin_dispatch
 def not_(*args):
     raise BackendNotSelectedError()
@@ -289,6 +294,41 @@ def power(*args):
     raise BackendNotSelectedError()
 
 
+@builtin_dispatch
+def int(*args):  # noqa: A001
+    raise BackendNotSelectedError()
+
+
+@builtin_dispatch
+def int32(*args):
+    raise BackendNotSelectedError()
+
+
+@builtin_dispatch
+def int64(*args):
+    raise BackendNotSelectedError()
+
+
+@builtin_dispatch
+def float(*args):  # noqa: A001
+    raise BackendNotSelectedError()
+
+
+@builtin_dispatch
+def float32(*args):
+    raise BackendNotSelectedError()
+
+
+@builtin_dispatch
+def float64(*args):
+    raise BackendNotSelectedError()
+
+
+@builtin_dispatch
+def bool(*args):  # noqa: A001
+    raise BackendNotSelectedError()
+
+
 UNARY_MATH_NUMBER_BUILTINS = {"abs"}
 UNARY_MATH_FP_BUILTINS = {
     "sin",
@@ -314,11 +354,13 @@ UNARY_MATH_FP_BUILTINS = {
 }
 UNARY_MATH_FP_PREDICATE_BUILTINS = {"isfinite", "isinf", "isnan"}
 BINARY_MATH_NUMBER_BUILTINS = {"minimum", "maximum", "fmod", "power"}
+TYPEBUILTINS = {"int", "int32", "int64", "float", "float32", "float64", "bool"}
 MATH_BUILTINS = (
     UNARY_MATH_NUMBER_BUILTINS
     | UNARY_MATH_FP_BUILTINS
     | UNARY_MATH_FP_PREDICATE_BUILTINS
     | BINARY_MATH_NUMBER_BUILTINS
+    | TYPEBUILTINS
 )
 BUILTINS = {
     "deref",
@@ -335,6 +377,7 @@ BUILTINS = {
     "make_tuple",
     "tuple_get",
     "if_",
+    "cast_",
     "greater",
     "less",
     "less_equal",
diff --git a/src/functional/iterator/embedded.py b/src/functional/iterator/embedded.py
index 874f5673..601e4c3f 100644
--- a/src/functional/iterator/embedded.py
+++ b/src/functional/iterator/embedded.py
@@ -253,6 +253,13 @@ def if_(cond, t, f):
     return t if cond else f
 
 
+@builtins.cast_.register(EMBEDDED)
+def cast_(obj, new_dtype):
+    if isinstance(obj, Column):
+        return obj.data.astype(new_dtype.__name__)
+    return new_dtype(obj)
+
+
 @builtins.not_.register(EMBEDDED)
 def not_(a):
     if isinstance(a, Column):
@@ -317,10 +324,21 @@ def lift(stencil):
 
             def max_neighbors(self):
                 # TODO cleanup, test edge cases
-                open_offsets = get_open_offsets(*self.offsets)
+                open_offsets = get_open_offsets(*self.incomplete_offsets, *self.offsets)
                 assert open_offsets
                 return _get_connectivity(args[0].offset_provider, open_offsets[0]).max_neighbors
 
+            @property
+            def incomplete_offsets(self):
+                incomplete_offsets = []
+                for arg in self.args:
+                    if arg.incomplete_offsets:
+                        assert (
+                            not incomplete_offsets or incomplete_offsets == arg.incomplete_offsets
+                        )
+                        incomplete_offsets = arg.incomplete_offsets
+                return incomplete_offsets
+
             def _shifted_args(self):
                 return tuple(map(lambda arg: arg.shift(*self.offsets), self.args))
 
@@ -472,10 +490,16 @@ def promote_scalars(val: CompositeOfScalarOrField):
 
 
 for math_builtin_name in builtins.MATH_BUILTINS:
+    python_builtins = {"int": int, "float": float, "bool": bool, "str": str}
     decorator = getattr(builtins, math_builtin_name).register(EMBEDDED)
     if math_builtin_name == "gamma":
         # numpy has no gamma function
         impl = np.vectorize(math.gamma)
+    elif math_builtin_name in python_builtins:
+        # TODO: Should potentially use numpy fixed size types to be consistent
+        #   with compiled backends. Currently using Python types to preserve
+        #   existing behaviour.
+        impl = python_builtins[math_builtin_name]
     else:
         impl = getattr(np, math_builtin_name)
     globals()[math_builtin_name] = decorator(impl)
diff --git a/src/functional/iterator/ir.py b/src/functional/iterator/ir.py
index 42417ab5..01b0b47a 100644
--- a/src/functional/iterator/ir.py
+++ b/src/functional/iterator/ir.py
@@ -106,6 +106,8 @@ UNARY_MATH_FP_BUILTINS = {
 UNARY_MATH_FP_PREDICATE_BUILTINS = {"isfinite", "isinf", "isnan"}
 BINARY_MATH_NUMBER_BUILTINS = {"minimum", "maximum", "fmod", "power"}
 
+TYPEBUILTINS = {"int", "int32", "int64", "float", "float32", "float64", "bool"}
+
 BUILTINS = {
     "cartesian_domain",
     "unstructured_domain",
@@ -131,6 +133,7 @@ BUILTINS = {
     "less_equal",
     "not_eq",
     "if_",
+    "cast_",
     "not_",
     "and_",
     "or_",
@@ -139,6 +142,7 @@ BUILTINS = {
     *UNARY_MATH_FP_BUILTINS,
     *UNARY_MATH_FP_PREDICATE_BUILTINS,
     *BINARY_MATH_NUMBER_BUILTINS,
+    *TYPEBUILTINS,
 }
 
 
diff --git a/src/functional/iterator/pretty_parser.py b/src/functional/iterator/pretty_parser.py
index 3bed7654..c474d759 100644
--- a/src/functional/iterator/pretty_parser.py
+++ b/src/functional/iterator/pretty_parser.py
@@ -152,6 +152,9 @@ class ToIrTransformer(lark_visitors.Transformer):
     def lift(self, arg: ir.Expr) -> ir.FunCall:
         return ir.FunCall(fun=ir.SymRef(id="lift"), args=[arg])
 
+    def astype(self, arg: ir.Expr) -> ir.FunCall:
+        return ir.FunCall(fun=ir.SymRef(id="cast_"), args=[arg])
+
     def shift(self, *offsets: ir.Expr) -> ir.FunCall:
         return ir.FunCall(fun=ir.SymRef(id="shift"), args=list(offsets))
 
diff --git a/src/functional/iterator/transforms/count_symbol_refs.py b/src/functional/iterator/transforms/count_symbol_refs.py
new file mode 100644
index 00000000..a60476af
--- /dev/null
+++ b/src/functional/iterator/transforms/count_symbol_refs.py
@@ -0,0 +1,28 @@
+import dataclasses
+
+import eve
+from functional.iterator import ir as itir
+
+
+@dataclasses.dataclass
+class CountSymbolRefs(eve.NodeVisitor):
+    ref_counts: dict[str, int]
+
+    @classmethod
+    def apply(cls, node: itir.Node, symbol_names: list[str]) -> dict[str, int]:
+        ref_counts = {name: 0 for name in symbol_names}
+        active_refs = set(symbol_names)
+
+        obj = cls(ref_counts=ref_counts)
+        obj.visit(node, active_refs=active_refs)
+
+        return obj.ref_counts
+
+    def visit_SymRef(self, node: itir.Node, *, active_refs: set[str]):
+        if node.id in active_refs:
+            self.ref_counts[node.id] += 1
+
+    def visit_Lambda(self, node: itir.Lambda, *, active_refs: set[str]):
+        active_refs = active_refs - {param.id for param in node.params}
+
+        self.generic_visit(node, active_refs=active_refs)
diff --git a/src/functional/iterator/transforms/cse.py b/src/functional/iterator/transforms/cse.py
index de0daf11..7b3ec9b5 100644
--- a/src/functional/iterator/transforms/cse.py
+++ b/src/functional/iterator/transforms/cse.py
@@ -12,8 +12,11 @@ class CollectSubexpressions(NodeVisitor):
     def apply(cls, node: ir.Node):
         subexprs = dict[ir.Node, tuple[list[int], Optional[ir.Node]]]()
         refs: ChainMap[str, bool] = ChainMap()
+        collector_stack: list[bool] = []
 
-        cls().visit(node, subexprs=subexprs, refs=refs, parent=None)
+        cls().visit(
+            node, subexprs=subexprs, refs=refs, parent=None, collector_stack=collector_stack
+        )
 
         return subexprs
 
@@ -22,10 +25,13 @@ class CollectSubexpressions(NodeVisitor):
         node: ir.SymRef,
         *,
         refs: ChainMap[str, bool],
+        collector_stack: list[bool],
         **kwargs,
     ) -> None:
         if node.id in refs:
-            refs[node.id] = True
+            # we have used a symbol that is not declared in the root node, so propagate
+            # to parent
+            collector_stack[-1] = False
 
     def visit_Lambda(
         self,
@@ -34,12 +40,19 @@ class CollectSubexpressions(NodeVisitor):
         subexprs: dict[ir.Node, tuple[list[int], Optional[ir.Node]]],
         refs: ChainMap[str, bool],
         parent: Optional[ir.Node],
+        collector_stack: list[bool],
     ) -> None:
         r = refs.new_child({p.id: False for p in node.params})
-        self.generic_visit(node, subexprs=subexprs, refs=r, parent=node)
 
-        if not any(refs.maps[0].values()):
+        child_collector_stack = [*collector_stack, True]
+        self.generic_visit(
+            node, subexprs=subexprs, refs=r, parent=node, collector_stack=child_collector_stack
+        )
+        if child_collector_stack[-1]:
             subexprs.setdefault(node, ([], parent))[0].append(id(node))
+        else:
+            if len(collector_stack) > 0:
+                collector_stack[-1] = False  # poison parent
 
     def visit_FunCall(
         self,
@@ -48,13 +61,22 @@ class CollectSubexpressions(NodeVisitor):
         subexprs: dict[ir.Node, tuple[list[int], Optional[ir.Node]]],
         refs: ChainMap[str, bool],
         parent: Optional[ir.Node],
+        collector_stack: list[bool],
     ) -> None:
-        self.generic_visit(node, subexprs=subexprs, refs=refs, parent=node)
-        # do not collect (and thus deduplicate in CSE) shift(offsets…) calls
-        if node.fun == ir.SymRef(id="shift"):
-            return
-        if not any(refs.maps[0].values()):
+        # do not collect (and thus deduplicate in CSE) shift(offsets…) calls. Node must still be
+        #  visited, to ensure symbol dependencies are recognized correctly.
+        allow_collection = node.fun != ir.SymRef(id="shift")
+        child_collector_stack = [*collector_stack, allow_collection]
+
+        self.generic_visit(
+            node, subexprs=subexprs, refs=refs, parent=node, collector_stack=child_collector_stack
+        )
+
+        if child_collector_stack[-1]:
             subexprs.setdefault(node, ([], parent))[0].append(id(node))
+        else:
+            if len(collector_stack) > 0:
+                collector_stack[-1] = False  # poison parent
 
 
 @dataclasses.dataclass(frozen=True)
@@ -81,8 +103,6 @@ class CommonSubexpressionElimination(NodeTranslator):
         ]:
             return node
 
-        node = self.generic_visit(node)
-
         # collect expressions
         subexprs = CollectSubexpressions.apply(node)
 
@@ -103,7 +123,7 @@ class CommonSubexpressionElimination(NodeTranslator):
                     expr_map[i] = expr_ref
 
         if not expr_map:
-            return node
+            return self.generic_visit(node)
 
         # apply remapping
         class Replace(NodeTranslator):
@@ -112,7 +132,9 @@ class CommonSubexpressionElimination(NodeTranslator):
                     return expr_map[id(node)]
                 return self.generic_visit(node)
 
-        return ir.FunCall(
-            fun=ir.Lambda(params=params, expr=Replace().visit(node)),
-            args=args,
+        return self.generic_visit(
+            ir.FunCall(
+                fun=ir.Lambda(params=params, expr=Replace().visit(node)),
+                args=args,
+            )
         )
diff --git a/src/functional/iterator/transforms/inline_lambdas.py b/src/functional/iterator/transforms/inline_lambdas.py
index e932c324..30ded640 100644
--- a/src/functional/iterator/transforms/inline_lambdas.py
+++ b/src/functional/iterator/transforms/inline_lambdas.py
@@ -1,32 +1,94 @@
 import dataclasses
+from typing import Optional
 
-from eve import NodeTranslator, NodeVisitor
+from eve import NodeTranslator
 from functional.iterator import ir
+from functional.iterator.transforms.count_symbol_refs import CountSymbolRefs
 from functional.iterator.transforms.remap_symbols import RemapSymbolRefs, RenameSymbols
 
 
-@dataclasses.dataclass
-class CountSymbolRefs(NodeVisitor):
-    ref_counts: dict[str, int]
-
-    @classmethod
-    def apply(cls, node: ir.Node, symbol_names: list[str]) -> dict[str, int]:
-        ref_counts = {name: 0 for name in symbol_names}
-        active_refs = set(symbol_names)
-
-        obj = cls(ref_counts=ref_counts)
-        obj.visit(node, active_refs=active_refs)
-
-        return ref_counts
-
-    def visit_SymRef(self, node: ir.Node, *, active_refs: set[str]):
-        if node.id in active_refs:
-            self.ref_counts[node.id] += 1
-
-    def visit_Lambda(self, node: ir.Lambda, *, active_refs: set[str]):
-        active_refs = active_refs - set(param.id for param in node.params)
+def inline_lambda(
+    node: ir.FunCall,
+    opcount_preserving=False,
+    force_inline_lift=False,
+    eligible_params: Optional[list[bool]] = None,
+):
+    eligible_params = eligible_params or [True] * len(node.fun.params)
+
+    assert len(eligible_params) == len(node.fun.params) == len(node.args)
+
+    if opcount_preserving:
+        ref_counts = CountSymbolRefs.apply(node.fun.expr, [p.id for p in node.fun.params])
+
+        for i, param in enumerate(node.fun.params):
+            # TODO(tehrengruber): allow inlining more complicated zero-op expressions like
+            #  ignore_shift(...)(it_sym)  # noqa: E800
+            if ref_counts[param.id] != 1 and not isinstance(
+                node.args[i], (ir.SymRef, ir.Literal, ir.OffsetLiteral)
+            ):
+                eligible_params[i] = False
+
+    if force_inline_lift:
+        for i, arg in enumerate(node.args):
+            if (
+                isinstance(arg, ir.FunCall)
+                and isinstance(arg.fun, ir.FunCall)
+                and isinstance(arg.fun.fun, ir.SymRef)
+                and arg.fun.fun.id == "lift"
+            ):
+                eligible_params[i] = True
+
+    if node.fun.params and not any(eligible_params):
+        return node
 
-        self.generic_visit(node, active_refs=active_refs)
+    refs = set().union(
+        *(
+            arg.pre_walk_values().if_isinstance(ir.SymRef).getattr("id").to_set()
+            for arg, eligible in zip(node.args, eligible_params)
+            if eligible
+        )
+    )
+    syms = node.fun.expr.pre_walk_values().if_isinstance(ir.Sym).getattr("id").to_set()
+    clashes = refs & syms
+    expr = node.fun.expr
+    if clashes:
+        # TODO(tehrengruber): find a better way of generating new symbols
+        #  in `name_map` that don't collide with each other. E.g. this
+        #  must still work:
+        # (lambda arg, arg_: (lambda arg_: ...)(arg))(a, b)  # noqa: E800
+        name_map = {}
+
+        def new_name(name):
+            while name in refs or name in syms or name in name_map.values():
+                name += "_"
+            return name
+
+        for sym in clashes:
+            name_map[sym] = new_name(sym)
+
+        expr = RenameSymbols().visit(expr, name_map=name_map)
+
+    symbol_map = {
+        param.id: arg
+        for param, arg, eligible in zip(node.fun.params, node.args, eligible_params)
+        if eligible
+    }
+    new_expr = RemapSymbolRefs().visit(expr, symbol_map=symbol_map)
+
+    if all(eligible_params):
+        return new_expr
+    else:
+        return ir.FunCall(
+            fun=ir.Lambda(
+                params=[
+                    param
+                    for param, eligible in zip(node.fun.params, eligible_params)
+                    if not eligible
+                ],
+                expr=new_expr,
+            ),
+            args=[arg for arg, eligible in zip(node.args, eligible_params) if not eligible],
+        )
 
 
 @dataclasses.dataclass
@@ -35,8 +97,10 @@ class InlineLambdas(NodeTranslator):
 
     opcount_preserving: bool
 
+    force_inline_lift: bool
+
     @classmethod
-    def apply(cls, node: ir.Node, opcount_preserving=False):
+    def apply(cls, node: ir.Node, opcount_preserving=False, force_inline_lift=False):
         """
         Inline lambda calls by substituting every arguments by its value.
 
@@ -51,64 +115,18 @@ class InlineLambdas(NodeTranslator):
             inline lambda call if the resulting call has the same number of
             operations.
         """
-        return cls(opcount_preserving=opcount_preserving).visit(node)
+        return cls(
+            opcount_preserving=opcount_preserving,
+            force_inline_lift=force_inline_lift,
+        ).visit(node)
 
     def visit_FunCall(self, node: ir.FunCall):
         node = self.generic_visit(node)
         if isinstance(node.fun, ir.Lambda):
-            assert len(node.fun.params) == len(node.args)
-
-            eligible_params: list[bool] = [True] * len(node.fun.params)
-            if self.opcount_preserving:
-                ref_counts = CountSymbolRefs.apply(node.fun.expr, [p.id for p in node.fun.params])
-
-                for i, param in enumerate(node.fun.params):
-                    if ref_counts[param.id] != 1 and not isinstance(node.args[i], ir.SymRef):
-                        eligible_params[i] = False
-
-                if not any(eligible_params):
-                    return node
-
-            refs = set().union(
-                *(
-                    arg.pre_walk_values().if_isinstance(ir.SymRef).getattr("id").to_set()
-                    for i, arg in enumerate(node.args)
-                    if eligible_params[i]
-                )
+            return inline_lambda(
+                node,
+                opcount_preserving=self.opcount_preserving,
+                force_inline_lift=self.force_inline_lift,
             )
-            syms = node.fun.expr.pre_walk_values().if_isinstance(ir.Sym).getattr("id").to_set()
-            clashes = refs & syms
-            expr = node.fun.expr
-            if clashes:
-
-                def new_name(name):
-                    while name in refs or name in syms:
-                        name += "_"
-                    return name
-
-                name_map = {sym: new_name(sym) for sym in clashes}
-                expr = RenameSymbols().visit(expr, name_map=name_map)
-
-            symbol_map = {
-                param.id: arg
-                for i, (param, arg) in enumerate(zip(node.fun.params, node.args))
-                if eligible_params[i]
-            }
-            new_expr = RemapSymbolRefs().visit(expr, symbol_map=symbol_map)
-
-            if all(eligible_params):
-                return new_expr
-            else:
-                return ir.FunCall(
-                    fun=ir.Lambda(
-                        params=[
-                            param
-                            for param, eligable in zip(node.fun.params, eligible_params)
-                            if not eligable
-                        ],
-                        expr=new_expr,
-                    ),
-                    args=[arg for arg, eligable in zip(node.args, eligible_params) if not eligable],
-                )
 
         return node
diff --git a/src/functional/iterator/transforms/inline_lifts.py b/src/functional/iterator/transforms/inline_lifts.py
index 69b0c3fc..8c1e800b 100644
--- a/src/functional/iterator/transforms/inline_lifts.py
+++ b/src/functional/iterator/transforms/inline_lifts.py
@@ -1,71 +1,160 @@
+import dataclasses
 from collections.abc import Callable
 from typing import Optional
 
-from eve import NodeTranslator
+import eve
+from eve import NodeTranslator, traits
+from functional.ffront import itir_makers as im
 from functional.iterator import ir
+from functional.iterator.transforms.inline_lambdas import inline_lambda
 
 
-class InlineLifts(NodeTranslator):
+def _generate_unique_symbol(
+    desired_name: Optional[eve.SymbolName | tuple[ir.Lambda | ir.SymRef, int]] = None,
+    occupied_names=None,
+    occupied_symbols=None,
+):
+    occupied_names = occupied_names or set()
+    occupied_symbols = occupied_symbols or set()
+    if not desired_name:
+        desired_name = "__sym"
+    elif isinstance(desired_name, tuple):
+        fun, arg_idx = desired_name
+        if isinstance(fun, ir.Lambda):
+            desired_name = fun.params[arg_idx].id
+        else:
+            desired_name = f"__arg{arg_idx}"
+
+    new_symbol = ir.Sym(id=desired_name)
+    # make unique
+    while new_symbol.id in occupied_names or new_symbol in occupied_symbols:
+        new_symbol = ir.Sym(id=new_symbol.id + "_")
+    return new_symbol
+
+
+def _is_lift(node: ir.Node):
+    return (
+        isinstance(node, ir.FunCall)
+        and isinstance(node.fun, ir.FunCall)
+        and node.fun.fun == ir.SymRef(id="lift")
+    )
+
+
+def _is_shift_lift(node: ir.Expr):
+    return (
+        isinstance(node, ir.FunCall)
+        and isinstance(node.fun, ir.FunCall)
+        and node.fun.fun == ir.SymRef(id="shift")
+        and isinstance(node.args[0], ir.FunCall)
+        and isinstance(node.args[0].fun, ir.FunCall)
+        and node.args[0].fun.fun == ir.SymRef(id="lift")
+    )
+
+
+def _is_scan(node: ir.FunCall):
+    return node.fun == ir.SymRef(id="scan")
+
+
+def _transform_and_extract_lift_args(
+    node: ir.FunCall,
+    symtable: dict[eve.SymbolName, ir.Sym],
+    extracted_args: dict[ir.Sym, ir.Expr],
+):
+    """
+    Transform and extract non-symbol arguments of a lifted stencil call.
+
+    E.g. ``lift(lambda a: ...)(sym1, expr1)`` is transformed into
+    ``lift(lambda a: ...)(sym1, sym2)`` with the extracted arguments
+    being ``{sym1: sym1, sym2: expr1}``.
+    """
+    assert _is_lift(node)
+    inner_stencil = node.fun.args[0]
+
+    new_args = []
+    for i, arg in enumerate(node.args):
+        if isinstance(arg, ir.SymRef):
+            sym = ir.Sym(id=arg.id)
+            assert sym not in extracted_args or extracted_args[sym] == arg
+            extracted_args[sym] = arg
+            new_args.append(arg)
+        else:
+            new_symbol = _generate_unique_symbol(
+                desired_name=(inner_stencil, i),
+                occupied_names=symtable.keys(),
+                occupied_symbols=extracted_args.keys(),
+            )
+            assert new_symbol not in extracted_args
+            extracted_args[new_symbol] = arg
+            new_args.append(ir.SymRef(id=new_symbol.id))
+
+    return (im.lift_(inner_stencil)(*new_args), extracted_args)
+
+
+@dataclasses.dataclass
+class InlineLifts(traits.VisitorWithSymbolTableTrait, NodeTranslator):
     """Inline lifted function calls.
 
     Optionally a predicate function can be passed which can enable or disable inlining of specific function nodes.
     """
 
-    def __init__(self, predicate: Optional[Callable[[ir.Expr], bool]] = None) -> None:
+    def __init__(self, predicate: Optional[Callable[[ir.Expr, bool], bool]] = None) -> None:
         super().__init__()
         if predicate is None:
-            self.predicate = lambda x: True
+            self.predicate = lambda _1, _2: True
         else:
             self.predicate = predicate
 
-    @staticmethod
-    def _is_lift(node: ir.Node):
-        return (
-            isinstance(node, ir.FunCall)
-            and isinstance(node.fun, ir.FunCall)
-            and node.fun.fun == ir.SymRef(id="lift")
-        )
+    def visit_FunCall(
+        self, node: ir.FunCall, *, is_scan_pass_context=False, recurse=True, **kwargs
+    ):
+        symtable = kwargs["symtable"]
 
-    @staticmethod
-    def _is_shift_lift(node: ir.Expr):
-        return (
-            isinstance(node, ir.FunCall)
-            and isinstance(node.fun, ir.FunCall)
-            and node.fun.fun == ir.SymRef(id="shift")
-            and isinstance(node.args[0], ir.FunCall)
-            and isinstance(node.args[0].fun, ir.FunCall)
-            and node.args[0].fun.fun == ir.SymRef(id="lift")
+        node = (
+            ir.FunCall(
+                fun=self.generic_visit(node.fun, is_scan_pass_context=_is_scan(node), **kwargs),
+                args=self.generic_visit(node.args, **kwargs),
+            )
+            if recurse
+            else node
         )
 
-    def visit_FunCall(self, node: ir.FunCall):
-        if node.fun == ir.SymRef(id="deref"):
+        if _is_shift_lift(node):
+            # shift(...)(lift(f)(args...)) -> lift(f)(shift(...)(args)...)
+            shift = node.fun
+            assert len(node.args) == 1
+            lift_call = node.args[0]
+            new_args = [
+                self.visit(ir.FunCall(fun=shift, args=[arg]), recurse=False, **kwargs)
+                for arg in lift_call.args
+            ]
+            result = ir.FunCall(fun=lift_call.fun, args=new_args)
+            return self.visit(result, recurse=False, **kwargs)
+        elif node.fun == ir.SymRef(id="deref"):
             assert len(node.args) == 1
-            if self._is_lift(node.args[0]) and self.predicate(node.args[0].fun):  # type: ignore[attr-defined]
+            if _is_lift(node.args[0]) and self.predicate(node.args[0], is_scan_pass_context):  # type: ignore[attr-defined]
                 # deref(lift(f)(args...)) -> f(args...)
                 assert isinstance(node.args[0], ir.FunCall)
                 assert isinstance(node.args[0].fun, ir.FunCall)
                 assert len(node.args[0].fun.args) == 1
-                f = self.visit(node.args[0].fun.args[0])
-                args = self.visit(node.args[0].args)
-                return ir.FunCall(fun=f, args=args)
-            elif self._is_shift_lift(node.args[0]) and self.predicate(node.args[0].args[0].fun):  # type: ignore[attr-defined]
-                # deref(shift(...)(lift(f)(args...)) -> f(shift(...)(args)...)
-                assert isinstance(node.args[0], ir.FunCall)
-                assert isinstance(node.args[0].args[0], ir.FunCall)
-                assert isinstance(node.args[0].args[0].fun, ir.FunCall)
-                f = self.visit(node.args[0].args[0].fun.args[0])
-                shift = self.visit(node.args[0].fun)
-                args = self.visit(node.args[0].args[0].args)
-                return ir.FunCall(fun=f, args=[ir.FunCall(fun=shift, args=[arg]) for arg in args])
-        if node.fun == ir.SymRef(id="can_deref"):
-            # TODO(havogt): this `can_deref` transformation doesn't look into lifted functions, this need to be changed to be 100% compliant
+                f = node.args[0].fun.args[0]
+                args = node.args[0].args
+                new_node = ir.FunCall(fun=f, args=args)
+                if isinstance(f, ir.Lambda):
+                    new_node = inline_lambda(new_node, opcount_preserving=True)
+                return self.visit(new_node, **kwargs)
+        elif node.fun == ir.SymRef(id="can_deref"):
+            # TODO(havogt): this `can_deref` transformation doesn't look into lifted functions,
+            #  this need to be changed to be 100% compliant
             assert len(node.args) == 1
-            if self._is_lift(node.args[0]) and self.predicate(node.args[0].fun):  # type: ignore[attr-defined]
+            if _is_lift(node.args[0]) and self.predicate(node.args[0], is_scan_pass_context):  # type: ignore[attr-defined]
                 # can_deref(lift(f)(args...)) -> and(can_deref(arg[0]), and(can_deref(arg[1]), ...))
                 assert isinstance(node.args[0], ir.FunCall)
                 assert isinstance(node.args[0].fun, ir.FunCall)
                 assert len(node.args[0].fun.args) == 1
-                args = self.visit(node.args[0].args)
+                args = node.args[0].args
+                if len(args) == 0:
+                    return ir.Literal(value="True", type="bool")
+
                 res = ir.FunCall(fun=ir.SymRef(id="can_deref"), args=[args[0]])
                 for arg in args[1:]:
                     res = ir.FunCall(
@@ -73,27 +162,53 @@ class InlineLifts(NodeTranslator):
                         args=[res, ir.FunCall(fun=ir.SymRef(id="can_deref"), args=[arg])],
                     )
                 return res
-            elif self._is_shift_lift(node.args[0]) and self.predicate(node.args[0].args[0].fun):  # type: ignore[attr-defined]
-                # can_deref(shift(...)(lift(f)(args...)) -> and(can_deref(shift(...)(arg[0])), and(can_deref(shift(...)(arg[1])), ...))
-                assert isinstance(node.args[0], ir.FunCall)
-                assert isinstance(node.args[0].args[0], ir.FunCall)
-                shift = self.visit(node.args[0].fun)
-                args = self.visit(node.args[0].args[0].args)
-                res = ir.FunCall(
-                    fun=ir.SymRef(id="can_deref"),
-                    args=[ir.FunCall(fun=shift, args=[args[0]])],
+        elif _is_lift(node) and len(node.args) > 0 and self.predicate(node, is_scan_pass_context):
+            # Inline arguments to lifted stencil calls, e.g.:
+            #  lift(λ(a) → inner_ex(a))(lift(λ(b) → outer_ex(b))(arg))
+            # is transformed into:
+            #  lift(λ(b) → inner_ex(outer_ex(b)))(arg)
+            #  lift(λ(a) → inner_ex(shift(...)(a)))(lift(λ(b) → outer_ex(b))(arg))
+            # Note: This branch is only needed when there is no outer `deref` by which the previous
+            # branches eliminate the lift calls. This occurs for example for the `reduce` builtin
+            # or when a better readible expression of a lift statement is needed during debugging.
+            # Due to it's complexity we might want to remove this branch at some point again,
+            # when we see that it is not required.
+            stencil = node.fun.args[0]
+            eligible_lifted_args = [
+                _is_lift(arg) and self.predicate(arg, is_scan_pass_context) for arg in node.args
+            ]
+
+            if isinstance(stencil, ir.Lambda) and any(eligible_lifted_args):
+                # TODO(tehrengruber): we currently only inlining opcount preserving, but what we
+                #  actually want is to inline whenever the argument is not shifted. This is
+                #  currently beyond the capabilities of the inliner and the shift tracer.
+                new_args = {}
+                inlined_args = []
+                for i, (arg, eligible) in enumerate(zip(node.args, eligible_lifted_args)):
+                    if eligible:
+                        inlined_arg, _ = _transform_and_extract_lift_args(arg, symtable, new_args)
+                        inlined_args.append(inlined_arg)
+                    else:
+                        if isinstance(arg, ir.SymRef):
+                            new_arg_sym = ir.Sym(id=arg.id)
+                        else:
+                            new_arg_sym = _generate_unique_symbol(
+                                desired_name=(stencil, i),
+                                occupied_names=symtable.keys(),
+                                occupied_symbols=new_args.keys(),
+                            )
+
+                        new_args[new_arg_sym] = arg
+                        inlined_args.append(ir.SymRef(id=new_arg_sym.id))
+
+                inlined_call = self.visit(
+                    inline_lambda(
+                        ir.FunCall(fun=stencil, args=inlined_args), opcount_preserving=True
+                    ),
+                    **kwargs,
                 )
-                for arg in args[1:]:
-                    res = ir.FunCall(
-                        fun=ir.SymRef(id="and_"),
-                        args=[
-                            res,
-                            ir.FunCall(
-                                fun=ir.SymRef(id="can_deref"),
-                                args=[ir.FunCall(fun=shift, args=[arg])],
-                            ),
-                        ],
-                    )
-                return res
 
-        return self.generic_visit(node)
+                new_stencil = im.lambda__(*new_args.keys())(inlined_call)
+                return im.lift_(new_stencil)(*new_args.values())
+
+        return node
diff --git a/src/functional/iterator/transforms/merge_let.py b/src/functional/iterator/transforms/merge_let.py
new file mode 100644
index 00000000..1068e527
--- /dev/null
+++ b/src/functional/iterator/transforms/merge_let.py
@@ -0,0 +1,54 @@
+import eve
+from functional.iterator import ir as itir
+from functional.iterator.transforms.count_symbol_refs import CountSymbolRefs
+
+
+class MergeLet(eve.NodeTranslator):
+    """
+    Merge let-like statements.
+
+    For example transforms::
+
+        (λ(a) → (λ(b) → a+b)(arg1))(arg2)
+
+    into::
+
+        (λ(a, b) → a+b)(arg1, arg2)
+
+    This can significantly reduce the depth of the tree and its readability.
+    """
+
+    def visit_FunCall(self, node: itir.FunCall):
+        node = self.generic_visit(node)
+        if (
+            isinstance(node.fun, itir.Lambda)
+            and isinstance(node.fun.expr, itir.FunCall)
+            and isinstance(node.fun.expr.fun, itir.Lambda)
+        ):
+            outer_lambda = node.fun
+            outer_lambda_args = node.args
+            inner_lambda = node.fun.expr.fun
+            inner_lambda_args = node.fun.expr.args
+            # skip if we have a collision
+            if set(outer_lambda.params) & set(inner_lambda.params):
+                return node
+            # check if the arguments to the outer lambda call use a symbol of the inner lambda
+            # e.g. (λ(a) → (λ(b) → b)(a))(b)
+            ref_counts_outer = CountSymbolRefs.apply(
+                outer_lambda_args, [param.id for param in inner_lambda.params]
+            )
+            if any(ref_count != 0 for ref_count in ref_counts_outer.values()):
+                return node
+            # check if the argument to the inner lambda call depend on an argument to the outer lambda
+            ref_counts = CountSymbolRefs.apply(
+                inner_lambda_args, [param.id for param in outer_lambda.params]
+            )
+            if any(ref_count != 0 for ref_count in ref_counts.values()):
+                return node
+            return itir.FunCall(
+                fun=itir.Lambda(
+                    params=outer_lambda.params + inner_lambda.params, expr=inner_lambda.expr
+                ),
+                args=outer_lambda_args + inner_lambda_args,
+            )
+        return node
diff --git a/src/functional/iterator/transforms/pass_manager.py b/src/functional/iterator/transforms/pass_manager.py
index 5c3b3577..e464103c 100644
--- a/src/functional/iterator/transforms/pass_manager.py
+++ b/src/functional/iterator/transforms/pass_manager.py
@@ -1,14 +1,15 @@
 import enum
 
 from functional.iterator import ir
+from functional.iterator.transforms import simple_inline_heuristic
 from functional.iterator.transforms.cse import CommonSubexpressionElimination
 from functional.iterator.transforms.eta_reduction import EtaReduction
 from functional.iterator.transforms.global_tmps import CreateGlobalTmps
 from functional.iterator.transforms.inline_fundefs import InlineFundefs, PruneUnreferencedFundefs
 from functional.iterator.transforms.inline_lambdas import InlineLambdas
 from functional.iterator.transforms.inline_lifts import InlineLifts
+from functional.iterator.transforms.merge_let import MergeLet
 from functional.iterator.transforms.normalize_shifts import NormalizeShifts
-from functional.iterator.transforms.simple_inline_heuristic import heuristic
 from functional.iterator.transforms.unroll_reduce import UnrollReduce
 
 
@@ -23,8 +24,7 @@ def _inline_lifts(ir, lift_mode):
     if lift_mode == LiftMode.FORCE_INLINE:
         return InlineLifts().visit(ir)
     if lift_mode == LiftMode.SIMPLE_HEURISTIC:
-        predicate = heuristic(ir)
-        return InlineLifts(predicate).visit(ir)
+        return InlineLifts(simple_inline_heuristic.is_eligible_for_inlining).visit(ir)
     assert lift_mode == LiftMode.FORCE_TEMPORARIES
     return ir
 
@@ -36,24 +36,32 @@ def apply_common_transforms(
     offset_provider=None,
     unroll_reduce=False,
     common_subexpression_elimination=True,
+    force_inline_lift=False,
 ):
     if lift_mode is None:
         lift_mode = LiftMode.FORCE_INLINE
     assert isinstance(lift_mode, LiftMode)
+    ir = MergeLet().visit(ir)
     ir = InlineFundefs().visit(ir)
     ir = PruneUnreferencedFundefs().visit(ir)
     ir = NormalizeShifts().visit(ir)
     if lift_mode != LiftMode.FORCE_TEMPORARIES:
         for _ in range(10):
             inlined = _inline_lifts(ir, lift_mode)
-            inlined = InlineLambdas.apply(inlined)
+            inlined = InlineLambdas.apply(
+                inlined,
+                opcount_preserving=True,
+                force_inline_lift=(lift_mode == LiftMode.FORCE_INLINE),
+            )
             if inlined == ir:
                 break
             ir = inlined
         else:
             raise RuntimeError("Inlining lift and lambdas did not converge.")
     else:
-        ir = InlineLambdas.apply(ir)
+        ir = InlineLambdas.apply(
+            ir, opcount_preserving=True, force_inline_lift=(lift_mode == LiftMode.FORCE_INLINE)
+        )
 
     ir = NormalizeShifts().visit(ir)
 
@@ -77,7 +85,8 @@ def apply_common_transforms(
 
     if common_subexpression_elimination:
         ir = CommonSubexpressionElimination().visit(ir)
+        ir = MergeLet().visit(ir)
 
-    ir = InlineLambdas.apply(ir, opcount_preserving=common_subexpression_elimination)
+    ir = InlineLambdas.apply(ir, opcount_preserving=True)
 
     return ir
diff --git a/src/functional/iterator/transforms/popup_tmps.py b/src/functional/iterator/transforms/popup_tmps.py
index 83dddef5..5082bcf7 100644
--- a/src/functional/iterator/transforms/popup_tmps.py
+++ b/src/functional/iterator/transforms/popup_tmps.py
@@ -64,6 +64,7 @@ class PopupTmps(NodeTranslator):
                 fun = fun.args[0]
                 params = fun.params[1:]
             else:
+                assert isinstance(fun, ir.Lambda)
                 params = fun.params
 
             def wrap(fun: ir.Lambda, args: list[ir.Expr]) -> ir.FunCall:
diff --git a/src/functional/iterator/transforms/simple_inline_heuristic.py b/src/functional/iterator/transforms/simple_inline_heuristic.py
index 08cfa316..05d105e2 100644
--- a/src/functional/iterator/transforms/simple_inline_heuristic.py
+++ b/src/functional/iterator/transforms/simple_inline_heuristic.py
@@ -1,33 +1,18 @@
-from collections.abc import Callable
-
-from eve import NodeVisitor
 from functional.iterator import ir
+from functional.iterator.transforms.inline_lifts import _is_lift
 
 
-class AnalyzeLifts(NodeVisitor):
-    def visit_FunCall(
-        self, node: ir.FunCall, *, inline: dict[int, bool], force_noinline: bool = False
-    ) -> None:
-        if node.fun == ir.SymRef(id="lift"):
-            (arg,) = node.args
-            if isinstance(arg, ir.FunCall) and arg.fun == ir.SymRef(id="scan"):
-                inline[id(node)] = False
-                return self.generic_visit(node, inline=inline, force_noinline=True)
-            else:
-                inline[id(node)] = not force_noinline
-                return self.generic_visit(node, inline=inline, force_noinline=False)
-        elif (
-            isinstance(node.fun, ir.FunCall)
-            and node.fun.fun == ir.SymRef(id="lift")
-            and isinstance(node.fun.args[0], ir.FunCall)
-            and node.fun.args[0].fun == ir.SymRef(id="scan")
-        ):
-            return self.generic_visit(node, inline=inline, force_noinline=True)
-        self.generic_visit(node, inline=inline, force_noinline=force_noinline)
+def is_eligible_for_inlining(node: ir.FunCall, is_scan_pass_context: bool) -> bool:
+    """
+    Predicate for the InlineLifts transformation.
 
+    Takes a lifted stencil call of the form `↑(f)(args...)` and returns whether the expression
+    is eligible for inlining. For example `·↑(f)(args...)` would be transformed into
+    `f(args...)` if true.
 
-def heuristic(node: ir.Node) -> Callable[[ir.Node], bool]:
-    """Create a predicate for the InlineLifts transformation.
+    The ``is_scan_pass_context`` argument indicates if the given node is within a scan (unnested),
+    e.g. `↑(f)(args...)` should not be inlined if it appears in a scan like this:
+    `↑(scan(λ(acc, args...) → acc + ·↑(f)(args...)))(...)`
 
     Follows the simple rules:
     - Don’t inline scans (as there is no efficient way to inline them, also required by some
@@ -35,10 +20,14 @@ def heuristic(node: ir.Node) -> Callable[[ir.Node], bool]:
     - Don’t inline the first lifted function call within a scan (otherwise, all stencils would get
       inlined into the scans, leading to reduced parallelism/scan-only computation)
     """
-    inline = dict[int, bool]()
-    AnalyzeLifts().visit(node, inline=inline)
+    assert _is_lift(node)
 
-    def predicate(node: ir.Node) -> bool:
-        return inline[id(node)]
+    (stencil,) = node.fun.args
+    # Don’t inline scans, i.e. exclude `↑(scan(...))(...)`
+    if isinstance(stencil, ir.FunCall) and stencil.fun == ir.SymRef(id="scan"):
+        return False
 
-    return predicate
+    # Don’t inline the first lifted function call within a scan, e.g. if the node given here
+    # is `↑(f)(args...)` and appears in a scan pass `scan(λ(acc, args...) → acc + ·↑(f)(args...))`
+    # it should not be inlined.
+    return not is_scan_pass_context
diff --git a/src/functional/iterator/transforms/unroll_reduce.py b/src/functional/iterator/transforms/unroll_reduce.py
index f0288af3..877fc1f3 100644
--- a/src/functional/iterator/transforms/unroll_reduce.py
+++ b/src/functional/iterator/transforms/unroll_reduce.py
@@ -17,7 +17,7 @@ class UnrollReduce(NodeTranslator):
         return cls().visit(node, **kwargs)
 
     @staticmethod
-    def _find_connectivity(reduce_args: Iterable[ir.Expr], offset_provider):
+    def _find_connectivities(reduce_args: Iterable[ir.Expr], offset_provider):
         connectivities = []
         for arg in reduce_args:
             if (
@@ -27,6 +27,19 @@ class UnrollReduce(NodeTranslator):
             ):
                 assert isinstance(arg.fun.args[-1], ir.OffsetLiteral), f"{arg.fun.args}"
                 connectivities.append(offset_provider[arg.fun.args[-1].value])
+            # if the argument is a lift call by itself look at its arguments recursively
+            elif (
+                isinstance(arg, ir.FunCall)
+                and isinstance(arg.fun, ir.FunCall)
+                and arg.fun.fun == ir.SymRef(id="lift")
+            ):
+                connectivities.extend(UnrollReduce._find_connectivities(arg.args, offset_provider))
+
+        return connectivities
+
+    @staticmethod
+    def _find_connectivity(reduce_args: Iterable[ir.Expr], offset_provider):
+        connectivities = UnrollReduce._find_connectivities(reduce_args, offset_provider)
 
         if not connectivities:
             raise RuntimeError("Couldn't detect partial shift in any arguments of reduce.")
diff --git a/src/functional/iterator/type_inference.py b/src/functional/iterator/type_inference.py
index e870f21b..9942fd0d 100644
--- a/src/functional/iterator/type_inference.py
+++ b/src/functional/iterator/type_inference.py
@@ -223,6 +223,7 @@ BUILTIN_TYPES: typing.Final[dict[str, Type]] = {
         ret=Val_BOOL_T1,
     ),
     "if_": FunctionType(args=Tuple.from_elems(Val_BOOL_T1, Val_T0_T1, Val_T0_T1), ret=Val_T0_T1),
+    "cast_": FunctionType(args=Tuple.from_elems(Val_T0_T1, Val_T0_T1), ret=Val_T0_T1),
     "lift": FunctionType(
         args=Tuple.from_elems(
             FunctionType(args=ValTuple(kind=Iterator(), dtypes=T2, size=T1), ret=Val_T0_T1)
@@ -292,7 +293,7 @@ class _TypeInferrer(eve.NodeTranslator):
             raise TypeError(
                 f"Builtin '{node.id}' is only supported as applied/called function by the type checker"
             )
-        if node.id in ir.BUILTINS:
+        if node.id in (ir.BUILTINS - ir.TYPEBUILTINS):
             raise NotImplementedError(f"Missing type definition for builtin '{node.id}'")
 
         return TypeVar.fresh()
diff --git a/src/functional/otf/compilation/build_systems/cmake_lists.py b/src/functional/otf/compilation/build_systems/cmake_lists.py
index d79277ac..2b5fbdff 100644
--- a/src/functional/otf/compilation/build_systems/cmake_lists.py
+++ b/src/functional/otf/compilation/build_systems/cmake_lists.py
@@ -12,7 +12,6 @@
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
 
-import textwrap
 from typing import Sequence
 
 import eve
@@ -84,15 +83,9 @@ class CMakeListsGenerator(eve.codegen.TemplatedGenerator):
 
                 return f"find_package(pybind11 CONFIG REQUIRED PATHS {pybind11.get_cmake_dir()})"
             case "gridtools":
-                return textwrap.dedent(
-                    """\
-                    FetchContent_Declare(GridTools
-                        GIT_REPOSITORY https://github.com/GridTools/gridtools.git
-                        GIT_TAG        master
-                    )
-                    FetchContent_MakeAvailable(GridTools)\
-                    """
-                )
+                import gridtools_cpp  # type: ignore
+
+                return f"find_package(GridTools REQUIRED PATHS {gridtools_cpp.get_cmake_dir()})"
             case _:
                 raise ValueError("Library {name} is not supported".format(name=dep.name))
 
diff --git a/src/functional/otf/compilation/build_systems/compiledb.py b/src/functional/otf/compilation/build_systems/compiledb.py
index 9384350e..7a490926 100644
--- a/src/functional/otf/compilation/build_systems/compiledb.py
+++ b/src/functional/otf/compilation/build_systems/compiledb.py
@@ -164,16 +164,21 @@ class CompiledbProject(
         logfile = self.root_path / "log_build.txt"
         compile_db = json.loads((self.root_path / "compile_commands.json").read_text())
         assert compile_db
-        with logfile.open(mode="w") as log_file_pointer:
-            for entry in compile_db:
-                log_file_pointer.write(entry["command"] + "\n")
-                subprocess.check_call(
-                    entry["command"],
-                    cwd=self.root_path,
-                    shell=True,
-                    stdout=log_file_pointer,
-                    stderr=log_file_pointer,
-                )
+        try:
+            with logfile.open(mode="w") as log_file_pointer:
+                for entry in compile_db:
+                    log_file_pointer.write(entry["command"] + "\n")
+                    subprocess.check_call(
+                        entry["command"],
+                        cwd=self.root_path,
+                        shell=True,
+                        stdout=log_file_pointer,
+                        stderr=log_file_pointer,
+                    )
+        except subprocess.CalledProcessError as e:
+            with logfile.open(mode="r") as log_file_pointer:
+                print(log_file_pointer.read())
+            raise e
 
         build_data.update_status(new_status=build_data.BuildStatus.COMPILED, path=self.root_path)
 
diff --git a/src/functional/program_processors/codegens/gtfn/codegen.py b/src/functional/program_processors/codegens/gtfn/codegen.py
index 03bd26f4..cd31d10c 100644
--- a/src/functional/program_processors/codegens/gtfn/codegen.py
+++ b/src/functional/program_processors/codegens/gtfn/codegen.py
@@ -57,6 +57,13 @@ class GTFNCodegen(codegen.TemplatedGenerator):
         "maximum": "std::max",
         "fmod": "std::fmod",
         "power": "std::pow",
+        "float": "double",
+        "float32": "float",
+        "float64": "double",
+        "int": "long",
+        "int32": "std::int32_t",
+        "int64": "std::int64_t",
+        "bool": "bool",
     }
 
     Sym = as_fmt("{id}")
@@ -64,6 +71,12 @@ class GTFNCodegen(codegen.TemplatedGenerator):
     def visit_SymRef(self, node: gtfn_ir.SymRef, **kwargs: Any) -> str:
         if node.id == "get":
             return "::gridtools::tuple_util::get"
+        if node.id in self._builtins_mapping:
+            return self._builtins_mapping[node.id]
+        if node.id in gtfn_ir.GTFN_BUILTINS:
+            qualified_fun_name = f"gtfn::{node.id}"
+            return qualified_fun_name
+
         return node.id
 
     @staticmethod
@@ -88,6 +101,7 @@ class GTFNCodegen(codegen.TemplatedGenerator):
     UnaryExpr = as_fmt("{op}({expr})")
     BinaryExpr = as_fmt("({lhs}{op}{rhs})")
     TernaryExpr = as_fmt("({cond}?{true_expr}:{false_expr})")
+    CastExpr = as_fmt("static_cast<{new_dtype}>({obj_expr})")
 
     def visit_TaggedValues(self, node: gtfn_ir.TaggedValues, **kwargs):
         tags = self.visit(node.tags)
@@ -110,11 +124,6 @@ class GTFNCodegen(codegen.TemplatedGenerator):
     )
 
     def visit_FunCall(self, node: gtfn_ir.FunCall, **kwargs):
-        if isinstance(node.fun, gtfn_ir.SymRef) and node.fun.id in self._builtins_mapping:
-            return self.generic_visit(node, fun_name=self._builtins_mapping[node.fun.id])
-        if isinstance(node.fun, gtfn_ir.SymRef) and node.fun.id in gtfn_ir.GTFN_BUILTINS:
-            qualified_fun_name = f"gtfn::{node.fun.id}"
-            return self.generic_visit(node, fun_name=qualified_fun_name)
         return self.generic_visit(node, fun_name=self.visit(node.fun))
 
     FunCall = as_fmt("{fun_name}({','.join(args)})")
@@ -192,6 +201,7 @@ class GTFNCodegen(codegen.TemplatedGenerator):
     FencilDefinition = as_mako(
         """
     #include <cmath>
+    #include <cstdint>
     #include <gridtools/fn/${grid_type_str}.hpp>
 
     namespace generated{
diff --git a/src/functional/program_processors/codegens/gtfn/gtfn_backend.py b/src/functional/program_processors/codegens/gtfn/gtfn_backend.py
index 9f90f445..a5b2e092 100644
--- a/src/functional/program_processors/codegens/gtfn/gtfn_backend.py
+++ b/src/functional/program_processors/codegens/gtfn/gtfn_backend.py
@@ -29,6 +29,7 @@ def generate(program: itir.FencilDefinition, **kwargs: Any) -> str:
         program,
         lift_mode=kwargs.get("lift_mode"),
         offset_provider=offset_provider,
+        # required since backend has no `reduce` builtin
         unroll_reduce=True,
     )
     gtfn_ir = GTFN_lowering.apply(
diff --git a/src/functional/program_processors/codegens/gtfn/gtfn_ir.py b/src/functional/program_processors/codegens/gtfn/gtfn_ir.py
index f39f2851..c6402bd6 100644
--- a/src/functional/program_processors/codegens/gtfn/gtfn_ir.py
+++ b/src/functional/program_processors/codegens/gtfn/gtfn_ir.py
@@ -53,6 +53,11 @@ class TernaryExpr(Expr):
     false_expr: Expr
 
 
+class CastExpr(Expr):
+    obj_expr: Expr
+    new_dtype: SymRef
+
+
 class Literal(Expr):
     value: str
     type: str  # noqa: A003
@@ -154,6 +159,7 @@ UNARY_MATH_NUMBER_BUILTINS = itir.UNARY_MATH_NUMBER_BUILTINS
 UNARY_MATH_FP_BUILTINS = itir.UNARY_MATH_FP_BUILTINS
 UNARY_MATH_FP_PREDICATE_BUILTINS = itir.UNARY_MATH_FP_PREDICATE_BUILTINS
 BINARY_MATH_NUMBER_BUILTINS = itir.BINARY_MATH_NUMBER_BUILTINS
+TYPEBUILTINS = itir.TYPEBUILTINS
 
 BUILTINS = {
     *GTFN_BUILTINS,
@@ -161,6 +167,7 @@ BUILTINS = {
     *UNARY_MATH_FP_BUILTINS,
     *UNARY_MATH_FP_PREDICATE_BUILTINS,
     *BINARY_MATH_NUMBER_BUILTINS,
+    *TYPEBUILTINS,
 }
 
 
diff --git a/src/functional/program_processors/codegens/gtfn/itir_to_gtfn_ir.py b/src/functional/program_processors/codegens/gtfn/itir_to_gtfn_ir.py
index a292357f..143d329d 100644
--- a/src/functional/program_processors/codegens/gtfn/itir_to_gtfn_ir.py
+++ b/src/functional/program_processors/codegens/gtfn/itir_to_gtfn_ir.py
@@ -24,6 +24,7 @@ from functional.program_processors.codegens.gtfn.gtfn_ir import (
     Backend,
     BinaryExpr,
     CartesianDomain,
+    CastExpr,
     Expr,
     FencilDefinition,
     FunCall,
@@ -188,6 +189,7 @@ class GTFN_lowering(eve.NodeTranslator, eve.VisitorWithSymbolTableTrait):
         "and_": "&&",
         "or_": "||",
         "xor_": "^",
+        "mod": "%",
     }
     _unary_op_map: ClassVar[dict[str, str]] = {"not_": "!"}
 
@@ -337,6 +339,12 @@ class GTFN_lowering(eve.NodeTranslator, eve.VisitorWithSymbolTableTrait):
             )
         return result
 
+    def _error_on_illegal_function_calls(self, node: itir.FunCall) -> None:
+        if node.fun.id == "shift":
+            raise ValueError("unapplied shift call not supported: {node}")
+        elif node.fun.id == "scan":
+            raise ValueError("scans are only supported at the top level of a stencil closure")
+
     def visit_FunCall(self, node: itir.FunCall, **kwargs: Any) -> Node:
         if isinstance(node.fun, itir.SymRef):
             if node.fun.id in self._unary_op_map:
@@ -358,13 +366,16 @@ class GTFN_lowering(eve.NodeTranslator, eve.VisitorWithSymbolTableTrait):
                     true_expr=self.visit(node.args[1], **kwargs),
                     false_expr=self.visit(node.args[2], **kwargs),
                 )
+            elif node.fun.id == "cast_":
+                assert len(node.args) == 2
+                return CastExpr(
+                    obj_expr=self.visit(node.args[0], **kwargs),
+                    new_dtype=self.visit(node.args[1], **kwargs),
+                )
             elif self._is_sparse_deref_shift(node):
                 return self._sparse_deref_shift_to_tuple_get(node)
-            elif node.fun.id == "shift":
-                raise ValueError("unapplied shift call not supported: {node}")
-            elif node.fun.id == "scan":
-                raise ValueError("scans are only supported at the top level of a stencil closure")
-            elif node.fun.id == "cartesian_domain":
+            self._error_on_illegal_function_calls(node)
+            if node.fun.id == "cartesian_domain":
                 sizes, domain_offsets = self._make_domain(node)
                 return CartesianDomain(tagged_sizes=sizes, tagged_offsets=domain_offsets)
             elif node.fun.id == "unstructured_domain":
@@ -384,14 +395,15 @@ class GTFN_lowering(eve.NodeTranslator, eve.VisitorWithSymbolTableTrait):
                     tagged_offsets=domain_offsets,
                     connectivities=connectivities,
                 )
-        elif isinstance(node.fun, itir.FunCall) and node.fun.fun == itir.SymRef(id="shift"):
-            assert len(node.args) == 1
-            return FunCall(
-                fun=self.visit(node.fun.fun, **kwargs),
-                args=self.visit(node.args, **kwargs) + self.visit(node.fun.args, **kwargs),
-            )
-        elif isinstance(node.fun, itir.FunCall) and node.fun == itir.SymRef(id="shift"):
-            raise ValueError("unapplied shift call not supported: {node}")
+        if isinstance(node.fun, itir.FunCall):
+            if node.fun.fun == itir.SymRef(id="shift"):
+                assert len(node.args) == 1
+                return FunCall(
+                    fun=self.visit(node.fun.fun, **kwargs),
+                    args=self.visit(node.args, **kwargs) + self.visit(node.fun.args, **kwargs),
+                )
+            elif node.fun == itir.SymRef(id="shift"):
+                raise ValueError("unapplied shift call not supported: {node}")
         return FunCall(fun=self.visit(node.fun, **kwargs), args=self.visit(node.args, **kwargs))
 
     def visit_FunctionDefinition(
diff --git a/tox.ini b/tox.ini
index 9d68598d..81679887 100644
--- a/tox.ini
+++ b/tox.ini
@@ -6,7 +6,7 @@ envlist =
     dev-quickstart-guide
 
 [testenv]
-passenv = BOOST_ROOT BOOST_HOME CUDA_HOME CUDA_PATH CXX CC OPENMP_CPPFLAGS OPENMP_LDFLAGS PIP_USER PYTHONUSERBASE
+passenv = BOOST_ROOT, BOOST_HOME, CUDA_HOME, CUDA_PATH, CXX, CC, OPENMP_CPPFLAGS, OPENMP_LDFLAGS, PIP_USER, PYTHONUSERBASE
 allowlist_externals =
     /bin/bash
     make


Test patch:
diff --git a/tests/functional_tests/ffront_tests/ffront_test_utils.py b/tests/functional_tests/ffront_tests/ffront_test_utils.py
new file mode 100644
index 00000000..3c747afc
--- /dev/null
+++ b/tests/functional_tests/ffront_tests/ffront_test_utils.py
@@ -0,0 +1,130 @@
+# -*- coding: utf-8 -*-
+#
+# GT4Py Project - GridTools Framework
+#
+# Copyright (c) 2014-2021, 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 collections import namedtuple
+from typing import TypeVar
+
+import numpy as np
+import pytest
+
+from functional.common import DimensionKind
+from functional.ffront.fbuiltins import Dimension, FieldOffset
+from functional.iterator.embedded import (
+    NeighborTableOffsetProvider,
+    index_field,
+    np_as_located_field,
+)
+from functional.program_processors.runners import gtfn_cpu, roundtrip
+
+
+@pytest.fixture(params=[roundtrip.executor, gtfn_cpu.run_gtfn])
+def fieldview_backend(request):
+    yield request.param
+
+
+def debug_itir(tree):
+    """Compare tree snippets while debugging."""
+    from devtools import debug
+
+    from eve.codegen import format_python_source
+    from functional.program_processors import EmbeddedDSL
+
+    debug(format_python_source(EmbeddedDSL.apply(tree)))
+
+
+DimsType = TypeVar("DimsType")
+DType = TypeVar("DType")
+
+IDim = Dimension("IDim")
+JDim = Dimension("JDim")
+KDim = Dimension("KDim", kind=DimensionKind.VERTICAL)
+Ioff = FieldOffset("Ioff", source=IDim, target=(IDim,))
+Joff = FieldOffset("Joff", source=JDim, target=(JDim,))
+Koff = FieldOffset("Koff", source=KDim, target=(KDim,))
+
+Vertex = Dimension("Vertex")
+Edge = Dimension("Edge")
+EdgeOffset = FieldOffset("EdgeOffset", source=Edge, target=(Edge,))
+
+size = 10
+
+
+@pytest.fixture
+def reduction_setup():
+    num_vertices = 9
+    edge = Dimension("Edge")
+    vertex = Dimension("Vertex")
+    v2edim = Dimension("V2E", kind=DimensionKind.LOCAL)
+    e2vdim = Dimension("E2V", kind=DimensionKind.LOCAL)
+
+    v2e_arr = np.array(
+        [
+            [0, 15, 2, 9],  # 0
+            [1, 16, 0, 10],
+            [2, 17, 1, 11],
+            [3, 9, 5, 12],  # 3
+            [4, 10, 3, 13],
+            [5, 11, 4, 14],
+            [6, 12, 8, 15],  # 6
+            [7, 13, 6, 16],
+            [8, 14, 7, 17],
+        ]
+    )
+
+    # create e2v connectivity by inverting v2e
+    num_edges = np.max(v2e_arr) + 1
+    e2v_arr = [[] for _ in range(0, num_edges)]
+    for v in range(0, v2e_arr.shape[0]):
+        for e in v2e_arr[v]:
+            e2v_arr[e].append(v)
+    assert all(len(row) == 2 for row in e2v_arr)
+    e2v_arr = np.asarray(e2v_arr)
+
+    yield namedtuple(
+        "ReductionSetup",
+        [
+            "num_vertices",
+            "num_edges",
+            "Edge",
+            "Vertex",
+            "V2EDim",
+            "E2VDim",
+            "V2E",
+            "E2V",
+            "inp",
+            "out",
+            "offset_provider",
+            "v2e_table",
+            "e2v_table",
+        ],
+    )(
+        num_vertices=num_vertices,
+        num_edges=num_edges,
+        Edge=edge,
+        Vertex=vertex,
+        V2EDim=v2edim,
+        E2VDim=e2vdim,
+        V2E=FieldOffset("V2E", source=edge, target=(vertex, v2edim)),
+        E2V=FieldOffset("E2V", source=vertex, target=(edge, e2vdim)),
+        inp=index_field(edge, dtype=np.int64),
+        out=np_as_located_field(vertex)(np.zeros([num_vertices], dtype=np.int64)),
+        offset_provider={
+            "V2E": NeighborTableOffsetProvider(v2e_arr, vertex, edge, 4),
+            "E2V": NeighborTableOffsetProvider(e2v_arr, edge, vertex, 2, has_skip_values=False),
+        },
+        v2e_table=v2e_arr,
+        e2v_table=e2v_arr,
+    )  # type: ignore
diff --git a/tests/functional_tests/ffront_tests/test_execution.py b/tests/functional_tests/ffront_tests/test_execution.py
index 5da4d656..d4d4ab8d 100644
--- a/tests/functional_tests/ffront_tests/test_execution.py
+++ b/tests/functional_tests/ffront_tests/test_execution.py
@@ -13,19 +13,16 @@
 # distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
-from collections import namedtuple
 from functools import reduce
-from typing import TypeVar
 
-import numpy as np
-import pytest
+import pytest as pytest
 
-from functional.common import DimensionKind
 from functional.ffront.decorator import field_operator, program, scan_operator
 from functional.ffront.fbuiltins import (
     Dimension,
     Field,
     FieldOffset,
+    astype,
     broadcast,
     float64,
     int32,
@@ -36,6 +33,7 @@ from functional.ffront.fbuiltins import (
     where,
 )
 from functional.ffront.foast_passes.type_deduction import FieldOperatorTypeDeductionError
+from functional.iterator.builtins import float32
 from functional.iterator.embedded import (
     NeighborTableOffsetProvider,
     index_field,
@@ -59,17 +57,12 @@ def debug_itir(tree):
     debug(format_python_source(EmbeddedDSL.apply(tree)))
 
 
-DimsType = TypeVar("DimsType")
-DType = TypeVar("DType")
-
-IDim = Dimension("IDim")
-JDim = Dimension("JDim")
+from .ffront_test_utils import *
 
 
 def test_copy(fieldview_backend):
-    size = 10
-    a = np_as_located_field(IDim)(np.ones((size)))
-    b = np_as_located_field(IDim)(np.zeros((size)))
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
 
     @field_operator(backend=fieldview_backend)
     def copy(inp: Field[[IDim], float64]) -> Field[[IDim], float64]:
@@ -78,18 +71,17 @@ def test_copy(fieldview_backend):
         field_1 = field_tuple[1]
         return field_0
 
-    copy(a, out=b, offset_provider={})
+    copy(a_I_float, out=b_I_float, offset_provider={})
 
-    assert np.allclose(a, b)
+    assert np.allclose(a_I_float, b_I_float)
 
 
 @pytest.mark.skip(reason="no lowering for returning a tuple of fields exists yet.")
 def test_multicopy(fieldview_backend):
-    size = 10
-    a = np_as_located_field(IDim)(np.ones((size)))
-    b = np_as_located_field(IDim)(np.ones((size)) * 3)
-    c = np_as_located_field(IDim)(np.zeros((size)))
-    d = np_as_located_field(IDim)(np.zeros((size)))
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.zeros((size), dtype=float64))
+    out_I_float_1 = np_as_located_field(IDim)(np.zeros((size), dtype=float64))
 
     @field_operator(backend=fieldview_backend)
     def multicopy(
@@ -97,102 +89,13 @@ def test_multicopy(fieldview_backend):
     ) -> tuple[Field[[IDim], float64], Field[[IDim], float64]]:
         return inp1, inp2
 
-    multicopy(a, b, out=(c, d), offset_provider={})
-
-    assert np.allclose(a, c)
-    assert np.allclose(b, d)
-
-
-def test_arithmetic(fieldview_backend):
-    size = 10
-    a = np_as_located_field(IDim)(np.ones((size)))
-    b = np_as_located_field(IDim)(np.ones((size)) * 2)
-    c = np_as_located_field(IDim)(np.zeros((size)))
-
-    @field_operator(backend=fieldview_backend)
-    def arithmetic(
-        inp1: Field[[IDim], float64], inp2: Field[[IDim], float64]
-    ) -> Field[[IDim], float64]:
-        return (inp1 + inp2) * 2.0
-
-    arithmetic(a, b, out=c, offset_provider={})
-
-    assert np.allclose((a.array() + b.array()) * 2.0, c)
-
-
-def test_power(fieldview_backend):
-    if fieldview_backend == gtfn_cpu.run_gtfn:
-        pytest.xfail("gtfn does not yet support math builtins")
-
-    size = 10
-    a = np_as_located_field(IDim)(np.random.randn((size)))
-    b = np_as_located_field(IDim)(np.zeros((size)))
-
-    @field_operator(backend=fieldview_backend)
-    def pow(inp1: Field[[IDim], float64]) -> Field[[IDim], float64]:
-        return inp1**2
-
-    pow(a, out=b, offset_provider={})
-
-    assert np.allclose(a.array() ** 2, b)
-
-
-def test_power_arithmetic(fieldview_backend):
-    if fieldview_backend == gtfn_cpu.run_gtfn:
-        pytest.xfail("gtfn does not yet support math builtins")
-
-    size = 10
-    a = np_as_located_field(IDim)(np.random.randn((size)))
-    b = np_as_located_field(IDim)(np.zeros((size)))
-    c = np_as_located_field(IDim)(np.random.randn((size)))
-
-    @field_operator(backend=fieldview_backend)
-    def power_arithmetic(
-        inp1: Field[[IDim], float64], inp2: Field[[IDim], float64]
-    ) -> Field[[IDim], float64]:
-        return inp2 + ((inp1 + inp2) ** 2)
-
-    power_arithmetic(a, c, out=b, offset_provider={})
-
-    assert np.allclose(c.array() + ((c.array() + a.array()) ** 2), b)
-
-
-def test_bit_logic(fieldview_backend):
-    size = 10
-    a = np_as_located_field(IDim)(np.full((size), True))
-    b_data = np.full((size), True)
-    b_data[5] = False
-    b = np_as_located_field(IDim)(b_data)
-    c = np_as_located_field(IDim)(np.full((size), False))
-
-    @field_operator(backend=fieldview_backend)
-    def bit_and(inp1: Field[[IDim], bool], inp2: Field[[IDim], bool]) -> Field[[IDim], bool]:
-        return inp1 & inp2 & True
-
-    bit_and(a, b, out=c, offset_provider={})
-
-    assert np.allclose(a.array() & b.array(), c)
-
-
-def test_unary_neg(fieldview_backend):
-    size = 10
-    a = np_as_located_field(IDim)(np.ones((size), dtype=int32))
-    b = np_as_located_field(IDim)(np.zeros((size), dtype=int32))
-
-    @field_operator(backend=fieldview_backend)
-    def uneg(inp: Field[[IDim], int32]) -> Field[[IDim], int32]:
-        return -inp
-
-    uneg(a, out=b, offset_provider={})
-
-    assert np.allclose(b, np.full((size), -1, dtype=int32))
+    assert np.allclose(a_I_float, out_I_float)
+    assert np.allclose(b_I_float, out_I_float_1)
 
 
 def test_shift(fieldview_backend):
-    size = 10
-    Ioff = FieldOffset("Ioff", source=IDim, target=(IDim,))
     a = np_as_located_field(IDim)(np.arange(size + 1, dtype=np.float64))
-    b = np_as_located_field(IDim)(np.zeros((size)))
+    out_I_float = np_as_located_field(IDim)(np.zeros((size), dtype=float64))
 
     @field_operator
     def shift_by_one(inp: Field[[IDim], float64]) -> Field[[IDim], float64]:
@@ -202,18 +105,16 @@ def test_shift(fieldview_backend):
     def fencil(inp: Field[[IDim], float64], out: Field[[IDim], float64]) -> None:
         shift_by_one(inp, out=out)
 
-    fencil(a, b, offset_provider={"Ioff": IDim})
+    fencil(a, out_I_float, offset_provider={"Ioff": IDim})
 
-    assert np.allclose(b.array(), np.arange(1, 11))
+    assert np.allclose(out_I_float.array(), np.arange(1, 11))
 
 
 def test_fold_shifts(fieldview_backend):
     """Shifting the result of an addition should work."""
-    size = 10
-    Ioff = FieldOffset("Ioff", source=IDim, target=(IDim,))
     a = np_as_located_field(IDim)(np.arange(size + 1, dtype=np.float64))
     b = np_as_located_field(IDim)(np.ones((size + 2)) * 2)
-    c = np_as_located_field(IDim)(np.zeros((size)))
+    out_I_float = np_as_located_field(IDim)(np.zeros((size), dtype=float64))
 
     @field_operator
     def auto_lift(
@@ -228,18 +129,18 @@ def test_fold_shifts(fieldview_backend):
     ) -> None:
         auto_lift(inp1, inp2, out=out)
 
-    fencil(a, b, c, offset_provider={"Ioff": IDim})
+    fencil(a, b, out_I_float, offset_provider={"Ioff": IDim})
 
-    assert np.allclose(a[1:] + b[2:], c)
+    assert np.allclose(a[1:] + b[2:], out_I_float)
 
 
 def test_tuples(fieldview_backend):
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.zeros((size), dtype=float64))
+
     if fieldview_backend == gtfn_cpu.run_gtfn:
         pytest.skip("Tuples are not supported yet.")
-    size = 10
-    a = np_as_located_field(IDim)(np.ones((size)))
-    b = np_as_located_field(IDim)(np.ones((size)) * 2)
-    c = np_as_located_field(IDim)(np.zeros((size)))
 
     @field_operator
     def tuples(
@@ -255,264 +156,16 @@ def test_tuples(fieldview_backend):
     ) -> None:
         tuples(inp1, inp2, out=out)
 
-    fencil(a, b, c, offset_provider={})
-
-    assert np.allclose((a.array() * 1.3 + b.array() * 5.0) * 3.4, c)
-
-
-def test_promotion(fieldview_backend):
-    Edge = Dimension("Edge")
-    K = Dimension("K")
-
-    size = 10
-    ksize = 5
-    a = np_as_located_field(Edge, K)(np.ones((size, ksize)))
-    b = np_as_located_field(K)(np.ones((ksize)) * 2)
-    c = np_as_located_field(Edge, K)(np.zeros((size, ksize)))
-
-    @field_operator(backend=fieldview_backend)
-    def promotion(
-        inp1: Field[[Edge, K], float64], inp2: Field[[K], float64]
-    ) -> Field[[Edge, K], float64]:
-        return inp1 / inp2
-
-    promotion(a, b, out=c, offset_provider={})
-
-    assert np.allclose((a.array() / b.array()), c)
-
-
-@pytest.fixture
-def reduction_setup():
-    num_vertices = 9
-    edge = Dimension("Edge")
-    vertex = Dimension("Vertex")
-    v2edim = Dimension("V2E", kind=DimensionKind.LOCAL)
-    e2vdim = Dimension("E2V", kind=DimensionKind.LOCAL)
-
-    v2e_arr = np.array(
-        [
-            [0, 15, 2, 9],  # 0
-            [1, 16, 0, 10],
-            [2, 17, 1, 11],
-            [3, 9, 5, 12],  # 3
-            [4, 10, 3, 13],
-            [5, 11, 4, 14],
-            [6, 12, 8, 15],  # 6
-            [7, 13, 6, 16],
-            [8, 14, 7, 17],
-        ]
-    )
-
-    # create e2v connectivity by inverting v2e
-    num_edges = np.max(v2e_arr) + 1
-    e2v_arr = [[] for _ in range(0, num_edges)]
-    for v in range(0, v2e_arr.shape[0]):
-        for e in v2e_arr[v]:
-            e2v_arr[e].append(v)
-    assert all(len(row) == 2 for row in e2v_arr)
-    e2v_arr = np.asarray(e2v_arr)
-
-    yield namedtuple(
-        "ReductionSetup",
-        [
-            "num_vertices",
-            "num_edges",
-            "Edge",
-            "Vertex",
-            "V2EDim",
-            "E2VDim",
-            "V2E",
-            "E2V",
-            "inp",
-            "out",
-            "offset_provider",
-            "v2e_table",
-            "e2v_table",
-        ],
-    )(
-        num_vertices=num_vertices,
-        num_edges=num_edges,
-        Edge=edge,
-        Vertex=vertex,
-        V2EDim=v2edim,
-        E2VDim=e2vdim,
-        V2E=FieldOffset("V2E", source=edge, target=(vertex, v2edim)),
-        E2V=FieldOffset("E2V", source=vertex, target=(edge, e2vdim)),
-        inp=index_field(edge, dtype=np.int64),
-        out=np_as_located_field(vertex)(np.zeros([num_vertices], dtype=np.int64)),
-        offset_provider={
-            "V2E": NeighborTableOffsetProvider(v2e_arr, vertex, edge, 4),
-            "E2V": NeighborTableOffsetProvider(e2v_arr, edge, vertex, 2, has_skip_values=False),
-        },
-        v2e_table=v2e_arr,
-        e2v_table=e2v_arr,
-    )  # type: ignore
-
-
-def test_maxover_execution_sparse(reduction_setup, fieldview_backend):
-    """Testing max_over functionality."""
-    if fieldview_backend == gtfn_cpu.run_gtfn:
-        pytest.skip("not yet supported.")
-    rs = reduction_setup
-    Vertex = rs.Vertex
-    V2EDim = rs.V2EDim
-
-    inp_field = np_as_located_field(Vertex, V2EDim)(rs.v2e_table)
-
-    @field_operator(backend=fieldview_backend)
-    def maxover_fieldoperator(inp_field: Field[[Vertex, V2EDim], int64]) -> Field[[Vertex], int64]:
-        return max_over(inp_field, axis=V2EDim)
-
-    maxover_fieldoperator(inp_field, out=rs.out, offset_provider=rs.offset_provider)
-
-    ref = np.max(rs.v2e_table, axis=1)
-    assert np.allclose(ref, rs.out)
-
-
-def test_maxover_execution_negatives(reduction_setup, fieldview_backend):
-    """Testing max_over functionality for negative values in array."""
-    if fieldview_backend == gtfn_cpu.run_gtfn:
-        pytest.skip("not yet supported.")
-    rs = reduction_setup
-    Edge = rs.Edge
-    Vertex = rs.Vertex
-    V2EDim = rs.V2EDim
-    V2E = rs.V2E
-
-    edge_num = np.max(rs.v2e_table)
-    inp_field_arr = np.arange(-edge_num // 2, edge_num // 2 + 1, 1, dtype=int)
-    inp_field = np_as_located_field(Edge)(inp_field_arr)
-
-    @field_operator(backend=fieldview_backend)
-    def maxover_negvals(
-        edge_f: Field[[Edge], int64],
-    ) -> Field[[Vertex], int64]:
-        out = max_over(edge_f(V2E), axis=V2EDim)
-        return out
-
-    maxover_negvals(inp_field, out=rs.out, offset_provider=rs.offset_provider)
-
-    ref = np.max(inp_field_arr[rs.v2e_table], axis=1)
-    assert np.allclose(ref, rs.out)
-
-
-def test_minover_execution(reduction_setup, fieldview_backend):
-    """Testing the min_over functionality"""
-    if fieldview_backend == gtfn_cpu.run_gtfn:
-        pytest.skip("not implemented yet")
-    rs = reduction_setup
-    Vertex = rs.Vertex
-    V2EDim = rs.V2EDim
-
-    in_field = np_as_located_field(Vertex, V2EDim)(rs.v2e_table)
-
-    @field_operator
-    def minover_fieldoperator(input: Field[[Vertex, V2EDim], int64]) -> Field[[Vertex], int64]:
-        return min_over(input, axis=V2EDim)
-
-    minover_fieldoperator(in_field, out=rs.out, offset_provider=rs.offset_provider)
-
-    ref = np.min(rs.v2e_table, axis=1)
-    assert np.allclose(ref, rs.out)
-
-
-def test_minover_execution_float(reduction_setup, fieldview_backend):
-    """Testing the min_over functionality"""
-    if fieldview_backend == gtfn_cpu.run_gtfn:
-        pytest.skip("not implemented yet")
-    rs = reduction_setup
-    Vertex = rs.Vertex
-    V2EDim = rs.V2EDim
-
-    in_array = np.random.default_rng().uniform(low=-1, high=1, size=rs.v2e_table.shape)
-    in_field = np_as_located_field(Vertex, V2EDim)(in_array)
-    out_field = np_as_located_field(Vertex)(np.zeros(rs.num_vertices))
-
-    @field_operator
-    def minover_fieldoperator(input: Field[[Vertex, V2EDim], float64]) -> Field[[Vertex], float64]:
-        return min_over(input, axis=V2EDim)
-
-    minover_fieldoperator(in_field, out=out_field, offset_provider=rs.offset_provider)
-
-    ref = np.min(in_array, axis=1)
-    assert np.allclose(ref, out_field)
-
-
-def test_reduction_execution(reduction_setup, fieldview_backend):
-    """Testing a trivial neighbor sum."""
-    if fieldview_backend == gtfn_cpu.run_gtfn:
-        pytest.skip("IndexFields are not supported yet.")
-    rs = reduction_setup
-    Edge = rs.Edge
-    Vertex = rs.Vertex
-    V2EDim = rs.V2EDim
-    V2E = rs.V2E
-
-    @field_operator
-    def reduction(edge_f: Field[[Edge], int64]) -> Field[[Vertex], int64]:
-        return neighbor_sum(edge_f(V2E), axis=V2EDim)
-
-    @program(backend=fieldview_backend)
-    def fencil(edge_f: Field[[Edge], int64], out: Field[[Vertex], int64]) -> None:
-        reduction(edge_f, out=out)
-
-    fencil(rs.inp, rs.out, offset_provider=rs.offset_provider)
-
-    ref = np.sum(rs.v2e_table, axis=1)
-    assert np.allclose(ref, rs.out)
-
-
-def test_reduction_execution_nb(reduction_setup, fieldview_backend):
-    """Testing a neighbor sum on a neighbor field."""
-    if fieldview_backend == gtfn_cpu.run_gtfn:
-        pytest.skip("not yet supported.")
-    rs = reduction_setup
-    V2EDim = rs.V2EDim
-
-    nb_field = np_as_located_field(rs.Vertex, rs.V2EDim)(rs.v2e_table)
-
-    @field_operator(backend=fieldview_backend)
-    def reduction(nb_field: Field[[rs.Vertex, rs.V2EDim], int64]) -> Field[[rs.Vertex], int64]:  # type: ignore
-        return neighbor_sum(nb_field, axis=V2EDim)
-
-    reduction(nb_field, out=rs.out, offset_provider=rs.offset_provider)
-
-    ref = np.sum(rs.v2e_table, axis=1)
-    assert np.allclose(ref, rs.out)
-
-
-def test_reduction_expression(reduction_setup, fieldview_backend):
-    """Test reduction with an expression directly inside the call."""
-    if fieldview_backend == gtfn_cpu.run_gtfn:
-        pytest.skip("IndexFields are not supported yet.")
-    rs = reduction_setup
-    Vertex = rs.Vertex
-    Edge = rs.Edge
-    V2EDim = rs.V2EDim
-    V2E = rs.V2E
-
-    @field_operator
-    def reduce_expr(edge_f: Field[[Edge], int64]) -> Field[[Vertex], int64]:
-        tmp_nbh_tup = edge_f(V2E), edge_f(V2E)
-        tmp_nbh = tmp_nbh_tup[0]
-        return 3 * neighbor_sum(-edge_f(V2E) * tmp_nbh * 2, axis=V2EDim)
-
-    @program(backend=fieldview_backend)
-    def fencil(edge_f: Field[[Edge], int64], out: Field[[Vertex], int64]) -> None:
-        reduce_expr(edge_f, out=out)
-
-    fencil(rs.inp, rs.out, offset_provider=rs.offset_provider)
+    fencil(a_I_float, b_I_float, out_I_float, offset_provider={})
 
-    ref = 3 * np.sum(-(rs.v2e_table**2) * 2, axis=1)
-    assert np.allclose(ref, rs.out.array())
+    assert np.allclose((a_I_float.array() * 1.3 + b_I_float.array() * 5.0) * 3.4, out_I_float)
 
 
 def test_scalar_arg(fieldview_backend):
     """Test scalar argument being turned into 0-dim field."""
     if fieldview_backend == gtfn_cpu.run_gtfn:
         pytest.skip("ConstantFields are not supported yet.")
-    Vertex = Dimension("Vertex")
-    size = 5
+
     inp = 5.0
     out = np_as_located_field(Vertex)(np.zeros([size]))
 
@@ -526,71 +179,10 @@ def test_scalar_arg(fieldview_backend):
     assert np.allclose(ref, out.array())
 
 
-def test_conditional_tuple_1():
-    size = 10
-    mask = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
-    mask.array()[0 : (size // 2)] = True
-    a = np_as_located_field(IDim)(np.ones((size,)))
-    c = np_as_located_field(IDim)(np.zeros((size,)))
-    d = np_as_located_field(IDim)(np.zeros((size,)))
-
-    @field_operator
-    def conditional_tuple(
-        mask: Field[[IDim], bool], a: Field[[IDim], float64]
-    ) -> tuple[Field[[IDim], float64], Field[[IDim], float64]]:
-        return where(mask, (a, 3.0), (2.0, 7.0))
-
-    @program
-    def conditional_tuple_p(
-        mask: Field[[IDim], bool],
-        a: Field[[IDim], float64],
-        c: Field[[IDim], float64],
-        d: Field[[IDim], float64],
-    ):
-        conditional_tuple(mask, a, out=(c, d))
-
-    conditional_tuple_p(mask, a, c, d, offset_provider={})
-
-    assert np.allclose(
-        np.where(mask, (a, np.full(size, 3.0)), (np.full(size, 2.0), np.full(size, 7.0))), (c, d)
-    )
-
-
-def test_conditional_nested_tuple():
-    size = 10
-    mask = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
-    mask.array()[0 : (size // 2)] = True
-    a = np_as_located_field(IDim)(np.ones((size,)))
-    b = np_as_located_field(IDim)(np.ones((size,)))
-    c = np_as_located_field(IDim)(np.zeros((size,)))
-    d = np_as_located_field(IDim)(np.zeros((size,)))
-
-    @field_operator
-    def conditional_tuple_3_field_op(
-        mask: Field[[IDim], bool], a: Field[[IDim], float64], b: Field[[IDim], float64]
-    ) -> tuple[
-        tuple[Field[[IDim], float64], Field[[IDim], float64]],
-        tuple[Field[[IDim], float64], Field[[IDim], float64]],
-    ]:
-        return where(mask, ((a, b), (b, a)), ((5.0, 7.0), (7.0, 5.0)))
-
-    conditional_tuple_3_field_op(mask, a, b, out=((c, d), (d, c)), offset_provider={})
-
-    assert np.allclose(
-        np.where(
-            mask,
-            ((a, b), (b, a)),
-            ((np.full(size, 5.0), np.full(size, 7.0)), (np.full(size, 7.0), np.full(size, 5.0))),
-        ),
-        ((c, d), (d, c)),
-    )
-
-
 def test_nested_scalar_arg(fieldview_backend):
     if fieldview_backend == gtfn_cpu.run_gtfn:
         pytest.skip("ConstantFields are not supported yet.")
-    Vertex = Dimension("Vertex")
-    size = 5
+
     inp = 5.0
     out = np_as_located_field(Vertex)(np.zeros([size]))
 
@@ -611,9 +203,7 @@ def test_nested_scalar_arg(fieldview_backend):
 def test_scalar_arg_with_field(fieldview_backend):
     if fieldview_backend == gtfn_cpu.run_gtfn:
         pytest.skip("IndexFields and ConstantFields are not supported yet.")
-    Edge = Dimension("Edge")
-    EdgeOffset = FieldOffset("EdgeOffset", source=Edge, target=(Edge,))
-    size = 5
+
     inp = index_field(Edge, dtype=float64)
     factor = 3.0
     out = np_as_located_field(Edge)(np.zeros((size), dtype=np.float64))
@@ -635,159 +225,53 @@ def test_scalar_arg_with_field(fieldview_backend):
     assert np.allclose(ref, out.array())
 
 
-def test_broadcast_simple(fieldview_backend):
+def test_astype_int(fieldview_backend):
     size = 10
-    a = np_as_located_field(IDim)(np.arange(0, size, 1, dtype=int64))
-    out = np_as_located_field(IDim, JDim)(np.zeros((size, size), dtype=int64))
+    b_float_64 = np_as_located_field(IDim)(np.ones((size), dtype=np.float64))
+    c_int64 = np_as_located_field(IDim)(np.ones((size,), dtype=np.int64))
+    out_int_64 = np_as_located_field(IDim)(np.zeros((size,), dtype=np.int64))
 
     @field_operator(backend=fieldview_backend)
-    def simple_broadcast(inp: Field[[IDim], int64]) -> Field[[IDim, JDim], int64]:
-        return broadcast(inp, (IDim, JDim))
-
-    simple_broadcast(a, out=out, offset_provider={})
+    def astype_fieldop_int(b: Field[[IDim], float64]) -> Field[[IDim], int64]:
+        d = astype(b, int64)
+        return d
 
-    assert np.allclose(a.array()[:, np.newaxis], out)
+    astype_fieldop_int(b_float_64, out=out_int_64, offset_provider={})
+    assert np.allclose(c_int64.array(), out_int_64)
 
 
-def test_broadcast_scalar(fieldview_backend):
-    size = 10
-    out = np_as_located_field(IDim)(np.zeros((size)))
+def test_astype_bool(fieldview_backend):
+    b_float_64 = np_as_located_field(IDim)(np.ones((size), dtype=np.float64))
+    c_bool = np_as_located_field(IDim)(np.ones((size,), dtype=bool))
+    out_bool = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
 
     @field_operator(backend=fieldview_backend)
-    def scalar_broadcast() -> Field[[IDim], float64]:
-        return broadcast(float(1.0), (IDim,))
-
-    scalar_broadcast(out=out, offset_provider={})
+    def astype_fieldop_bool(b: Field[[IDim], float64]) -> Field[[IDim], bool]:
+        d = astype(b, bool)
+        return d
 
-    assert np.allclose(1, out)
+    astype_fieldop_bool(b_float_64, out=out_bool, offset_provider={})
+    assert np.allclose(c_bool, out_bool)
 
 
-def test_broadcast_two_fields(fieldview_backend):
-    size = 10
-    a = np_as_located_field(IDim)(np.arange(0, size, 1, dtype=int64))
-    b = np_as_located_field(JDim)(np.arange(0, size, 1, dtype=int64))
-
-    out = np_as_located_field(IDim, JDim)(np.zeros((size, size), dtype=int64))
+def test_astype_float(fieldview_backend):
+    c_int64 = np_as_located_field(IDim)(np.ones((size,), dtype=np.int64))
+    c_int32 = np_as_located_field(IDim)(np.ones((size,), dtype=np.int32))
+    out_int_32 = np_as_located_field(IDim)(np.zeros((size,), dtype=np.int32))
 
     @field_operator(backend=fieldview_backend)
-    def broadcast_two_fields(
-        inp1: Field[[IDim], int64], inp2: Field[[JDim], int64]
-    ) -> Field[[IDim, JDim], int64]:
-        a = broadcast(inp1, (IDim, JDim))
-        b = broadcast(inp2, (IDim, JDim))
-        return a + b
-
-    broadcast_two_fields(a, b, out=out, offset_provider={})
-
-    expected = a.array()[:, np.newaxis] + b.array()[np.newaxis, :]
-
-    assert np.allclose(expected, out)
+    def astype_fieldop_float(b: Field[[IDim], int64]) -> Field[[IDim], int32]:
+        d = astype(b, int32)
+        return d
 
-
-def test_broadcast_shifted(fieldview_backend):
-    Joff = FieldOffset("Joff", source=JDim, target=(JDim,))
-
-    size = 10
-    a = np_as_located_field(IDim)(np.arange(0, size, 1, dtype=int))
-    out = np_as_located_field(IDim, JDim)(np.zeros((size, size), dtype=int64))
-
-    @field_operator(backend=fieldview_backend)
-    def simple_broadcast(inp: Field[[IDim], int64]) -> Field[[IDim, JDim], int64]:
-        bcasted = broadcast(inp, (IDim, JDim))
-        return bcasted(Joff[1])
-
-    simple_broadcast(a, out=out, offset_provider={"Joff": JDim})
-
-    assert np.allclose(a.array()[:, np.newaxis], out)
-
-
-def test_conditional(fieldview_backend):
-    size = 10
-    mask = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
-    mask.array()[0 : (size // 2)] = True
-    a = np_as_located_field(IDim)(np.ones((size,)))
-    b = np_as_located_field(IDim)(2 * np.ones((size,)))
-    out = np_as_located_field(IDim)(np.zeros((size,)))
-
-    @field_operator(backend=fieldview_backend)
-    def conditional(
-        mask: Field[[IDim], bool], a: Field[[IDim], float64], b: Field[[IDim], float64]
-    ) -> Field[[IDim], float64]:
-        return where(mask, a, b)
-
-    conditional(mask, a, b, out=out, offset_provider={})
-
-    assert np.allclose(np.where(mask, a, b), out)
-
-
-def test_conditional_promotion(fieldview_backend):
-    size = 10
-    mask = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
-    mask.array()[0 : (size // 2)] = True
-    a = np_as_located_field(IDim)(np.ones((size,)))
-    out = np_as_located_field(IDim)(np.zeros((size,)))
-
-    @field_operator(backend=fieldview_backend)
-    def conditional_promotion(
-        mask: Field[[IDim], bool], a: Field[[IDim], float64]
-    ) -> Field[[IDim], float64]:
-        return where(mask, a, 10.0)
-
-    conditional_promotion(mask, a, out=out, offset_provider={})
-
-    assert np.allclose(np.where(mask, a, 10), out)
-
-
-def test_conditional_compareop(fieldview_backend):
-    size = 10
-    a = np_as_located_field(IDim)(np.ones((size,)))
-    out = np_as_located_field(IDim)(np.zeros((size,)))
-
-    @field_operator(backend=fieldview_backend)
-    def conditional_promotion(a: Field[[IDim], float64]) -> Field[[IDim], float64]:
-        return where(a != a, a, 10.0)
-
-    conditional_promotion(a, out=out, offset_provider={})
-
-    assert np.allclose(np.where(np.asarray(a) != np.asarray(a), a, 10), out)
-
-
-def test_conditional_shifted(fieldview_backend):
-    Ioff = FieldOffset("Ioff", source=IDim, target=(IDim,))
-
-    size = 10
-    mask = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
-    mask.array()[size // 2] = True
-    a = np_as_located_field(IDim)(np.arange(0, size, 1, dtype=float64))
-    b = np_as_located_field(IDim)(np.zeros((size,)))
-    out = np_as_located_field(IDim)(np.zeros((size,)))
-
-    @field_operator()
-    def conditional_shifted(
-        mask: Field[[IDim], bool], a: Field[[IDim], float64], b: Field[[IDim], float64]
-    ) -> Field[[IDim], float64]:
-        tmp = where(mask, a, b)
-        return tmp(Ioff[1])
-
-    @program(backend=fieldview_backend)
-    def conditional_program(
-        mask: Field[[IDim], bool],
-        a: Field[[IDim], float64],
-        b: Field[[IDim], float64],
-        out: Field[[IDim], float64],
-    ):
-        conditional_shifted(mask, a, b, out=out[:-1])
-
-    conditional_program(mask, a, b, out, offset_provider={"Ioff": IDim})
-
-    assert np.allclose(np.where(mask, a, b)[1:], out.array()[:-1])
+    astype_fieldop_float(c_int64, out=out_int_32, offset_provider={})
+    assert np.allclose(c_int32.array(), out_int_32)
 
 
 def test_nested_tuple_return():
-    size = 10
-    a = np_as_located_field(IDim)(np.ones((size,)))
-    b = np_as_located_field(IDim)(2 * np.ones((size,)))
-    out = np_as_located_field(IDim)(np.zeros((size,)))
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.zeros((size), dtype=float64))
 
     @field_operator
     def pack_tuple(
@@ -800,9 +284,9 @@ def test_nested_tuple_return():
         packed = pack_tuple(a, b)
         return packed[0] + packed[1][0] + packed[1][1]
 
-    combine(a, b, out=out, offset_provider={})
+    combine(a_I_float, b_I_float, out=out_I_float, offset_provider={})
 
-    assert np.allclose(2 * a.array() + b.array(), out)
+    assert np.allclose(2 * a_I_float.array() + b_I_float.array(), out_I_float)
 
 
 def test_tuple_return_2(reduction_setup):
@@ -843,7 +327,6 @@ def test_tuple_with_local_field_in_reduction_shifted(reduction_setup):
     num_vertices = rs.num_vertices
     num_edges = rs.num_edges
 
-    size = 10
     # TODO(tehrengruber): use different values per location
     a = np_as_located_field(Edge)(np.ones((num_edges,)))
     b = np_as_located_field(Vertex)(2 * np.ones((num_vertices,)))
@@ -872,12 +355,12 @@ def test_tuple_with_local_field_in_reduction_shifted(reduction_setup):
 
 
 def test_tuple_arg(fieldview_backend):
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.zeros((size), dtype=float64))
+
     if fieldview_backend == gtfn_cpu.run_gtfn:
         pytest.skip("Tuple arguments are not supported in gtfn yet.")
-    size = 10
-    a = np_as_located_field(IDim)(np.ones((size,)))
-    b = np_as_located_field(IDim)(2 * np.ones((size,)))
-    out = np_as_located_field(IDim)(np.zeros((size,)))
 
     @field_operator(backend=fieldview_backend)
     def unpack_tuple(
@@ -885,15 +368,15 @@ def test_tuple_arg(fieldview_backend):
     ) -> Field[[IDim], float64]:
         return 3.0 * inp[0][0] + inp[0][1] + inp[1]
 
-    unpack_tuple(((a, b), a), out=out, offset_provider={})
+    unpack_tuple(((a_I_float, b_I_float), a_I_float), out=out_I_float, offset_provider={})
 
-    assert np.allclose(3 * a.array() + b.array() + a.array(), out)
+    assert np.allclose(3 * a_I_float.array() + b_I_float.array() + a_I_float.array(), out_I_float)
 
 
 @pytest.mark.parametrize("forward", [True, False])
 def test_fieldop_from_scan(fieldview_backend, forward):
-    KDim = Dimension("K", kind=DimensionKind.VERTICAL)
-    size = 10
+    if fieldview_backend == gtfn_cpu.run_gtfn:
+        pytest.xfail("gtfn does not yet support scan pass.")
     init = 1.0
     out = np_as_located_field(KDim)(np.zeros((size,)))
     expected = np.arange(init + 1.0, init + 1.0 + size, 1)
@@ -917,7 +400,6 @@ def test_solve_triag(fieldview_backend):
     if fieldview_backend == gtfn_cpu.run_gtfn:
         pytest.xfail("gtfn does not yet support scan pass.")
 
-    KDim = Dimension("K", kind=DimensionKind.VERTICAL)
     shape = (3, 7, 5)
     rng = np.random.default_rng()
     a_np, b_np, c_np, d_np = (rng.normal(size=shape) for _ in range(4))
@@ -961,11 +443,9 @@ def test_solve_triag(fieldview_backend):
 
 
 def test_ternary_operator():
-    size = 10
-
-    a = np_as_located_field(IDim)(2 * np.ones((size,)))
-    b = np_as_located_field(IDim)(2 * np.ones((size,)))
-    out = np_as_located_field(IDim)(np.zeros((size,)))
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.zeros((size), dtype=float64))
 
     left = 2.0
     right = 3.0
@@ -976,25 +456,24 @@ def test_ternary_operator():
     ) -> Field[[IDim], float]:
         return a if left < right else b
 
-    ternary_field_op(a, b, left, right, out=out, offset_provider={})
-    e = np.asarray(a) if left < right else np.asarray(b)
-    np.allclose(e, out)
+    ternary_field_op(a_I_float, b_I_float, left, right, out=out_I_float, offset_provider={})
+    e = np.asarray(a_I_float) if left < right else np.asarray(b_I_float)
+    np.allclose(e, out_I_float)
 
     @field_operator
     def ternary_field_op_scalars(left: float, right: float) -> Field[[IDim], float]:
         return broadcast(3.0, (IDim,)) if left > right else broadcast(4.0, (IDim,))
 
-    ternary_field_op_scalars(left, right, out=out, offset_provider={})
+    ternary_field_op_scalars(left, right, out=out_I_float, offset_provider={})
     e = np.full(e.shape, 3.0) if left > right else e
-    np.allclose(e, out)
+    np.allclose(e, out_I_float)
 
 
 def test_ternary_operator_tuple():
-    size = 10
-    a = np_as_located_field(IDim)(np.ones((size,)))
-    b = np_as_located_field(IDim)(2 * np.ones((size,)))
-    out_1 = np_as_located_field(IDim)(np.zeros((size,)))
-    out_2 = np_as_located_field(IDim)(np.zeros((size,)))
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.zeros((size), dtype=float64))
+    out_I_float_1 = np_as_located_field(IDim)(np.zeros((size), dtype=float64))
 
     left = 2.0
     right = 3.0
@@ -1005,11 +484,17 @@ def test_ternary_operator_tuple():
     ) -> tuple[Field[[IDim], float], Field[[IDim], float]]:
         return (a, b) if left < right else (b, a)
 
-    ternary_field_op(a, b, left, right, out=(out_1, out_2), offset_provider={})
+    ternary_field_op(
+        a_I_float, b_I_float, left, right, out=(out_I_float, out_I_float_1), offset_provider={}
+    )
 
-    e, f = (np.asarray(a), np.asarray(b)) if left < right else (np.asarray(b), np.asarray(a))
-    np.allclose(e, out_1)
-    np.allclose(f, out_2)
+    e, f = (
+        (np.asarray(a_I_float), np.asarray(b_I_float))
+        if left < right
+        else (np.asarray(b_I_float), np.asarray(a_I_float))
+    )
+    np.allclose(e, out_I_float)
+    np.allclose(f, out_I_float_1)
 
 
 def test_ternary_builtin_neighbor_sum(reduction_setup):
@@ -1043,8 +528,6 @@ def test_ternary_builtin_neighbor_sum(reduction_setup):
 
 
 def test_ternary_scan():
-    KDim = Dimension("K", kind=DimensionKind.VERTICAL)
-    size = 10
     init = 0.0
     a_float = 4
     a = np_as_located_field(KDim)(a_float * np.ones((size,)))
@@ -1065,8 +548,6 @@ def test_scan_nested_tuple_output(fieldview_backend, forward):
     if fieldview_backend == gtfn_cpu.run_gtfn:
         pytest.xfail("gtfn does not yet support scan pass or tuple out arguments.")
 
-    KDim = Dimension("K", kind=DimensionKind.VERTICAL)
-    size = 10
     init = (1.0, (2.0, 3.0))
     out1, out2, out3 = (np_as_located_field(KDim)(np.zeros((size,))) for _ in range(3))
     expected = np.arange(1.0, 1.0 + size, 1)
@@ -1091,8 +572,6 @@ def test_scan_nested_tuple_input(fieldview_backend, forward):
     if fieldview_backend == gtfn_cpu.run_gtfn:
         pytest.xfail("gtfn does not yet support scan pass or tuple arguments.")
 
-    KDim = Dimension("K", kind=DimensionKind.VERTICAL)
-    size = 10
     init = 1.0
     inp1 = np_as_located_field(KDim)(np.ones(size))
     inp2 = np_as_located_field(KDim)(np.arange(0.0, size, 1))
@@ -1116,8 +595,7 @@ def test_scan_nested_tuple_input(fieldview_backend, forward):
 
 
 def test_docstring():
-    size = 10
-    a = np_as_located_field(IDim)(np.ones((size,)))
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
 
     @field_operator
     def fieldop_with_docstring(a: Field[[IDim], float64]) -> Field[[IDim], float64]:
@@ -1129,12 +607,11 @@ def test_docstring():
         """My docstring."""
         fieldop_with_docstring(a, out=a)
 
-    test_docstring(a, offset_provider={})
+    test_docstring(a_I_float, offset_provider={})
 
 
 def test_domain(fieldview_backend):
-    size = 10
-    a = np_as_located_field(IDim, JDim)(np.ones((size, size)))
+    a_IJ_float = np_as_located_field(IDim, JDim)(np.ones((size, size), dtype=float64))
 
     @field_operator(backend=fieldview_backend)
     def fieldop_domain(a: Field[[IDim, JDim], float64]) -> Field[[IDim, JDim], float64]:
@@ -1144,20 +621,19 @@ def test_domain(fieldview_backend):
     def program_domain(a: Field[[IDim, JDim], float64]):
         fieldop_domain(a, out=a, domain={IDim: (1, 9), JDim: (4, 6)})
 
-    program_domain(a, offset_provider={})
+    program_domain(a_IJ_float, offset_provider={})
 
-    expected = np.asarray(a)
+    expected = np.asarray(a_IJ_float)
     expected[1:9, 4:6] = 1 + 1
 
-    assert np.allclose(expected, a)
+    assert np.allclose(expected, a_IJ_float)
 
 
 def test_domain_input_bounds(fieldview_backend):
     if fieldview_backend == gtfn_cpu.run_gtfn:
-        pytest.skip("FloorDiv and Power not fully supported in gtfn.")
+        pytest.skip("FloorDiv not fully supported in gtfn.")
+    a_IJ_float = np_as_located_field(IDim, JDim)(np.ones((size, size), dtype=float64))
 
-    size = 10
-    a = np_as_located_field(IDim, JDim)(np.ones((size, size)))
     lower_i = 1
     upper_i = 9
     lower_j = 4
@@ -1181,17 +657,17 @@ def test_domain_input_bounds(fieldview_backend):
             domain={IDim: (lower_i, upper_i // 1), JDim: (lower_j**1, upper_j)},
         )
 
-    program_domain(a, lower_i, upper_i, lower_j, upper_j, offset_provider={})
+    program_domain(a_IJ_float, lower_i, upper_i, lower_j, upper_j, offset_provider={})
 
-    expected = np.asarray(a)
+    expected = np.asarray(a_IJ_float)
     expected[1:9, 4:6] = 1 + 1
 
-    assert np.allclose(expected, a)
+    assert np.allclose(expected, a_IJ_float)
 
 
 def test_domain_input_bounds_1(fieldview_backend):
-    size = 10
-    a = np_as_located_field(IDim, JDim)(np.ones((size, size)) * 2)
+    a_IJ_float = np_as_located_field(IDim, JDim)(np.ones((size, size), dtype=float64))
+
     lower_i = 1
     upper_i = 9
     lower_j = 4
@@ -1215,18 +691,17 @@ def test_domain_input_bounds_1(fieldview_backend):
             domain={IDim: (1 * lower_i, upper_i + 0), JDim: (lower_j - 0, upper_j)},
         )
 
-    program_domain(a, lower_i, upper_i, lower_j, upper_j, offset_provider={})
+    program_domain(a_IJ_float, lower_i, upper_i, lower_j, upper_j, offset_provider={})
 
-    expected = np.asarray(a)
+    expected = np.asarray(a_IJ_float)
     expected[1:9, 4:6] = 2 * 2
 
-    assert np.allclose(expected, a)
+    assert np.allclose(expected, a_IJ_float)
 
 
 def test_domain_tuple(fieldview_backend):
-    size = 10
-    a = np_as_located_field(IDim, JDim)(np.ones((size, size)))
-    b = np_as_located_field(IDim, JDim)(np.ones((size, size)))
+    a_IJ_float = np_as_located_field(IDim, JDim)(np.ones((size, size), dtype=float64))
+    b2d_float = a_IJ_float
 
     @field_operator(backend=fieldview_backend)
     def fieldop_domain_tuple(
@@ -1238,21 +713,18 @@ def test_domain_tuple(fieldview_backend):
     def program_domain_tuple(a: Field[[IDim, JDim], float64], b: Field[[IDim, JDim], float64]):
         fieldop_domain_tuple(a, out=(b, a), domain={IDim: (1, 9), JDim: (4, 6)})
 
-    program_domain_tuple(a, b, offset_provider={})
+    program_domain_tuple(a_IJ_float, b2d_float, offset_provider={})
 
-    expected = np.asarray(a)
+    expected = np.asarray(a_IJ_float)
     expected[1:9, 4:6] = 1 + 1
 
-    assert np.allclose(np.asarray(a), a)
-    assert np.allclose(expected, b)
+    assert np.allclose(np.asarray(a_IJ_float), a_IJ_float)
+    assert np.allclose(expected, b2d_float)
 
 
 def test_where_k_offset(fieldview_backend):
     if fieldview_backend == gtfn_cpu.run_gtfn:
         pytest.skip("IndexFields are not supported yet.")
-    size = 10
-    KDim = Dimension("K", kind=DimensionKind.VERTICAL)
-    Koff = FieldOffset("Koff", source=KDim, target=(KDim,))
     a = np_as_located_field(IDim, KDim)(np.ones((size, size)))
     out = np_as_located_field(IDim, KDim)(np.zeros((size, size)))
     k_index = index_field(KDim)
diff --git a/tests/functional_tests/ffront_tests/test_foast_parsing.py b/tests/functional_tests/ffront_tests/test_foast_parsing.py
index fb23e246..af571c5d 100644
--- a/tests/functional_tests/ffront_tests/test_foast_parsing.py
+++ b/tests/functional_tests/ffront_tests/test_foast_parsing.py
@@ -28,7 +28,7 @@ import pytest
 from eve.pattern_matching import ObjectPattern as P
 from functional.common import Field, GTTypeError
 from functional.ffront import common_types, field_operator_ast as foast
-from functional.ffront.fbuiltins import Dimension, float32, float64, int32, int64, where
+from functional.ffront.fbuiltins import Dimension, astype, float32, float64, int32, int64, where
 from functional.ffront.foast_passes.type_deduction import FieldOperatorTypeDeductionError
 from functional.ffront.func_to_foast import FieldOperatorParser, FieldOperatorSyntaxError
 from functional.ffront.symbol_makers import TypingError
@@ -266,6 +266,18 @@ def test_conditional_wrong_arg_type():
     assert re.search(msg, exc_info.value.__cause__.args[0]) is not None
 
 
+def test_astype():
+    def astype_fieldop(a: Field[..., "int64"]) -> Field[..., float64]:
+        return astype(a, float64)
+
+    parsed = FieldOperatorParser.apply_to_function(astype_fieldop)
+
+    assert parsed.body[-1].value.type == common_types.FieldType(
+        dims=Ellipsis,
+        dtype=common_types.ScalarType(kind=common_types.ScalarKind.FLOAT64, shape=None),
+    )
+
+
 # --- External symbols ---
 def test_closure_symbols():
     import numpy as np
diff --git a/tests/functional_tests/ffront_tests/test_gt4py_builtins.py b/tests/functional_tests/ffront_tests/test_gt4py_builtins.py
new file mode 100644
index 00000000..7e08e403
--- /dev/null
+++ b/tests/functional_tests/ffront_tests/test_gt4py_builtins.py
@@ -0,0 +1,362 @@
+# -*- coding: utf-8 -*-
+#
+# GT4Py Project - GridTools Framework
+#
+# Copyright (c) 2014-2021, 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 functional.ffront.decorator import field_operator, program
+from functional.ffront.fbuiltins import (
+    Field,
+    broadcast,
+    float64,
+    int64,
+    max_over,
+    min_over,
+    neighbor_sum,
+    where,
+)
+
+from .ffront_test_utils import *
+
+
+def test_maxover_execution(reduction_setup, fieldview_backend):
+    """Testing max_over functionality."""
+    if fieldview_backend == gtfn_cpu.run_gtfn:
+        pytest.skip("not yet supported.")
+
+    rs = reduction_setup
+    Vertex, V2EDim = rs.Vertex, rs.V2EDim
+    inp_field = np_as_located_field(Vertex, V2EDim)(rs.v2e_table)
+
+    @field_operator(backend=fieldview_backend)
+    def maxover_fieldoperator(inp_field: Field[[Vertex, V2EDim], int64]) -> Field[[Vertex], int64]:
+        return max_over(inp_field, axis=V2EDim)
+
+    maxover_fieldoperator(inp_field, out=rs.out, offset_provider=rs.offset_provider)
+
+    ref = np.max(rs.v2e_table, axis=1)
+    assert np.allclose(ref, rs.out)
+
+
+def test_maxover_execution_negatives(reduction_setup, fieldview_backend):
+    """Testing max_over functionality for negative values in array."""
+    if fieldview_backend == gtfn_cpu.run_gtfn:
+        pytest.skip("not yet supported.")
+
+    rs = reduction_setup
+    Vertex, V2EDim, V2E = rs.Vertex, rs.V2EDim, rs.V2E
+    Edge = rs.Edge
+    edge_num = np.max(rs.v2e_table)
+    inp_field_arr = np.arange(-edge_num // 2, edge_num // 2 + 1, 1, dtype=int)
+    inp_field = np_as_located_field(Edge)(inp_field_arr)
+
+    @field_operator(backend=fieldview_backend)
+    def maxover_negvals(edge_f: Field[[Edge], int64]) -> Field[[Vertex], int64]:
+        out = max_over(edge_f(V2E), axis=V2EDim)
+        return out
+
+    maxover_negvals(inp_field, out=rs.out, offset_provider=rs.offset_provider)
+
+    ref = np.max(inp_field_arr[rs.v2e_table], axis=1)
+    assert np.allclose(ref, rs.out)
+
+
+def test_minover_execution(reduction_setup, fieldview_backend):
+    """Testing the min_over functionality"""
+    if fieldview_backend == gtfn_cpu.run_gtfn:
+        pytest.skip("not implemented yet")
+
+    rs = reduction_setup
+    Vertex, V2EDim = rs.Vertex, rs.V2EDim
+    in_field = np_as_located_field(Vertex, V2EDim)(rs.v2e_table)
+
+    @field_operator
+    def minover_fieldoperator(input: Field[[Vertex, V2EDim], int64]) -> Field[[Vertex], int64]:
+        return min_over(input, axis=V2EDim)
+
+    minover_fieldoperator(in_field, out=rs.out, offset_provider=rs.offset_provider)
+
+    ref = np.min(rs.v2e_table, axis=1)
+    assert np.allclose(ref, rs.out)
+
+
+def test_minover_execution_float(reduction_setup, fieldview_backend):
+    """Testing the min_over functionality"""
+    if fieldview_backend == gtfn_cpu.run_gtfn:
+        pytest.skip("not implemented yet")
+
+    rs = reduction_setup
+    Vertex, V2EDim = rs.Vertex, rs.V2EDim
+    in_array = np.random.default_rng().uniform(low=-1, high=1, size=rs.v2e_table.shape)
+    in_field = np_as_located_field(Vertex, V2EDim)(in_array)
+    out_field = np_as_located_field(Vertex)(np.zeros(rs.num_vertices))
+
+    @field_operator
+    def minover_fieldoperator(input: Field[[Vertex, V2EDim], float64]) -> Field[[Vertex], float64]:
+        return min_over(input, axis=V2EDim)
+
+    minover_fieldoperator(in_field, out=out_field, offset_provider=rs.offset_provider)
+
+    ref = np.min(in_array, axis=1)
+    assert np.allclose(ref, out_field)
+
+
+def test_reduction_execution(reduction_setup, fieldview_backend):
+    """Testing a trivial neighbor sum."""
+    if fieldview_backend == gtfn_cpu.run_gtfn:
+        pytest.skip("IndexFields are not supported yet.")
+
+    rs = reduction_setup
+    Edge = rs.Edge
+    Vertex, V2EDim, V2E = rs.Vertex, rs.V2EDim, rs.V2E
+
+    @field_operator
+    def reduction(edge_f: Field[[Edge], int64]) -> Field[[Vertex], int64]:
+        return neighbor_sum(edge_f(V2E), axis=V2EDim)
+
+    @program(backend=fieldview_backend)
+    def fencil(edge_f: Field[[Edge], int64], out: Field[[Vertex], int64]) -> None:
+        reduction(edge_f, out=out)
+
+    fencil(rs.inp, rs.out, offset_provider=rs.offset_provider)
+
+    ref = np.sum(rs.v2e_table, axis=1)
+    assert np.allclose(ref, rs.out)
+
+
+def test_reduction_execution_nb(reduction_setup, fieldview_backend):
+    """Testing a neighbor sum on a neighbor field."""
+    if fieldview_backend == gtfn_cpu.run_gtfn:
+        pytest.skip("not yet supported.")
+
+    rs = reduction_setup
+    Vertex, V2EDim = rs.Vertex, rs.V2EDim
+    nb_field = np_as_located_field(Vertex, V2EDim)(rs.v2e_table)
+
+    @field_operator(backend=fieldview_backend)
+    def reduction(nb_field: Field[[Vertex, V2EDim], int64]) -> Field[[Vertex], int64]:
+        return neighbor_sum(nb_field, axis=V2EDim)
+
+    reduction(nb_field, out=rs.out, offset_provider=rs.offset_provider)
+
+    ref = np.sum(rs.v2e_table, axis=1)
+    assert np.allclose(ref, rs.out)
+
+
+def test_reduction_expression(reduction_setup, fieldview_backend):
+    """Test reduction with an expression directly inside the call."""
+    if fieldview_backend == gtfn_cpu.run_gtfn:
+        pytest.skip("IndexFields are not supported yet.")
+
+    rs = reduction_setup
+    Vertex, V2EDim, V2E = rs.Vertex, rs.V2EDim, rs.V2E
+    Edge = rs.Edge
+
+    @field_operator
+    def reduce_expr(edge_f: Field[[Edge], int64]) -> Field[[Vertex], int64]:
+        tmp_nbh_tup = edge_f(V2E), edge_f(V2E)
+        tmp_nbh = tmp_nbh_tup[0]
+        return 3 * neighbor_sum(-edge_f(V2E) * tmp_nbh * 2, axis=V2EDim)
+
+    @program(backend=fieldview_backend)
+    def fencil(edge_f: Field[[Edge], int64], out: Field[[Vertex], int64]) -> None:
+        reduce_expr(edge_f, out=out)
+
+    fencil(rs.inp, rs.out, offset_provider=rs.offset_provider)
+
+    ref = 3 * np.sum(-(rs.v2e_table**2) * 2, axis=1)
+    assert np.allclose(ref, rs.out.array())
+
+
+def test_conditional_nested_tuple():
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float_1 = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    mask = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+
+    @field_operator
+    def conditional_nested_tuple(
+        mask: Field[[IDim], bool], a: Field[[IDim], float64], b: Field[[IDim], float64]
+    ) -> tuple[
+        tuple[Field[[IDim], float64], Field[[IDim], float64]],
+        tuple[Field[[IDim], float64], Field[[IDim], float64]],
+    ]:
+        return where(mask, ((a, b), (b, a)), ((5.0, 7.0), (7.0, 5.0)))
+
+    conditional_nested_tuple(
+        mask,
+        a_I_float,
+        b_I_float,
+        out=((out_I_float, out_I_float_1), (out_I_float_1, out_I_float)),
+        offset_provider={},
+    )
+
+    expected = np.where(
+        mask,
+        ((a_I_float, b_I_float), (b_I_float, a_I_float)),
+        ((np.full(size, 5.0), np.full(size, 7.0)), (np.full(size, 7.0), np.full(size, 5.0))),
+    )
+
+    assert np.allclose(expected, ((out_I_float, out_I_float_1), (out_I_float_1, out_I_float)))
+
+
+def test_broadcast_simple(fieldview_backend):
+    a_I_int = np_as_located_field(IDim)(np.random.randn(size).astype("int64"))
+    out_IJ_int = np_as_located_field(IDim, JDim)(np.zeros((size, size), dtype=int64))
+
+    @field_operator(backend=fieldview_backend)
+    def simple_broadcast(inp: Field[[IDim], int64]) -> Field[[IDim, JDim], int64]:
+        return broadcast(inp, (IDim, JDim))
+
+    simple_broadcast(a_I_int, out=out_IJ_int, offset_provider={})
+
+    assert np.allclose(a_I_int.array()[:, np.newaxis], out_IJ_int)
+
+
+def test_broadcast_scalar(fieldview_backend):
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+
+    @field_operator(backend=fieldview_backend)
+    def scalar_broadcast() -> Field[[IDim], float64]:
+        return broadcast(float(1.0), (IDim,))
+
+    scalar_broadcast(out=out_I_float, offset_provider={})
+
+    assert np.allclose(1, out_I_float)
+
+
+def test_broadcast_two_fields(fieldview_backend):
+    a_I_int = np_as_located_field(IDim)(np.random.randn(size).astype("int64"))
+    b_J_int = np_as_located_field(JDim)(np.random.randn(size).astype("int64"))
+    out_IJ_int = np_as_located_field(IDim, JDim)(np.zeros((size, size), dtype=int64))
+
+    @field_operator(backend=fieldview_backend)
+    def broadcast_two_fields(
+        inp1: Field[[IDim], int64], inp2: Field[[JDim], int64]
+    ) -> Field[[IDim, JDim], int64]:
+        a = broadcast(inp1, (IDim, JDim))
+        b = broadcast(inp2, (IDim, JDim))
+        return a + b
+
+    broadcast_two_fields(a_I_int, b_J_int, out=out_IJ_int, offset_provider={})
+
+    expected = a_I_int.array()[:, np.newaxis] + b_J_int.array()[np.newaxis, :]
+
+    assert np.allclose(expected, out_IJ_int)
+
+
+def test_broadcast_shifted(fieldview_backend):
+    a_I_int = np_as_located_field(IDim)(np.random.randn(size).astype("int64"))
+    out_IJ_int = np_as_located_field(IDim, JDim)(np.zeros((size, size), dtype=int64))
+
+    @field_operator(backend=fieldview_backend)
+    def simple_broadcast(inp: Field[[IDim], int64]) -> Field[[IDim, JDim], int64]:
+        bcasted = broadcast(inp, (IDim, JDim))
+        return bcasted(Joff[1])
+
+    simple_broadcast(a_I_int, out=out_IJ_int, offset_provider={"Joff": JDim})
+
+    assert np.allclose(a_I_int.array()[:, np.newaxis], out_IJ_int)
+
+
+def test_conditional(fieldview_backend):
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    mask = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+
+    @field_operator(backend=fieldview_backend)
+    def conditional(
+        mask: Field[[IDim], bool], a: Field[[IDim], float64], b: Field[[IDim], float64]
+    ) -> Field[[IDim], float64]:
+        return where(mask, a, b)
+
+    conditional(mask, a_I_float, b_I_float, out=out_I_float, offset_provider={})
+
+    assert np.allclose(np.where(mask, a_I_float, b_I_float), out_I_float)
+
+
+def test_conditional_promotion(fieldview_backend):
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    mask = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+
+    @field_operator(backend=fieldview_backend)
+    def conditional_promotion(
+        mask: Field[[IDim], bool], a: Field[[IDim], float64]
+    ) -> Field[[IDim], float64]:
+        return where(mask, a, 10.0)
+
+    conditional_promotion(mask, a_I_float, out=out_I_float, offset_provider={})
+
+    assert np.allclose(np.where(mask, a_I_float, 10), out_I_float)
+
+
+def test_conditional_compareop(fieldview_backend):
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+
+    @field_operator(backend=fieldview_backend)
+    def conditional_promotion(a: Field[[IDim], float64]) -> Field[[IDim], float64]:
+        return where(a != a, a, 10.0)
+
+    conditional_promotion(a_I_float, out=out_I_float, offset_provider={})
+
+    assert np.allclose(
+        np.where(np.asarray(a_I_float) != np.asarray(a_I_float), a_I_float, 10), out_I_float
+    )
+
+
+def test_conditional_shifted(fieldview_backend):
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    mask = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+
+    @field_operator()
+    def conditional_shifted(
+        mask: Field[[IDim], bool], a: Field[[IDim], float64], b: Field[[IDim], float64]
+    ) -> Field[[IDim], float64]:
+        tmp = where(mask, a, b)
+        return tmp(Ioff[1])
+
+    @program(backend=fieldview_backend)
+    def conditional_program(
+        mask: Field[[IDim], bool],
+        a: Field[[IDim], float64],
+        b: Field[[IDim], float64],
+        out: Field[[IDim], float64],
+    ):
+        conditional_shifted(mask, a, b, out=out[:-1])
+
+    conditional_program(mask, a_I_float, b_I_float, out_I_float, offset_provider={"Ioff": IDim})
+
+    assert np.allclose(np.where(mask, a_I_float, b_I_float)[1:], out_I_float.array()[:-1])
+
+
+def test_promotion(fieldview_backend):
+    ksize = 5
+    a = np_as_located_field(Edge, KDim)(np.ones((size, ksize)))
+    b = np_as_located_field(KDim)(np.ones((ksize)) * 2)
+    c = np_as_located_field(Edge, KDim)(np.zeros((size, ksize)))
+
+    @field_operator(backend=fieldview_backend)
+    def promotion(
+        inp1: Field[[Edge, KDim], float64], inp2: Field[[KDim], float64]
+    ) -> Field[[Edge, KDim], float64]:
+        return inp1 / inp2
+
+    promotion(a, b, out=c, offset_provider={})
+
+    assert np.allclose((a.array() / b.array()), c)
diff --git a/tests/functional_tests/ffront_tests/test_math_unary_builtins.py b/tests/functional_tests/ffront_tests/test_math_unary_builtins.py
new file mode 100644
index 00000000..66f4dd8e
--- /dev/null
+++ b/tests/functional_tests/ffront_tests/test_math_unary_builtins.py
@@ -0,0 +1,286 @@
+# -*- coding: utf-8 -*-
+#
+# GT4Py Project - GridTools Framework
+#
+# Copyright (c) 2014-2021, 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 functional.ffront.decorator import field_operator
+from functional.ffront.fbuiltins import (
+    Field,
+    cbrt,
+    ceil,
+    cos,
+    cosh,
+    exp,
+    float64,
+    floor,
+    int64,
+    isfinite,
+    isinf,
+    isnan,
+    log,
+    sin,
+    sinh,
+    sqrt,
+    tan,
+    tanh,
+    trunc,
+)
+
+from .ffront_test_utils import *
+
+
+# Math builtins
+
+
+def test_arithmetic(fieldview_backend):
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+
+    @field_operator(backend=fieldview_backend)
+    def arithmetic(
+        inp1: Field[[IDim], float64], inp2: Field[[IDim], float64]
+    ) -> Field[[IDim], float64]:
+        return (inp1 + inp2 / 3.0 - inp2) * 2.0
+
+    arithmetic(a_I_float, b_I_float, out=out_I_float, offset_provider={})
+    expected = (a_I_float.array() + b_I_float.array() / 3.0 - b_I_float.array()) * 2.0
+    assert np.allclose(expected, out_I_float)
+
+
+def test_power(fieldview_backend):
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+
+    @field_operator(backend=fieldview_backend)
+    def pow(inp1: Field[[IDim], float64]) -> Field[[IDim], float64]:
+        return inp1**2
+
+    pow(a_I_float, out=out_I_float, offset_provider={})
+    assert np.allclose(a_I_float.array() ** 2, out_I_float)
+
+
+def test_floordiv(fieldview_backend):
+    a_I_int = np_as_located_field(IDim)(np.random.randn(size).astype("int64"))
+    out_I_int = np_as_located_field(IDim)(np.zeros((size,), dtype=int64))
+
+    if fieldview_backend == gtfn_cpu.run_gtfn:
+        pytest.skip("FloorDiv not yet supported.")
+
+    @field_operator(backend=fieldview_backend)
+    def floorDiv(inp1: Field[[IDim], int64]) -> Field[[IDim], int64]:
+        return inp1 // 2
+
+    floorDiv(a_I_int, out=out_I_int, offset_provider={})
+    assert np.allclose(a_I_int.array() // 2, out_I_int)
+
+
+def test_mod(fieldview_backend):
+    a_I_int = np_as_located_field(IDim)(np.random.randn(size).astype("int64"))
+    out_I_int = np_as_located_field(IDim)(np.zeros((size,), dtype=int64))
+
+    if fieldview_backend == gtfn_cpu.run_gtfn:
+        pytest.skip("Modulo not yet supported.")
+
+    @field_operator(backend=fieldview_backend)
+    def mod_fieldop(inp1: Field[[IDim], int64]) -> Field[[IDim], int64]:
+        return inp1 % 2
+
+    mod_fieldop(a_I_int, out=out_I_int, offset_provider={})
+    assert np.allclose(a_I_int.array() % 2, out_I_int)
+
+
+def test_bit_xor(fieldview_backend):
+    a_I_bool = np_as_located_field(IDim)(np.random.randn(size).astype(bool))
+    b_I_bool = np_as_located_field(IDim)(np.random.randn(size).astype(bool))
+    out_I_bool = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+
+    @field_operator(backend=fieldview_backend)
+    def binary_xor(inp1: Field[[IDim], bool], inp2: Field[[IDim], bool]) -> Field[[IDim], bool]:
+        return inp1 ^ inp2
+
+    binary_xor(a_I_bool, b_I_bool, out=out_I_bool, offset_provider={})
+    assert np.allclose(a_I_bool.array() ^ b_I_bool.array(), out_I_bool)
+
+
+def test_bit_and(fieldview_backend):
+    a_I_bool = np_as_located_field(IDim)(np.random.randn(size).astype(bool))
+    b_I_bool = np_as_located_field(IDim)(np.random.randn(size).astype(bool))
+    out_I_bool = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+
+    @field_operator(backend=fieldview_backend)
+    def bit_and(inp1: Field[[IDim], bool], inp2: Field[[IDim], bool]) -> Field[[IDim], bool]:
+        return inp1 & inp2 & True
+
+    bit_and(a_I_bool, b_I_bool, out=out_I_bool, offset_provider={})
+    assert np.allclose(a_I_bool.array() & b_I_bool.array(), out_I_bool)
+
+
+def test_bit_or(fieldview_backend):
+    a_I_bool = np_as_located_field(IDim)(np.random.randn(size).astype(bool))
+    b_I_bool = np_as_located_field(IDim)(np.random.randn(size).astype(bool))
+    out_I_bool = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+
+    @field_operator(backend=fieldview_backend)
+    def bit_or(inp1: Field[[IDim], bool], inp2: Field[[IDim], bool]) -> Field[[IDim], bool]:
+        return inp1 | inp2 | True
+
+    bit_or(a_I_bool, b_I_bool, out=out_I_bool, offset_provider={})
+    assert np.allclose(a_I_bool.array() | b_I_bool.array(), out_I_bool)
+
+
+# Unary builtins
+
+
+def test_unary_neg(fieldview_backend):
+    a_I_int = np_as_located_field(IDim)(np.random.randn(size).astype("int64"))
+    out_I_int = np_as_located_field(IDim)(np.zeros((size,), dtype=int64))
+
+    @field_operator(backend=fieldview_backend)
+    def uneg(inp: Field[[IDim], int64]) -> Field[[IDim], int64]:
+        return -inp
+
+    uneg(a_I_int, out=out_I_int, offset_provider={})
+    assert np.allclose(-a_I_int.array(), out_I_int)
+
+
+def test_unary_invert(fieldview_backend):
+    a_I_bool = np_as_located_field(IDim)(np.random.randn(size).astype(bool))
+    out_I_bool = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+
+    @field_operator(backend=fieldview_backend)
+    def tilde_fieldop(inp1: Field[[IDim], bool]) -> Field[[IDim], bool]:
+        return ~inp1
+
+    tilde_fieldop(a_I_bool, out=out_I_bool, offset_provider={})
+    assert np.allclose(~a_I_bool.array(), out_I_bool)
+
+
+def test_unary_not(fieldview_backend):
+    a_I_bool = np_as_located_field(IDim)(np.random.randn(size).astype(bool))
+    out_I_bool = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+
+    @field_operator(backend=fieldview_backend)
+    def not_fieldop(inp1: Field[[IDim], bool]) -> Field[[IDim], bool]:
+        return not inp1
+
+    not_fieldop(a_I_bool, out=out_I_bool, offset_provider={})
+    assert np.allclose(~a_I_bool.array(), out_I_bool)
+
+
+# Trig builtins
+
+
+def test_basic_trig(fieldview_backend):
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+
+    @field_operator(backend=fieldview_backend)
+    def basic_trig_fieldop(
+        inp1: Field[[IDim], float64], inp2: Field[[IDim], float64]
+    ) -> Field[[IDim], float64]:
+        return sin(cos(inp1)) - sinh(cosh(inp2)) + tan(inp1) - tanh(inp2)
+
+    basic_trig_fieldop(a_I_float, b_I_float, out=out_I_float, offset_provider={})
+
+    expected = (
+        np.sin(np.cos(a_I_float))
+        - np.sinh(np.cosh(b_I_float))
+        + np.tan(a_I_float)
+        - np.tanh(b_I_float)
+    )
+    assert np.allclose(expected, out_I_float)
+
+
+def test_exp_log(fieldview_backend):
+    a_float = np_as_located_field(IDim)(np.ones((size)))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+
+    @field_operator(backend=fieldview_backend)
+    def exp_log_fieldop(
+        inp1: Field[[IDim], float64], inp2: Field[[IDim], float64]
+    ) -> Field[[IDim], float64]:
+        return log(inp1) - exp(inp2)
+
+    exp_log_fieldop(a_float, b_I_float, out=out_I_float, offset_provider={})
+
+    expected = np.log(a_float) - np.exp(b_I_float)
+    assert np.allclose(expected, out_I_float)
+
+
+def test_roots(fieldview_backend):
+    a_float = np_as_located_field(IDim)(np.ones((size)))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+
+    @field_operator(backend=fieldview_backend)
+    def roots_fieldop(
+        inp1: Field[[IDim], float64], inp2: Field[[IDim], float64]
+    ) -> Field[[IDim], float64]:
+        return sqrt(inp1) - cbrt(inp2)
+
+    roots_fieldop(a_float, b_I_float, out=out_I_float, offset_provider={})
+
+    expected = np.sqrt(a_float) - np.cbrt(b_I_float)
+    assert np.allclose(expected, out_I_float)
+
+
+def test_is_values(fieldview_backend):
+    out_bool_1 = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+    out_bool_2 = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_bool = np_as_located_field(IDim)(np.zeros((size,), dtype=bool))
+
+    @field_operator(backend=fieldview_backend)
+    def is_isinf_fieldop(inp1: Field[[IDim], float64]) -> Field[[IDim], bool]:
+        return isinf(inp1)
+
+    is_isinf_fieldop(a_I_float, out=out_I_bool, offset_provider={})
+    expected = np.isinf(a_I_float)
+    assert np.allclose(expected, out_I_bool)
+
+    @field_operator(backend=fieldview_backend)
+    def is_isnan_fieldop(inp1: Field[[IDim], float64]) -> Field[[IDim], bool]:
+        return isnan(inp1)
+
+    is_isnan_fieldop(a_I_float, out=out_bool_1, offset_provider={})
+    expected = np.isnan(a_I_float)
+    assert np.allclose(expected, out_bool_1)
+
+    @field_operator(backend=fieldview_backend)
+    def is_isfinite_fieldop(inp1: Field[[IDim], float64]) -> Field[[IDim], bool]:
+        return isfinite(inp1)
+
+    is_isfinite_fieldop(a_I_float, out=out_bool_2, offset_provider={})
+    expected = np.isfinite(a_I_float)
+    assert np.allclose(expected, out_bool_2)
+
+
+def test_rounding_funs(fieldview_backend):
+    a_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    b_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+    out_I_float = np_as_located_field(IDim)(np.random.randn(size).astype("float64"))
+
+    @field_operator(backend=fieldview_backend)
+    def rounding_funs_fieldop(
+        inp1: Field[[IDim], float64], inp2: Field[[IDim], float64]
+    ) -> Field[[IDim], float64]:
+        return floor(inp1) - ceil(inp2) + trunc(inp1)
+
+    rounding_funs_fieldop(a_I_float, b_I_float, out=out_I_float, offset_provider={})
+
+    expected = np.floor(a_I_float) - np.ceil(b_I_float) + np.trunc(a_I_float)
+    assert np.allclose(expected, out_I_float)
diff --git a/tests/functional_tests/ffront_tests/test_type_deduction.py b/tests/functional_tests/ffront_tests/test_type_deduction.py
index 58ff835c..b8a84a06 100644
--- a/tests/functional_tests/ffront_tests/test_type_deduction.py
+++ b/tests/functional_tests/ffront_tests/test_type_deduction.py
@@ -21,6 +21,7 @@ from functional.ffront.fbuiltins import (
     Dimension,
     Field,
     FieldOffset,
+    astype,
     broadcast,
     float32,
     float64,
@@ -646,3 +647,35 @@ def test_where_mixed_dims():
             ),
         ]
     )
+
+
+def test_astype_dtype():
+    ADim = Dimension("ADim")
+
+    def simple_astype(a: Field[[ADim], float64]):
+        return astype(a, bool)
+
+    parsed = FieldOperatorParser.apply_to_function(simple_astype)
+
+    assert parsed.body[0].value.type == ct.FieldType(
+        dims=[ADim], dtype=ct.ScalarType(kind=ct.ScalarKind.BOOL)
+    )
+
+
+def test_mod_floats():
+    def modulo_floats(inp: Field[..., float]):
+        return inp % 3.0
+
+    with pytest.raises(
+        FieldOperatorTypeDeductionError,
+        match=r"Type float64 can not be used in operator `%`",
+    ):
+        _ = FieldOperatorParser.apply_to_function(modulo_floats)
+
+
+def test_undefined_symbols():
+    def return_undefined():
+        return undefined_symbol
+
+    with pytest.raises(FieldOperatorTypeDeductionError, match="Undeclared symbol"):
+        _ = FieldOperatorParser.apply_to_function(return_undefined)
diff --git a/tests/functional_tests/iterator_tests/test_builtins.py b/tests/functional_tests/iterator_tests/test_builtins.py
index e97c926c..10b41e46 100644
--- a/tests/functional_tests/iterator_tests/test_builtins.py
+++ b/tests/functional_tests/iterator_tests/test_builtins.py
@@ -4,15 +4,22 @@ from typing import Callable, Iterable
 import numpy as np
 import pytest
 
+from functional.iterator import builtins as it_builtins
 from functional.iterator.builtins import (
     and_,
+    bool,
     can_deref,
     cartesian_domain,
+    cast_,
     deref,
     divides,
     eq,
+    float32,
+    float64,
     greater,
     if_,
+    int32,
+    int64,
     less,
     lift,
     minus,
@@ -137,8 +144,6 @@ def test_arithmetic_and_logical_builtins(program_processor, builtin, inputs, exp
 @pytest.mark.parametrize("as_column", [False, True])
 @pytest.mark.parametrize("builtin_name, inputs", math_builtin_test_data())
 def test_math_function_builtins(program_processor, builtin_name, inputs, as_column):
-    from functional.iterator import builtins as it_builtins
-
     program_processor, validate = program_processor
 
     if program_processor == type_check.check:
@@ -251,3 +256,46 @@ def test_can_deref(program_processor, stencil):
 
 #     if validate:
 #         assert np.allclose(np.asarray(out), 1.0)
+
+
+# There is no straight-forward way to test cast, because when we define the
+# output buffer with the cast-to type an implicit conversion will happen even
+# if no explicit cast was done.
+# Therefore, we have to set up the test in a way that the explicit cast is required,
+# e.g. by a combination of explicit an implicit cast.
+# Test setup:
+# - Input buffer is setup with the dtype from `input_value`
+# - Output buffer is setup with the type of the `expected_value`
+# `expected_value` should be chosen with a different type than the explict cast-to type `dtype`.
+@pytest.mark.parametrize(
+    "input_value, dtype, expected_value",
+    [
+        (float64("0.1"), float32, float64(float32("0.1"))),
+        (int64(42), bool, int64(1)),
+        (int64(2147483648), int32, int64(-2147483648)),
+        (int64(2147483648), int64, int64(2147483648)),  # int64 does not accidentally down-cast
+    ],
+)
+@pytest.mark.parametrize("as_column", [False, True])
+def test_cast(program_processor, as_column, input_value, dtype, expected_value):
+    program_processor, validate = program_processor
+    column_axis = IDim if as_column else None
+
+    inp = asfield(*asarray(input_value))[0]
+    out = asfield((np.zeros_like(*asarray(expected_value))))[0]
+
+    @fundef
+    def sten_cast(value):
+        return cast_(deref(value), dtype)
+
+    run_processor(
+        sten_cast[{IDim: range(1)}],
+        program_processor,
+        inp,
+        out=out,
+        offset_provider={},
+        column_axis=column_axis,
+    )
+
+    if validate:
+        assert out[0] == expected_value
diff --git a/tests/functional_tests/iterator_tests/test_cse.py b/tests/functional_tests/iterator_tests/test_cse.py
index bb50c20e..2e23e390 100644
--- a/tests/functional_tests/iterator_tests/test_cse.py
+++ b/tests/functional_tests/iterator_tests/test_cse.py
@@ -1,16 +1,12 @@
 import pytest
 
 from eve.utils import UIDs
+from functional.ffront import itir_makers as im
 from functional.iterator import ir
 from functional.iterator.transforms.cse import CommonSubexpressionElimination as CSE
 
 
-@pytest.fixture
-def fresh_uid_sequence():
-    UIDs.reset_sequence()
-
-
-def test_trivial(fresh_uid_sequence):
+def test_trivial():
     common = ir.FunCall(fun=ir.SymRef(id="plus"), args=[ir.SymRef(id="x"), ir.SymRef(id="y")])
     testee = ir.FunCall(fun=ir.SymRef(id="plus"), args=[common, common])
     expected = ir.FunCall(
@@ -26,7 +22,7 @@ def test_trivial(fresh_uid_sequence):
     assert actual == expected
 
 
-def test_lambda_capture(fresh_uid_sequence):
+def test_lambda_capture():
     common = ir.FunCall(fun=ir.SymRef(id="plus"), args=[ir.SymRef(id="x"), ir.SymRef(id="y")])
     testee = ir.FunCall(fun=ir.Lambda(params=[ir.Sym(id="x")], expr=common), args=[common])
     expected = testee
@@ -34,7 +30,7 @@ def test_lambda_capture(fresh_uid_sequence):
     assert actual == expected
 
 
-def test_lambda_no_capture(fresh_uid_sequence):
+def test_lambda_no_capture():
     common = ir.FunCall(fun=ir.SymRef(id="plus"), args=[ir.SymRef(id="x"), ir.SymRef(id="y")])
     testee = ir.FunCall(fun=ir.Lambda(params=[ir.Sym(id="z")], expr=common), args=[common])
     expected = ir.FunCall(
@@ -51,20 +47,43 @@ def test_lambda_no_capture(fresh_uid_sequence):
     assert actual == expected
 
 
-def test_lambda_nested_capture(fresh_uid_sequence):
-    common = ir.FunCall(fun=ir.SymRef(id="plus"), args=[ir.SymRef(id="x"), ir.SymRef(id="y")])
+def test_lambda_nested_capture():
+    def common_expr():
+        return ir.FunCall(fun=ir.SymRef(id="plus"), args=[ir.SymRef(id="x"), ir.SymRef(id="y")])
+
     testee = ir.FunCall(
-        fun=ir.Lambda(params=[ir.Sym(id="x"), ir.Sym(id="y")], expr=common), args=[common, common]
+        fun=ir.Lambda(params=[ir.Sym(id="x"), ir.Sym(id="y")], expr=common_expr()),
+        args=[common_expr(), common_expr()],
     )
     expected = ir.FunCall(
         fun=ir.Lambda(
             params=[ir.Sym(id="_cs_1")],
             expr=ir.FunCall(
-                fun=ir.Lambda(params=[ir.Sym(id="x"), ir.Sym(id="y")], expr=common),
+                fun=ir.Lambda(params=[ir.Sym(id="x"), ir.Sym(id="y")], expr=common_expr()),
                 args=[ir.SymRef(id="_cs_1"), ir.SymRef(id="_cs_1")],
             ),
         ),
-        args=[common],
+        args=[common_expr()],
+    )
+    actual = CSE().visit(testee)
+    assert actual == expected
+
+
+def test_lambda_nested_capture_scoped():
+    def common_expr():
+        return ir.FunCall(fun=ir.SymRef(id="plus"), args=[ir.SymRef(id="x"), ir.SymRef(id="x")])
+
+    # λ(x) → (λ(y) → y + (x + x + (x + x)))(z)
+    testee = im.lambda__("x")(
+        im.call_(im.lambda__("y")(im.plus_("y", im.plus_(common_expr(), common_expr()))))("z")
+    )
+    # λ(x) → (λ(_cs_1) → (λ(y) → y + (_cs_1 + _cs_1))(z))(x + x)
+    expected = im.lambda__("x")(
+        im.call_(
+            im.lambda__("_cs_1")(
+                im.call_(im.lambda__("y")(im.plus_("y", im.plus_("_cs_1", "_cs_1"))))("z")
+            )
+        )(common_expr())
     )
     actual = CSE().visit(testee)
     assert actual == expected
diff --git a/tests/functional_tests/iterator_tests/test_fvm_nabla.py b/tests/functional_tests/iterator_tests/test_fvm_nabla.py
index 8cebcfee..48d18117 100644
--- a/tests/functional_tests/iterator_tests/test_fvm_nabla.py
+++ b/tests/functional_tests/iterator_tests/test_fvm_nabla.py
@@ -224,6 +224,8 @@ def test_nabla(program_processor, lift_mode):
     program_processor, validate = program_processor
     if program_processor == run_gtfn:
         pytest.xfail("TODO: gtfn bindings don't support unstructured")
+    if lift_mode != LiftMode.FORCE_INLINE:
+        pytest.xfail("shifted input arguments not supported for lift_mode != LiftMode.FORCE_INLINE")
     setup = nabla_setup()
 
     sign = np_as_located_field(Vertex, V2E)(setup.sign_field)
diff --git a/tests/functional_tests/iterator_tests/test_inline_lifts.py b/tests/functional_tests/iterator_tests/test_inline_lifts.py
index d8b6233d..b5c11536 100644
--- a/tests/functional_tests/iterator_tests/test_inline_lifts.py
+++ b/tests/functional_tests/iterator_tests/test_inline_lifts.py
@@ -1,95 +1,92 @@
 import pytest
 
+from functional.ffront import itir_makers as im
 from functional.iterator import ir
 from functional.iterator.transforms.inline_lifts import InlineLifts
 
 
 def inline_lift_test_data():
-    foo_shift = ir.FunCall(
-        fun=ir.SymRef(id="shift"), args=[ir.OffsetLiteral(value="I"), ir.OffsetLiteral(value=1)]
-    )
     return [
         # (testee, expected)
         (
             # deref(lift(f)(args...)) -> f(args...)
-            ir.FunCall(
-                fun=ir.SymRef(id="deref"),
-                args=[
-                    ir.FunCall(
-                        fun=ir.FunCall(fun=ir.SymRef(id="lift"), args=[ir.SymRef(id="f")]),
-                        args=[ir.SymRef(id="arg")],
-                    )
-                ],
-            ),
-            ir.FunCall(fun=ir.SymRef(id="f"), args=[ir.SymRef(id="arg")]),
+            im.deref_(im.lift_("f")("arg")),
+            im.call_("f")("arg"),
         ),
         (
             # deref(shift(...)(lift(f)(args...))) -> f(shift(...)(args)...)
-            ir.FunCall(
-                fun=ir.SymRef(id="deref"),
-                args=[
-                    ir.FunCall(
-                        fun=foo_shift,
-                        args=[
-                            ir.FunCall(
-                                fun=ir.FunCall(fun=ir.SymRef(id="lift"), args=[ir.SymRef(id="f")]),
-                                args=[ir.SymRef(id="arg")],
-                            )
-                        ],
-                    )
-                ],
-            ),
-            ir.FunCall(
-                fun=ir.SymRef(id="f"), args=[ir.FunCall(fun=foo_shift, args=[ir.SymRef(id="arg")])]
-            ),
+            im.deref_(im.shift_("I", 1)(im.lift_("f")("arg"))),
+            im.call_("f")(im.shift_("I", 1)("arg")),
         ),
         (
             # can_deref(lift(f)(args...)) -> and(can_deref(arg[0]), and(can_deref(arg[1]), ...))
-            ir.FunCall(
-                fun=ir.SymRef(id="can_deref"),
-                args=[
-                    ir.FunCall(
-                        fun=ir.FunCall(fun=ir.SymRef(id="lift"), args=[ir.SymRef(id="f")]),
-                        args=[ir.SymRef(id="arg1"), ir.SymRef(id="arg2")],
+            im.call_("can_deref")(im.lift_("f")("arg1", "arg2")),
+            im.and__(im.call_("can_deref")("arg1"), im.call_("can_deref")("arg2")),
+        ),
+        (
+            # can_deref(shift(...)(lift(f)(args...)) -> and(can_deref(shift(...)(arg[0])), and(can_deref(shift(...)(arg[1])), ...))
+            im.call_("can_deref")(im.shift_("I", 1)(im.lift_("f")("arg1", "arg2"))),
+            im.and__(
+                im.call_("can_deref")(im.shift_("I", 1)("arg1")),
+                im.call_("can_deref")(im.shift_("I", 1)("arg2")),
+            ),
+        ),
+        (
+            # (↑(λ(arg1, arg2) → ·arg1 + ·arg2))((↑(λ(arg1, arg2) → ·arg1 × ·arg2))(a, b), c)
+            im.lift_(
+                im.lambda__(im.sym("arg1"), im.sym("arg2"))(
+                    im.plus_(im.deref_("arg1"), im.deref_("arg2"))
+                )
+            )(
+                im.lift_(
+                    im.lambda__(im.sym("arg1"), im.sym("arg2"))(
+                        im.multiplies_(im.deref_("arg1"), im.deref_("arg2"))
                     )
-                ],
+                )(im.ref("a"), im.ref("b")),
+                im.ref("c"),
+            ),
+            # (↑(λ(a, b, c) → ·a × ·b + ·c))(a, b, c)
+            im.lift_(
+                im.lambda__("a", "b", "c")(
+                    im.plus_(im.multiplies_(im.deref_("a"), im.deref_("b")), im.deref_("c"))
+                )
+            )(im.ref("a"), im.ref("b"), im.ref("c")),
+        ),
+        (
+            # (↑(λ(arg1, arg2) → ·arg1 + ·arg2))(arg1, (↑(λ(arg1) → ·arg1))(f()))
+            im.lift_(
+                im.lambda__(im.sym("arg1"), im.sym("arg2"))(
+                    im.plus_(im.deref_("arg1"), im.deref_("arg2"))
+                )
+            )(
+                im.ref("arg1"),
+                # The `f()` argument must not be inlined and becomes an argument to the outer
+                # lift. The inliner assigns a new symbol for this argument that by default is
+                # named like the respective argument of the inner lift, i.e. here the `arg1`
+                # below. Here we test that if this symbol collides with a symbol of the outer
+                # lifted stencil, i.e. the lambda function above, the collision is properly
+                # resolved.
+                im.lift_(im.lambda__(im.sym("arg1"))(im.deref_("arg1")))(im.call_("f")()),
             ),
-            ir.FunCall(
-                fun=ir.SymRef(id="and_"),
-                args=[
-                    ir.FunCall(fun=ir.SymRef(id="can_deref"), args=[ir.SymRef(id="arg1")]),
-                    ir.FunCall(fun=ir.SymRef(id="can_deref"), args=[ir.SymRef(id="arg2")]),
-                ],
+            # (↑(λ(arg1, arg1_) → ·arg1 + ·arg1_))(arg1, f())
+            im.lift_(im.lambda__("arg1", "arg1_")(im.plus_(im.deref_("arg1"), im.deref_("arg1_"))))(
+                im.ref("arg1"), im.call_("f")()
             ),
         ),
         (
-            # can_deref(shift(...)(lift(f)(args...)) -> and(can_deref(shift(...)(arg[0])), and(can_deref(shift(...)(arg[1])), ...))
-            ir.FunCall(
-                fun=ir.SymRef(id="can_deref"),
-                args=[
-                    ir.FunCall(
-                        fun=foo_shift,
-                        args=[
-                            ir.FunCall(
-                                fun=ir.FunCall(fun=ir.SymRef(id="lift"), args=[ir.SymRef(id="f")]),
-                                args=[ir.SymRef(id="arg1"), ir.SymRef(id="arg2")],
-                            )
-                        ],
-                    )
-                ],
+            # similar to the test case above, but the collision is with a symbol from
+            # the outer scope
+            # λ(arg1) → (↑(λ(arg2) → ·arg2 + arg1))((↑(λ(arg1) → ·arg1))(f()))
+            im.lambda__("arg1")(
+                im.lift_(im.lambda__(im.sym("arg2"))(im.plus_(im.deref_("arg2"), im.ref("arg1"))))(
+                    im.lift_(im.lambda__(im.sym("arg1"))(im.deref_("arg1")))(im.call_("f")())
+                )
             ),
-            ir.FunCall(
-                fun=ir.SymRef(id="and_"),
-                args=[
-                    ir.FunCall(
-                        fun=ir.SymRef(id="can_deref"),
-                        args=[ir.FunCall(fun=foo_shift, args=[ir.SymRef(id="arg1")])],
-                    ),
-                    ir.FunCall(
-                        fun=ir.SymRef(id="can_deref"),
-                        args=[ir.FunCall(fun=foo_shift, args=[ir.SymRef(id="arg2")])],
-                    ),
-                ],
+            # λ(arg1) → (↑(λ(arg1_) → ·arg1_ + arg1))(f())
+            im.lambda__("arg1")(
+                im.lift_(
+                    im.lambda__(im.sym("arg1_"))(im.plus_(im.deref_("arg1_"), im.ref("arg1")))
+                )(im.call_("f")())
             ),
         ),
     ]
diff --git a/tests/functional_tests/iterator_tests/test_simple_inline_heuristic.py b/tests/functional_tests/iterator_tests/test_simple_inline_heuristic.py
index ed66bfcf..61caf0ae 100644
--- a/tests/functional_tests/iterator_tests/test_simple_inline_heuristic.py
+++ b/tests/functional_tests/iterator_tests/test_simple_inline_heuristic.py
@@ -1,7 +1,7 @@
 import pytest
 
 from functional.iterator import ir
-from functional.iterator.transforms.simple_inline_heuristic import heuristic
+from functional.iterator.transforms.simple_inline_heuristic import is_eligible_for_inlining
 
 
 @pytest.fixture
@@ -25,19 +25,26 @@ def scan():
     )
 
 
-def test_trivial():
+@pytest.mark.parametrize("is_scan_context", [True, False])
+def test_trivial(is_scan_context):
+    # `is_scan_context == True` covers this pattern:
+    # `↑(scan(λ(acc, it) → acc + ·↑(deref)(it)))(...)` where the inner lift should not be inlined.
+    expected = not is_scan_context
     testee = ir.FunCall(
-        fun=ir.SymRef(id="lift"),
-        args=[ir.SymRef(id="deref")],
+        fun=ir.FunCall(
+            fun=ir.SymRef(id="lift"),
+            args=[ir.SymRef(id="deref")],
+        ),
+        args=[ir.SymRef(id="it")],
     )
-    predicate = heuristic(testee)
-    assert predicate(testee)
+    assert expected == is_eligible_for_inlining(testee, is_scan_context)
 
 
 def test_scan(scan):
-    testee = ir.FunCall(fun=ir.SymRef(id="lift"), args=[scan])
-    predicate = heuristic(testee)
-    assert not predicate(testee)
+    testee = ir.FunCall(
+        fun=ir.FunCall(fun=ir.SymRef(id="lift"), args=[scan]), args=[ir.SymRef(id="it")]
+    )
+    assert not is_eligible_for_inlining(testee, False)
 
 
 def test_scan_with_lifted_arg(scan):
@@ -53,6 +60,4 @@ def test_scan_with_lifted_arg(scan):
             )
         ],
     )
-    predicate = heuristic(testee)
-    assert not predicate(testee.fun)
-    assert not predicate(testee.args[0].fun)
+    assert not is_eligible_for_inlining(testee, False)
diff --git a/tests/functional_tests/iterator_tests/test_toy_connectivity.py b/tests/functional_tests/iterator_tests/test_toy_connectivity.py
index 75c0aa7e..76f90aef 100644
--- a/tests/functional_tests/iterator_tests/test_toy_connectivity.py
+++ b/tests/functional_tests/iterator_tests/test_toy_connectivity.py
@@ -2,6 +2,7 @@ import numpy as np
 import pytest
 
 from functional.common import Dimension
+from functional.iterator import transforms
 from functional.iterator.builtins import deref, lift, reduce, shift
 from functional.iterator.embedded import (
     NeighborTableOffsetProvider,
@@ -452,6 +453,9 @@ def test_sparse_shifted_stencil_reduce(program_processor_no_gtfn_exec, lift_mode
         pytest.xfail("We cannot unroll a reduction on a sparse field only.")
         # With our current understanding, this iterator IR program is illegal, however we might want to fix it and therefore keep the test for now.
 
+    if lift_mode != transforms.LiftMode.FORCE_INLINE:
+        pytest.xfail("shifted input arguments not supported for lift_mode != LiftMode.FORCE_INLINE")
+
     inp = np_as_located_field(Vertex, V2V)(v2v_arr)
     out = np_as_located_field(Vertex)(np.zeros([9]))
 
diff --git a/tests/functional_tests/iterator_tests/test_trivial.py b/tests/functional_tests/iterator_tests/test_trivial.py
index e33cb28d..41a795c2 100644
--- a/tests/functional_tests/iterator_tests/test_trivial.py
+++ b/tests/functional_tests/iterator_tests/test_trivial.py
@@ -2,6 +2,7 @@ import numpy as np
 import pytest
 
 from functional.common import Dimension, DimensionKind
+from functional.iterator import transforms
 from functional.iterator.builtins import *
 from functional.iterator.embedded import np_as_located_field
 from functional.iterator.runtime import closure, fendef, fundef, offset
@@ -61,6 +62,44 @@ def test_trivial(program_processor, lift_mode):
         assert np.allclose(out[:, :, 0], out_s)
 
 
+@fundef
+def stencil_shifted_arg_to_lift(inp):
+    return deref(lift(deref)(shift(I, -1)(inp)))
+
+
+def test_shifted_arg_to_lift(program_processor, lift_mode):
+    program_processor, validate = program_processor
+
+    if program_processor == run_gtfn:
+        pytest.xfail("origin not yet supported in gtfn")
+
+    if lift_mode != transforms.LiftMode.FORCE_INLINE:
+        pytest.xfail("shifted input arguments not supported for lift_mode != LiftMode.FORCE_INLINE")
+
+    rng = np.random.default_rng()
+    inp = rng.uniform(size=(5, 7))
+    out = np.zeros_like(inp)
+    out[1:, :] = inp[:-1, :]
+    shape = (out.shape[0], out.shape[1])
+
+    inp_s = np_as_located_field(IDim, JDim, origin={IDim: 0, JDim: 0})(inp[:, :])
+    out_s = np_as_located_field(IDim, JDim)(np.zeros_like(inp[:, :]))
+
+    run_processor(
+        stencil_shifted_arg_to_lift[
+            cartesian_domain(named_range(IDim, 1, shape[0]), named_range(JDim, 0, shape[1]))
+        ],
+        program_processor,
+        inp_s,
+        out=out_s,
+        lift_mode=lift_mode,
+        offset_provider={"I": IDim, "J": JDim},
+    )
+
+    if validate:
+        assert np.allclose(out, out_s)
+
+
 @fendef
 def fen_direct_deref(i_size, j_size, out, inp):
     closure(