Repository: GridTools/gt4py
Pull request #1312 (2023-08-04 11:44:05)

Program with bound arguments
Allow for program signature to not include all required fields by the `field_operator` by setting args keyword arguments 
```python
@gtx.program
def program_bound_args(a: cases.IField, scalar: int32, bool_val: bool, out: cases.IField):
    fieldop_bound_args(a, scalar, bool_val, out=out)

prog_bounds = program_bound_args.with_bound_args(scalar=scalar, bool_val=True)
cases.verify(cartesian_case, prog_bounds, a, out, inout=out, ref=ref)
```


Patch:
diff --git a/src/gt4py/next/ffront/decorator.py b/src/gt4py/next/ffront/decorator.py
index 5b4d32b5..12ab3955 100644
--- a/src/gt4py/next/ffront/decorator.py
+++ b/src/gt4py/next/ffront/decorator.py
@@ -52,6 +52,7 @@ from gt4py.next.ffront.past_passes.type_deduction import ProgramTypeDeduction
 from gt4py.next.ffront.past_to_itir import ProgramLowering
 from gt4py.next.ffront.source_utils import SourceDefinition, get_closure_vars_from_function
 from gt4py.next.iterator import ir as itir
+from gt4py.next.iterator.ir_makers import literal_from_value, promote_to_const_iterator, ref, sym
 from gt4py.next.program_processors import processor_interface as ppi
 from gt4py.next.program_processors.runners import roundtrip
 from gt4py.next.type_system import type_info, type_specifications as ts, type_translation
@@ -219,6 +220,40 @@ class Program:
     def with_grid_type(self, grid_type: GridType) -> Program:
         return dataclasses.replace(self, grid_type=grid_type)
 
+    def with_bound_args(self, **kwargs) -> ProgramWithBoundArgs:
+        """
+        Bind scalar, i.e. non field, program arguments.
+
+        Example (pseudo-code):
+
+        >>> import gt4py.next as gtx
+        >>> @gtx.program  # doctest: +SKIP
+        ... def program(condition: bool, out: gtx.Field[[IDim], float]):  # noqa: F821
+        ...     sample_field_operator(condition, out=out)  # noqa: F821
+
+        Create a new program from `program` with the `condition` parameter set to `True`:
+
+        >>> program_with_bound_arg = program.with_bound_args(condition=True)  # doctest: +SKIP
+
+        The resulting program is equivalent to
+
+        >>> @gtx.program  # doctest: +SKIP
+        ... def program(condition: bool, out: gtx.Field[[IDim], float]):  # noqa: F821
+        ...     sample_field_operator(condition=True, out=out)  # noqa: F821
+
+        and can be executed without passing `condition`.
+
+        >>> program_with_bound_arg(out, offset_provider={})  # doctest: +SKIP
+        """
+        for key in kwargs.keys():
+            if all(key != param.id for param in self.past_node.params):
+                raise TypeError(f"Keyword argument `{key}` is not a valid program parameter.")
+
+        return ProgramWithBoundArgs(
+            bound_args=kwargs,
+            **{field.name: getattr(self, field.name) for field in dataclasses.fields(self)},
+        )
+
     @functools.cached_property
     def _all_closure_vars(self) -> dict[str, Any]:
         return _get_closure_vars_recursively(self.closure_vars)
@@ -358,6 +393,82 @@ class Program:
         return iter(scanops_per_axis.keys()).__next__()
 
 
+@dataclasses.dataclass(frozen=True)
+class ProgramWithBoundArgs(Program):
+    bound_args: dict[str, typing.Union[float, int, bool]] = None
+
+    def _process_args(self, args: tuple, kwargs: dict):
+        type_ = self.past_node.type
+        new_type = ts_ffront.ProgramType(
+            definition=ts.FunctionType(
+                kw_only_args={
+                    k: v
+                    for k, v in type_.definition.kw_only_args.items()
+                    if k not in self.bound_args.keys()
+                },
+                pos_only_args=type_.definition.pos_only_args,
+                pos_or_kw_args={
+                    k: v
+                    for k, v in type_.definition.pos_or_kw_args.items()
+                    if k not in self.bound_args.keys()
+                },
+                returns=type_.definition.returns,
+            )
+        )
+
+        arg_types = [type_translation.from_value(arg) for arg in args]
+        kwarg_types = {k: type_translation.from_value(v) for k, v in kwargs.items()}
+
+        try:
+            # This error is also catched using `accepts_args`, but we do it manually here to give
+            # a better error message.
+            for name in self.bound_args.keys():
+                if name in kwargs:
+                    raise ValueError(f"Parameter `{name}` already set as a bound argument.")
+
+            type_info.accepts_args(
+                new_type,
+                with_args=arg_types,
+                with_kwargs=kwarg_types,
+                raise_exception=True,
+            )
+        except ValueError as err:
+            bound_arg_names = ", ".join([f"`{bound_arg}`" for bound_arg in self.bound_args.keys()])
+            raise TypeError(
+                f"Invalid argument types in call to program `{self.past_node.id}` with "
+                f"bound arguments {bound_arg_names}!"
+            ) from err
+
+        full_args = [*args]
+        for index, param in enumerate(self.past_node.params):
+            if param.id in self.bound_args.keys():
+                full_args.insert(index, self.bound_args[param.id])
+
+        return super()._process_args(tuple(full_args), kwargs)
+
+    @functools.cached_property
+    def itir(self):
+        new_itir = super().itir
+        for new_clos in new_itir.closures:
+            for key in self.bound_args.keys():
+                index = next(
+                    index
+                    for index, closure_input in enumerate(new_clos.inputs)
+                    if closure_input.id == key
+                )
+                new_clos.inputs.pop(index)
+            new_args = [ref(inp.id) for inp in new_clos.inputs]
+            params = [sym(inp.id) for inp in new_clos.inputs]
+            for value in self.bound_args.values():
+                new_args.append(promote_to_const_iterator(literal_from_value(value)))
+            expr = itir.FunCall(
+                fun=new_clos.stencil,
+                args=new_args,
+            )
+            new_clos.stencil = itir.Lambda(params=params, expr=expr)
+        return new_itir
+
+
 @typing.overload
 def program(definition: types.FunctionType) -> Program:
     ...
diff --git a/src/gt4py/next/ffront/fbuiltins.py b/src/gt4py/next/ffront/fbuiltins.py
index d1d403c4..b6831b35 100644
--- a/src/gt4py/next/ffront/fbuiltins.py
+++ b/src/gt4py/next/ffront/fbuiltins.py
@@ -176,7 +176,7 @@ def where(
 
 
 @builtin_function
-def astype(field: Field, type_: type, /) -> Field:
+def astype(field: Field | gt4py_defs.ScalarT, type_: type, /) -> Field:
     raise NotImplementedError()
 
 
diff --git a/src/gt4py/next/ffront/foast_passes/type_deduction.py b/src/gt4py/next/ffront/foast_passes/type_deduction.py
index bd7eddbc..605b83a5 100644
--- a/src/gt4py/next/ffront/foast_passes/type_deduction.py
+++ b/src/gt4py/next/ffront/foast_passes/type_deduction.py
@@ -28,31 +28,32 @@ from gt4py.next.ffront.foast_passes.utils import compute_assign_indices
 from gt4py.next.type_system import type_info, type_specifications as ts, type_translation
 
 
-def boolified_type(symbol_type: ts.TypeSpec) -> ts.ScalarType | ts.FieldType:
+def with_altered_scalar_kind(
+    type_spec: ts.TypeSpec, new_scalar_kind: ts.ScalarKind
+) -> ts.ScalarType | ts.FieldType:
     """
-    Create a new symbol type from a symbol type, replacing the data type with ``bool``.
+    Given a scalar or field type, return a type with different scalar kind.
 
     Examples:
     ---------
     >>> from gt4py.next import Dimension
     >>> scalar_t = ts.ScalarType(kind=ts.ScalarKind.FLOAT64)
-    >>> print(boolified_type(scalar_t))
+    >>> print(with_altered_scalar_kind(scalar_t, ts.ScalarKind.BOOL))
     bool
 
-    >>> field_t = ts.FieldType(dims=[Dimension(value="I")], dtype=ts.ScalarType(kind=ts.ScalarKind))
-    >>> print(boolified_type(field_t))
-    Field[[I], bool]
+    >>> field_t = ts.FieldType(dims=[Dimension(value="I")], dtype=ts.ScalarType(kind=ts.ScalarKind.FLOAT64))
+    >>> print(with_altered_scalar_kind(field_t, ts.ScalarKind.FLOAT32))
+    Field[[I], float32]
     """
-    shape = None
-    if type_info.is_concrete(symbol_type):
-        shape = type_info.extract_dtype(symbol_type).shape
-    scalar_bool = ts.ScalarType(kind=ts.ScalarKind.BOOL, shape=shape)
-    type_class = type_info.type_class(symbol_type)
-    if type_class is ts.ScalarType:
-        return scalar_bool
-    elif type_class is ts.FieldType:
-        return ts.FieldType(dtype=scalar_bool, dims=type_info.extract_dims(symbol_type))
-    raise ValueError(f"Can not boolify type {symbol_type}!")
+    if isinstance(type_spec, ts.FieldType):
+        return ts.FieldType(
+            dims=type_spec.dims,
+            dtype=ts.ScalarType(kind=new_scalar_kind, shape=type_spec.dtype.shape),
+        )
+    elif isinstance(type_spec, ts.ScalarType):
+        return ts.ScalarType(kind=new_scalar_kind, shape=type_spec.shape)
+    else:
+        raise ValueError(f"Expected field or scalar type, but got {type_spec}.")
 
 
 def construct_tuple_type(
@@ -563,7 +564,10 @@ class FieldOperatorTypeDeduction(traits.VisitorWithSymbolTableTrait, NodeTransla
         try:
             # transform operands to have bool dtype and use regular promotion
             #  mechanism to handle dimension promotion
-            return type_info.promote(boolified_type(left.type), boolified_type(right.type))
+            return type_info.promote(
+                with_altered_scalar_kind(left.type, ts.ScalarKind.BOOL),
+                with_altered_scalar_kind(right.type, ts.ScalarKind.BOOL),
+            )
         except ValueError as ex:
             raise errors.DSLError(
                 node.location,
@@ -762,7 +766,9 @@ class FieldOperatorTypeDeduction(traits.VisitorWithSymbolTableTrait, NodeTransla
         ):
             return_type = cast(ts.FieldType | ts.ScalarType, node.args[0].type)
         elif func_name in fbuiltins.UNARY_MATH_FP_PREDICATE_BUILTIN_NAMES:
-            return_type = boolified_type(cast(ts.FieldType | ts.ScalarType, node.args[0].type))
+            return_type = with_altered_scalar_kind(
+                cast(ts.FieldType | ts.ScalarType, node.args[0].type), ts.ScalarKind.BOOL
+            )
         elif func_name in fbuiltins.BINARY_MATH_NUMBER_BUILTIN_NAMES:
             try:
                 return_type = type_info.promote(
@@ -817,19 +823,22 @@ class FieldOperatorTypeDeduction(traits.VisitorWithSymbolTableTrait, NodeTransla
         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, ts.FunctionType)
-        assert dtype_obj.id in fbuiltins.TYPE_BUILTIN_NAMES
-        assert isinstance(casted_obj_type, ts.FieldType)
-        assert type_info.is_arithmetic(casted_obj_type) or type_info.is_logical(casted_obj_type)
+        value, new_type = node.args
+        assert isinstance(
+            value.type, (ts.FieldType, ts.ScalarType)
+        )  # already checked using generic mechanism
+        if not isinstance(new_type, foast.Name) or new_type.id.upper() not in [
+            kind.name for kind in ts.ScalarKind
+        ]:
+            raise errors.DSLError(
+                node.location,
+                f"Invalid call to `astype`. Second argument must be a scalar type, but got {new_type}.",
+            )
 
-        return_type = ts.FieldType(
-            dims=casted_obj_type.dims,
-            dtype=self.visit(dtype_obj_type).returns,
+        return_type = with_altered_scalar_kind(
+            value.type, getattr(ts.ScalarKind, new_type.id.upper())
         )
+
         return foast.Call(
             func=node.func,
             args=node.args,
diff --git a/src/gt4py/next/iterator/transforms/constant_folding.py b/src/gt4py/next/iterator/transforms/constant_folding.py
new file mode 100644
index 00000000..cda422f3
--- /dev/null
+++ b/src/gt4py/next/iterator/transforms/constant_folding.py
@@ -0,0 +1,49 @@
+# GT4Py - GridTools Framework
+#
+# Copyright (c) 2014-2023, ETH Zurich
+# All rights reserved.
+#
+# This file is part of the GT4Py project and the GridTools framework.
+# GT4Py is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or any later
+# version. See the LICENSE.txt file at the top-level directory of this
+# distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from gt4py.eve import NodeTranslator
+from gt4py.next.iterator import embedded, ir, ir_makers as im
+
+
+class ConstantFolding(NodeTranslator):
+    @classmethod
+    def apply(cls, node: ir.Node) -> ir.Node:
+        return cls().visit(node)
+
+    def visit_FunCall(self, node: ir.FunCall):
+        # visit depth-first such that nested constant expressions (e.g. `(1+2)+3`) are properly folded
+        new_node = self.generic_visit(node)
+
+        if (
+            isinstance(new_node.fun, ir.SymRef)
+            and new_node.fun.id == "if_"
+            and isinstance(new_node.args[0], ir.Literal)
+        ):  # `if_(True, true_branch, false_branch)` -> `true_branch`
+            if new_node.args[0].value == "True":
+                new_node = new_node.args[1]
+            else:
+                new_node = new_node.args[2]
+
+        if (
+            isinstance(new_node, ir.FunCall)
+            and isinstance(new_node.fun, ir.SymRef)
+            and len(new_node.args) > 0
+            and all(isinstance(arg, ir.Literal) for arg in new_node.args)
+        ):  # `1 + 1` -> `2`
+            if new_node.fun.id in ir.ARITHMETIC_BUILTINS:
+                fun = getattr(embedded, str(new_node.fun.id))
+                arg_values = [getattr(embedded, str(arg.type))(arg.value) for arg in new_node.args]  # type: ignore[attr-defined] # arg type already established in if condition
+                new_node = im.literal_from_value(fun(*arg_values))
+
+        return new_node
diff --git a/src/gt4py/next/iterator/transforms/pass_manager.py b/src/gt4py/next/iterator/transforms/pass_manager.py
index 50786bac..62251a3e 100644
--- a/src/gt4py/next/iterator/transforms/pass_manager.py
+++ b/src/gt4py/next/iterator/transforms/pass_manager.py
@@ -18,6 +18,7 @@ from gt4py.next.iterator import ir
 from gt4py.next.iterator.transforms import simple_inline_heuristic
 from gt4py.next.iterator.transforms.collapse_list_get import CollapseListGet
 from gt4py.next.iterator.transforms.collapse_tuple import CollapseTuple
+from gt4py.next.iterator.transforms.constant_folding import ConstantFolding
 from gt4py.next.iterator.transforms.cse import CommonSubexpressionElimination
 from gt4py.next.iterator.transforms.eta_reduction import EtaReduction
 from gt4py.next.iterator.transforms.fuse_maps import FuseMaps
@@ -79,26 +80,36 @@ def apply_common_transforms(
     ir = PruneUnreferencedFundefs().visit(ir)
     ir = PropagateDeref.apply(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,
-                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, opcount_preserving=True, force_inline_lift=(lift_mode == LiftMode.FORCE_INLINE)
+
+    for _ in range(10):
+        inlined = ir
+
+        if lift_mode != LiftMode.FORCE_TEMPORARIES:
+            inlined = _inline_lifts(inlined, lift_mode)
+
+        inlined = InlineLambdas.apply(
+            inlined,
+            opcount_preserving=True,
+            force_inline_lift=(lift_mode == LiftMode.FORCE_INLINE),
         )
+        inlined = ConstantFolding.apply(inlined)
+        # This pass is required to be in the loop such that when an `if_` call with tuple arguments
+        # is constant-folded the surrounding tuple_get calls can be removed.
+        inlined = CollapseTuple.apply(inlined)
 
-    if lift_mode == LiftMode.FORCE_INLINE:
+        if inlined == ir:
+            break
+        ir = inlined
+    else:
+        raise RuntimeError("Inlining lift and lambdas did not converge.")
+
+    # Since `CollapseTuple` relies on the type inference which does not support returning tuples
+    # larger than the number of closure outputs as given by the unconditional collapse, we can
+    # only run the unconditional version here instead of in the loop above.
+    if unconditionally_collapse_tuples:
         ir = CollapseTuple.apply(ir, ignore_tuple_size=unconditionally_collapse_tuples)
+
+    if lift_mode == LiftMode.FORCE_INLINE:
         ir = _inline_into_scan(ir)
 
     ir = NormalizeShifts().visit(ir)
diff --git a/src/gt4py/next/type_system/type_info.py b/src/gt4py/next/type_system/type_info.py
index 5a228247..564df7fd 100644
--- a/src/gt4py/next/type_system/type_info.py
+++ b/src/gt4py/next/type_system/type_info.py
@@ -23,6 +23,31 @@ from gt4py.next import common
 from gt4py.next.type_system import type_specifications as ts
 
 
+def _number_to_ordinal_number(number: int) -> str:
+    """
+    Convert number into ordinal number.
+
+    >>> for i in range(0, 5):
+    ...   print(_number_to_ordinal_number(i))
+    0th
+    1st
+    2nd
+    3rd
+    4th
+    """
+    number_as_string = str(number)
+    if len(number_as_string) > 1 and number_as_string[-2] == "1":
+        return number_as_string + "th"
+    last_digit = number_as_string[-1]
+    if last_digit == "1":
+        return number_as_string + "st"
+    if last_digit == "2":
+        return number_as_string + "nd"
+    if last_digit == "3":
+        return number_as_string + "rd"
+    return number_as_string + "th"
+
+
 def is_concrete(symbol_type: ts.TypeSpec) -> TypeGuard[ts.TypeSpec]:
     """Figure out if the foast type is completely deduced."""
     if isinstance(symbol_type, ts.DeferredType):
@@ -612,7 +637,7 @@ def function_signature_incompatibilities_func(  # noqa: C901
             and not is_concretizable(a_arg, to_type=b_arg)
         ):
             if i < len(func_type.pos_only_args):
-                arg_repr = f"{i}-th argument"
+                arg_repr = f"{_number_to_ordinal_number(i+1)} argument"
             else:
                 arg_repr = f"argument `{list(func_type.pos_or_kw_args.keys())[i - len(func_type.pos_only_args)]}`"
             yield f"Expected {arg_repr} to be of type `{a_arg}`, but got `{b_arg}`."
@@ -631,11 +656,11 @@ def function_signature_incompatibilities_field(
     kwargs: dict[str, ts.TypeSpec],
 ) -> Iterator[str]:
     if len(args) != 1:
-        yield f"Function takes 1 argument(s), but {len(args)} were given."
+        yield f"Function takes 1 argument, but {len(args)} were given."
         return
 
     if not isinstance(args[0], ts.OffsetType):
-        yield f"Expected 0-th argument to be of type {ts.OffsetType}, but got {args[0]}."
+        yield f"Expected first argument to be of type {ts.OffsetType}, but got {args[0]}."
         return
 
     if kwargs:


Test patch:
diff --git a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_arg_call_interface.py b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_arg_call_interface.py
index 78c1b64e..ade410ef 100644
--- a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_arg_call_interface.py
+++ b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_arg_call_interface.py
@@ -13,6 +13,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 import itertools
+import re
 import typing
 
 import numpy as np
@@ -20,8 +21,9 @@ import pytest
 
 from gt4py.next import errors
 from gt4py.next.common import Field
+from gt4py.next.errors.exceptions import TypeError_
 from gt4py.next.ffront.decorator import field_operator, program, scan_operator
-from gt4py.next.ffront.fbuiltins import int32, int64
+from gt4py.next.ffront.fbuiltins import broadcast, int32, int64
 from gt4py.next.program_processors.runners import dace_iterator, gtfn_cpu
 
 from next_tests.integration_tests import cases
@@ -271,3 +273,55 @@ def test_scan_wrong_state_type(cartesian_case):
         @program
         def testee(qc: cases.IKFloatField, param_1: int32, param_2: float, scalar: float):
             testee_scan(qc, param_1, param_2, scalar, out=(qc, param_1, param_2))
+
+
+@pytest.fixture
+def bound_args_testee():
+    @field_operator
+    def fieldop_bound_args() -> cases.IField:
+        return broadcast(0, (IDim,))
+
+    @program
+    def program_bound_args(arg1: bool, arg2: bool, out: cases.IField):
+        # for the test itself we don't care what happens here, but empty programs are not supported
+        fieldop_bound_args(out=out)
+
+    return program_bound_args
+
+
+def test_bind_invalid_arg(cartesian_case, bound_args_testee):
+    with pytest.raises(
+        TypeError, match="Keyword argument `inexistent_arg` is not a valid program parameter."
+    ):
+        bound_args_testee.with_bound_args(inexistent_arg=1)
+
+
+def test_call_bound_program_with_wrong_args(cartesian_case, bound_args_testee):
+    program_with_bound_arg = bound_args_testee.with_bound_args(arg1=True)
+    out = cases.allocate(cartesian_case, bound_args_testee, "out")()
+
+    with pytest.raises(TypeError) as exc_info:
+        program_with_bound_arg(out, offset_provider={})
+
+    assert (
+        re.search(
+            "Function takes 2 positional arguments, but 1 were given.",
+            exc_info.value.__cause__.args[0],
+        )
+        is not None
+    )
+
+
+def test_call_bound_program_with_already_bound_arg(cartesian_case, bound_args_testee):
+    program_with_bound_arg = bound_args_testee.with_bound_args(arg2=True)
+    out = cases.allocate(cartesian_case, bound_args_testee, "out")()
+
+    with pytest.raises(TypeError) as exc_info:
+        program_with_bound_arg(True, out, arg2=True, offset_provider={})
+
+    assert (
+        re.search(
+            "Parameter `arg2` already set as a bound argument.", exc_info.value.__cause__.args[0]
+        )
+        is not None
+    )
diff --git a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_execution.py b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_execution.py
index 8e4483fa..9f284f40 100644
--- a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_execution.py
+++ b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_execution.py
@@ -339,7 +339,7 @@ def test_astype_int(cartesian_case):  # noqa: F811 # fixtures
     )
 
 
-def test_astype_bool(cartesian_case):  # noqa: F811 # fixtures
+def test_astype_bool_field(cartesian_case):  # noqa: F811 # fixtures
     @gtx.field_operator
     def testee(a: cases.IFloatField) -> gtx.Field[[IDim], bool]:
         b = astype(a, bool)
@@ -353,6 +353,17 @@ def test_astype_bool(cartesian_case):  # noqa: F811 # fixtures
     )
 
 
+@pytest.mark.parametrize("inp", [0.0, 2.0])
+def test_astype_bool_scalar(cartesian_case, inp):  # noqa: F811 # fixtures
+    @gtx.field_operator
+    def testee(inp: float) -> gtx.Field[[IDim], bool]:
+        return broadcast(astype(inp, bool), (IDim,))
+
+    out = cases.allocate(cartesian_case, testee, cases.RETURN)()
+
+    cases.verify(cartesian_case, testee, inp, out=out, ref=bool(inp))
+
+
 def test_astype_float(cartesian_case):  # noqa: F811 # fixtures
     @gtx.field_operator
     def testee(a: cases.IFloatField) -> gtx.Field[[IDim], np.float32]:
@@ -730,6 +741,26 @@ def test_docstring(cartesian_case):
     cases.verify(cartesian_case, test_docstring, a, inout=a, ref=a)
 
 
+def test_with_bound_args(cartesian_case):
+    @gtx.field_operator
+    def fieldop_bound_args(a: cases.IField, scalar: int32, condition: bool) -> cases.IField:
+        if not condition:
+            scalar = 0
+        return a + a + scalar
+
+    @gtx.program
+    def program_bound_args(a: cases.IField, scalar: int32, condition: bool, out: cases.IField):
+        fieldop_bound_args(a, scalar, condition, out=out)
+
+    a = cases.allocate(cartesian_case, program_bound_args, "a")()
+    scalar = int32(1)
+    ref = a.array() + a.array() + 1
+    out = cases.allocate(cartesian_case, program_bound_args, "out")()
+
+    prog_bounds = program_bound_args.with_bound_args(scalar=scalar, condition=True)
+    cases.verify(cartesian_case, prog_bounds, a, out, inout=out, ref=ref)
+
+
 def test_domain(cartesian_case):
     @gtx.field_operator
     def fieldop_domain(a: cases.IField) -> cases.IField:
diff --git a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_type_deduction.py b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_type_deduction.py
index 70b24ace..7800a30e 100644
--- a/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_type_deduction.py
+++ b/tests/next_tests/integration_tests/feature_tests/ffront_tests/test_type_deduction.py
@@ -11,7 +11,7 @@
 # distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
-
+import re
 from typing import Optional, Pattern
 
 import pytest
@@ -180,7 +180,7 @@ def callable_type_info_cases():
             unary_func_type,
             [float_type],
             {},
-            [r"Expected 0-th argument to be of type `bool`, but got `float64`."],
+            [r"Expected 1st argument to be of type `bool`, but got `float64`."],
             None,
         ),
         (
@@ -274,7 +274,7 @@ def callable_type_info_cases():
             [int_type],
             {"bar": bool_type, "foo": bool_type},
             [
-                r"Expected 0-th argument to be of type `bool`, but got `int64`",
+                r"Expected 1st argument to be of type `bool`, but got `int64`",
                 r"Expected argument `foo` to be of type `int64`, but got `bool`",
                 r"Expected keyword argument `bar` to be of type `float64`, but got `bool`",
             ],
@@ -299,7 +299,7 @@ def callable_type_info_cases():
             [ts.TupleType(types=[float_type, field_type])],
             {},
             [
-                "Expected 0-th argument to be of type `tuple\[bool, Field\[\[I\], float64\]\]`, but got `tuple\[float64, Field\[\[I\], float64\]\]`"
+                "Expected 1st argument to be of type `tuple\[bool, Field\[\[I\], float64\]\]`, but got `tuple\[float64, Field\[\[I\], float64\]\]`"
             ],
             ts.VoidType(),
         ),
@@ -308,7 +308,7 @@ def callable_type_info_cases():
             [int_type],
             {},
             [
-                "Expected 0-th argument to be of type `tuple\[bool, Field\[\[I\], float64\]\]`, but got `int64`"
+                "Expected 1st argument to be of type `tuple\[bool, Field\[\[I\], float64\]\]`, but got `int64`"
             ],
             ts.VoidType(),
         ),
@@ -761,15 +761,42 @@ def test_where_mixed_dims():
 
 
 def test_astype_dtype():
-    ADim = Dimension("ADim")
-
-    def simple_astype(a: Field[[ADim], float64]):
+    def simple_astype(a: Field[[TDim], float64]):
         return astype(a, bool)
 
     parsed = FieldOperatorParser.apply_to_function(simple_astype)
 
     assert parsed.body.stmts[0].value.type == ts.FieldType(
-        dims=[ADim], dtype=ts.ScalarType(kind=ts.ScalarKind.BOOL)
+        dims=[TDim], dtype=ts.ScalarType(kind=ts.ScalarKind.BOOL)
+    )
+
+
+def test_astype_wrong_dtype():
+    def simple_astype(a: Field[[TDim], float64]):
+        # we just use broadcast here, but anything with type function is fine
+        return astype(a, broadcast)
+
+    with pytest.raises(
+        errors.DSLError,
+        match=r"Invalid call to `astype`. Second argument must be a scalar type, but got.",
+    ):
+        _ = FieldOperatorParser.apply_to_function(simple_astype)
+
+
+def test_astype_wrong_value_type():
+    def simple_astype(a: Field[[TDim], float64]):
+        # we just use a tuple here but anything that is not a field or scalar works
+        return astype((1, 2), bool)
+
+    with pytest.raises(errors.DSLError) as exc_info:
+        _ = FieldOperatorParser.apply_to_function(simple_astype)
+
+    assert (
+        re.search(
+            "Expected 1st argument to be of type",
+            exc_info.value.__cause__.args[0],
+        )
+        is not None
     )
 
 
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_constant_folding.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_constant_folding.py
new file mode 100644
index 00000000..5d052b19
--- /dev/null
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_constant_folding.py
@@ -0,0 +1,47 @@
+# GT4Py - GridTools Framework
+#
+# Copyright (c) 2014-2023, ETH Zurich
+# All rights reserved.
+#
+# This file is part of the GT4Py project and the GridTools framework.
+# GT4Py is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or any later
+# version. See the LICENSE.txt file at the top-level directory of this
+# distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from gt4py.next.iterator import ir_makers as im
+from gt4py.next.iterator.transforms.constant_folding import ConstantFolding
+
+
+def test_constant_folding_boolean():
+    testee = im.not_(im.literal_from_value(True))
+    expected = im.literal_from_value(False)
+
+    actual = ConstantFolding.apply(testee)
+    assert actual == expected
+
+
+def test_constant_folding_math_op():
+    expected = im.literal_from_value(13)
+    testee = im.plus(
+        im.literal_from_value(4),
+        im.plus(
+            im.literal_from_value(7), im.minus(im.literal_from_value(7), im.literal_from_value(5))
+        ),
+    )
+    actual = ConstantFolding.apply(testee)
+    assert actual == expected
+
+
+def test_constant_folding_if():
+    expected = im.call("plus")("a", 2)
+    testee = im.if_(
+        im.literal_from_value(True),
+        im.plus(im.ref("a"), im.literal_from_value(2)),
+        im.minus(im.literal_from_value(9), im.literal_from_value(5)),
+    )
+    actual = ConstantFolding.apply(testee)
+    assert actual == expected