Repository: GridTools/gt4py
Pull request #1738 (2024-11-14 17:00:05)

feat[next]: Inline dynamic shifts
Dynamic shifts are not supported in the domain inference. In order to make them work nonetheless this PR aggressively inlines all arguments to `as_fieldop` until they contain only references to `itir.Program` params. Additionally the domain inference is extended to tolerate such `as_fieldop` by introducing a special domain marker that signifies a domain is unknown.


Patch:
diff --git a/src/gt4py/next/iterator/transforms/fuse_as_fieldop.py b/src/gt4py/next/iterator/transforms/fuse_as_fieldop.py
index 9076bf2d..e8a221b8 100644
--- a/src/gt4py/next/iterator/transforms/fuse_as_fieldop.py
+++ b/src/gt4py/next/iterator/transforms/fuse_as_fieldop.py
@@ -53,7 +53,7 @@ def _canonicalize_as_fieldop(expr: itir.FunCall) -> itir.FunCall:
     if cpm.is_ref_to(stencil, "deref"):
         stencil = im.lambda_("arg")(im.deref("arg"))
         new_expr = im.as_fieldop(stencil, domain)(*expr.args)
-        type_inference.copy_type(from_=expr, to=new_expr)
+        type_inference.copy_type(from_=expr, to=new_expr, allow_untyped=True)
 
         return new_expr
 
@@ -68,6 +68,107 @@ def _is_tuple_expr_of_literals(expr: itir.Expr):
     return isinstance(expr, itir.Literal)
 
 
+def _inline_as_fieldop_arg(
+    arg: itir.Expr, *, uids: eve_utils.UIDGenerator
+) -> tuple[itir.Expr, dict[str, itir.Expr]]:
+    assert cpm.is_applied_as_fieldop(arg)
+    arg = _canonicalize_as_fieldop(arg)
+
+    stencil, *_ = arg.fun.args  # type: ignore[attr-defined]  # ensured by `is_applied_as_fieldop`
+    inner_args: list[itir.Expr] = arg.args
+    extracted_args: dict[str, itir.Expr] = {}  # mapping from outer-stencil param to arg
+
+    stencil_params: list[itir.Sym] = []
+    stencil_body: itir.Expr = stencil.expr
+
+    for inner_param, inner_arg in zip(stencil.params, inner_args, strict=True):
+        if isinstance(inner_arg, itir.SymRef):
+            stencil_params.append(inner_param)
+            extracted_args[inner_arg.id] = inner_arg
+        elif isinstance(inner_arg, itir.Literal):
+            # note: only literals, not all scalar expressions are required as it doesn't make sense
+            # for them to be computed per grid point.
+            stencil_body = im.let(inner_param, im.promote_to_const_iterator(inner_arg))(
+                stencil_body
+            )
+        else:
+            # a scalar expression, a previously not inlined `as_fieldop` call or an opaque
+            # expression e.g. containing a tuple
+            stencil_params.append(inner_param)
+            new_outer_stencil_param = uids.sequential_id(prefix="__iasfop")
+            extracted_args[new_outer_stencil_param] = inner_arg
+
+    return im.lift(im.lambda_(*stencil_params)(stencil_body))(
+        *extracted_args.keys()
+    ), extracted_args
+
+
+def fuse_as_fieldop(
+    expr: itir.Expr, eligible_args: list[bool], *, uids: eve_utils.UIDGenerator
+) -> itir.Expr:
+    assert cpm.is_applied_as_fieldop(expr) and isinstance(expr.fun.args[0], itir.Lambda)  # type: ignore[attr-defined]  # ensured by is_applied_as_fieldop
+
+    stencil: itir.Lambda = expr.fun.args[0]  # type: ignore[attr-defined]  # ensured by is_applied_as_fieldop
+    domain = expr.fun.args[1] if len(expr.fun.args) > 1 else None  # type: ignore[attr-defined]  # ensured by is_applied_as_fieldop
+
+    args: list[itir.Expr] = expr.args
+
+    new_args: dict[str, itir.Expr] = {}
+    new_stencil_body: itir.Expr = stencil.expr
+
+    for eligible, stencil_param, arg in zip(eligible_args, stencil.params, args, strict=True):
+        if eligible:
+            if cpm.is_applied_as_fieldop(arg):
+                pass
+            elif cpm.is_call_to(arg, "if_"):
+                # TODO(tehrengruber): revisit if we want to inline if_
+                type_ = arg.type
+                arg = im.op_as_fieldop("if_")(*arg.args)
+                arg.type = type_
+            elif _is_tuple_expr_of_literals(arg):
+                arg = im.op_as_fieldop(im.lambda_()(arg))()
+            else:
+                raise NotImplementedError()
+
+            inline_expr, extracted_args = _inline_as_fieldop_arg(arg, uids=uids)
+
+            new_stencil_body = im.let(stencil_param, inline_expr)(new_stencil_body)
+
+            new_args = _merge_arguments(new_args, extracted_args)
+        else:
+            # just a safety check if typing information is available
+            if arg.type and not isinstance(arg.type, ts.DeferredType):
+                assert isinstance(arg.type, ts.TypeSpec)
+                dtype = type_info.apply_to_primitive_constituents(type_info.extract_dtype, arg.type)
+                assert not isinstance(dtype, it_ts.ListType)
+            new_param: str
+            if isinstance(
+                arg, itir.SymRef
+            ):  # use name from outer scope (optional, just to get a nice IR)
+                new_param = arg.id
+                new_stencil_body = im.let(stencil_param.id, arg.id)(new_stencil_body)
+            else:
+                new_param = stencil_param.id
+            new_args = _merge_arguments(new_args, {new_param: arg})
+
+    new_node = im.as_fieldop(im.lambda_(*new_args.keys())(new_stencil_body), domain)(
+        *new_args.values()
+    )
+
+    # simplify stencil directly to keep the tree small
+    new_node = inline_center_deref_lift_vars.InlineCenterDerefLiftVars.apply(
+        new_node
+    )  # to keep the tree small
+    new_node = inline_lambdas.InlineLambdas.apply(
+        new_node, opcount_preserving=True, force_inline_lift_args=True
+    )
+    new_node = inline_lifts.InlineLifts().visit(new_node)
+
+    type_inference.copy_type(from_=expr, to=new_node, allow_untyped=True)
+
+    return new_node
+
+
 @dataclasses.dataclass
 class FuseAsFieldOp(eve.NodeTranslator):
     """
@@ -98,38 +199,6 @@ class FuseAsFieldOp(eve.NodeTranslator):
 
     uids: eve_utils.UIDGenerator
 
-    def _inline_as_fieldop_arg(self, arg: itir.Expr) -> tuple[itir.Expr, dict[str, itir.Expr]]:
-        assert cpm.is_applied_as_fieldop(arg)
-        arg = _canonicalize_as_fieldop(arg)
-
-        stencil, *_ = arg.fun.args  # type: ignore[attr-defined]  # ensured by `is_applied_as_fieldop`
-        inner_args: list[itir.Expr] = arg.args
-        extracted_args: dict[str, itir.Expr] = {}  # mapping from outer-stencil param to arg
-
-        stencil_params: list[itir.Sym] = []
-        stencil_body: itir.Expr = stencil.expr
-
-        for inner_param, inner_arg in zip(stencil.params, inner_args, strict=True):
-            if isinstance(inner_arg, itir.SymRef):
-                stencil_params.append(inner_param)
-                extracted_args[inner_arg.id] = inner_arg
-            elif isinstance(inner_arg, itir.Literal):
-                # note: only literals, not all scalar expressions are required as it doesn't make sense
-                # for them to be computed per grid point.
-                stencil_body = im.let(inner_param, im.promote_to_const_iterator(inner_arg))(
-                    stencil_body
-                )
-            else:
-                # a scalar expression, a previously not inlined `as_fieldop` call or an opaque
-                # expression e.g. containing a tuple
-                stencil_params.append(inner_param)
-                new_outer_stencil_param = self.uids.sequential_id(prefix="__iasfop")
-                extracted_args[new_outer_stencil_param] = inner_arg
-
-        return im.lift(im.lambda_(*stencil_params)(stencil_body))(
-            *extracted_args.keys()
-        ), extracted_args
-
     @classmethod
     def apply(
         cls,
@@ -158,72 +227,26 @@ class FuseAsFieldOp(eve.NodeTranslator):
 
         if cpm.is_call_to(node.fun, "as_fieldop") and isinstance(node.fun.args[0], itir.Lambda):
             stencil: itir.Lambda = node.fun.args[0]
-            domain = node.fun.args[1] if len(node.fun.args) > 1 else None
-
-            shifts = trace_shifts.trace_stencil(stencil)
-
             args: list[itir.Expr] = node.args
+            shifts = trace_shifts.trace_stencil(stencil)
 
-            new_args: dict[str, itir.Expr] = {}
-            new_stencil_body: itir.Expr = stencil.expr
-
-            for stencil_param, arg, arg_shifts in zip(stencil.params, args, shifts, strict=True):
+            eligible_args = []
+            for arg, arg_shifts in zip(args, shifts, strict=True):
                 assert isinstance(arg.type, ts.TypeSpec)
                 dtype = type_info.apply_to_primitive_constituents(type_info.extract_dtype, arg.type)
                 # TODO(tehrengruber): make this configurable
-                should_inline = _is_tuple_expr_of_literals(arg) or (
-                    isinstance(arg, itir.FunCall)
-                    and (
-                        cpm.is_call_to(arg.fun, "as_fieldop")
-                        and isinstance(arg.fun.args[0], itir.Lambda)
-                        or cpm.is_call_to(arg, "if_")
+                eligible_args.append(
+                    _is_tuple_expr_of_literals(arg)
+                    or (
+                        isinstance(arg, itir.FunCall)
+                        and (
+                            cpm.is_call_to(arg.fun, "as_fieldop")
+                            and isinstance(arg.fun.args[0], itir.Lambda)
+                            or cpm.is_call_to(arg, "if_")
+                        )
+                        and (isinstance(dtype, it_ts.ListType) or len(arg_shifts) <= 1)
                     )
-                    and (isinstance(dtype, it_ts.ListType) or len(arg_shifts) <= 1)
                 )
-                if should_inline:
-                    if cpm.is_applied_as_fieldop(arg):
-                        pass
-                    elif cpm.is_call_to(arg, "if_"):
-                        # TODO(tehrengruber): revisit if we want to inline if_
-                        type_ = arg.type
-                        arg = im.op_as_fieldop("if_")(*arg.args)
-                        arg.type = type_
-                    elif _is_tuple_expr_of_literals(arg):
-                        arg = im.op_as_fieldop(im.lambda_()(arg))()
-                    else:
-                        raise NotImplementedError()
-
-                    inline_expr, extracted_args = self._inline_as_fieldop_arg(arg)
-
-                    new_stencil_body = im.let(stencil_param, inline_expr)(new_stencil_body)
-
-                    new_args = _merge_arguments(new_args, extracted_args)
-                else:
-                    assert not isinstance(dtype, it_ts.ListType)
-                    new_param: str
-                    if isinstance(
-                        arg, itir.SymRef
-                    ):  # use name from outer scope (optional, just to get a nice IR)
-                        new_param = arg.id
-                        new_stencil_body = im.let(stencil_param.id, arg.id)(new_stencil_body)
-                    else:
-                        new_param = stencil_param.id
-                    new_args = _merge_arguments(new_args, {new_param: arg})
-
-            new_node = im.as_fieldop(im.lambda_(*new_args.keys())(new_stencil_body), domain)(
-                *new_args.values()
-            )
-
-            # simplify stencil directly to keep the tree small
-            new_node = inline_center_deref_lift_vars.InlineCenterDerefLiftVars.apply(
-                new_node
-            )  # to keep the tree small
-            new_node = inline_lambdas.InlineLambdas.apply(
-                new_node, opcount_preserving=True, force_inline_lift_args=True
-            )
-            new_node = inline_lifts.InlineLifts().visit(new_node)
-
-            type_inference.copy_type(from_=node, to=new_node)
 
-            return new_node
+            return fuse_as_fieldop(node, eligible_args, uids=self.uids)
         return node
diff --git a/src/gt4py/next/iterator/transforms/global_tmps.py b/src/gt4py/next/iterator/transforms/global_tmps.py
index a6d39883..334fb330 100644
--- a/src/gt4py/next/iterator/transforms/global_tmps.py
+++ b/src/gt4py/next/iterator/transforms/global_tmps.py
@@ -74,7 +74,7 @@ def _transform_by_pattern(
         #    or a tuple thereof)
         #  - one `SetAt` statement that materializes the expression into the temporary
         for tmp_sym, tmp_expr in extracted_fields.items():
-            domain = tmp_expr.annex.domain
+            domain: infer_domain.DomainAccess = tmp_expr.annex.domain
 
             # TODO(tehrengruber): Implement. This happens when the expression is a combination
             #  of an `if_` call with a tuple, e.g., `if_(cond, {a, b}, {c, d})`. As long as we are
@@ -186,7 +186,7 @@ def create_global_tmps(
     This pass looks at all `as_fieldop` calls and transforms field-typed subexpressions of its
     arguments into temporaries.
     """
-    program = infer_domain.infer_program(program, offset_provider)
+    program = infer_domain.infer_program(program, offset_provider=offset_provider)
     program = type_inference.infer(
         program, offset_provider_type=common.offset_provider_to_type(offset_provider)
     )
diff --git a/src/gt4py/next/iterator/transforms/infer_domain.py b/src/gt4py/next/iterator/transforms/infer_domain.py
index 6852b47a..f26d3f9e 100644
--- a/src/gt4py/next/iterator/transforms/infer_domain.py
+++ b/src/gt4py/next/iterator/transforms/infer_domain.py
@@ -10,10 +10,10 @@ from __future__ import annotations
 
 import itertools
 import typing
-from typing import Callable, Optional, TypeAlias
 
 from gt4py import eve
 from gt4py.eve import utils as eve_utils
+from gt4py.eve.extended_typing import Callable, Optional, TypeAlias, Unpack
 from gt4py.next import common
 from gt4py.next.iterator import ir as itir
 from gt4py.next.iterator.ir_utils import (
@@ -25,8 +25,35 @@ from gt4py.next.iterator.transforms import trace_shifts
 from gt4py.next.utils import flatten_nested_tuple, tree_map
 
 
-DOMAIN: TypeAlias = domain_utils.SymbolicDomain | None | tuple["DOMAIN", ...]
-ACCESSED_DOMAINS: TypeAlias = dict[str, DOMAIN]
+class DomainAccessDescriptor(eve.StrEnum):
+    """
+    Descriptor for domains that could not be inferred.
+    """
+
+    # TODO(tehrengruber): Revisit this concept. It is strange that we don't have a descriptor
+    #  `KNOWN`, but since we don't need it, it wasn't added.
+
+    #: The access is unknown because of a dynamic shift.whose extent is not known.
+    #: E.g.: `(⇑(λ(arg0, arg1) → ·⟪Ioffₒ, ·arg1⟫(arg0)))(in_field1, in_field2)`
+    UNKNOWN = "unknown"
+    #: The domain is never accessed.
+    #: E.g.: `{in_field1, in_field2}[0]`
+    NEVER = "never"
+
+
+NonTupleDomainAccess: TypeAlias = domain_utils.SymbolicDomain | DomainAccessDescriptor
+#: The domain can also be a tuple of domains, usually this only occurs for scan operators returning
+#: a tuple since other occurrences for tuples are removed before domain inference. This is
+#: however not a requirement of the pass and `make_tuple(vertex_field, edge_field)` infers just
+#: fine to a tuple of a vertex and an edge domain.
+DomainAccess: TypeAlias = NonTupleDomainAccess | tuple["DomainAccess", ...]
+AccessedDomains: TypeAlias = dict[str, DomainAccess]
+
+
+class InferenceOptions(typing.TypedDict):
+    offset_provider: common.OffsetProvider
+    symbolic_domain_sizes: Optional[dict[str, str]]
+    allow_uninferred: bool
 
 
 class DomainAnnexDebugger(eve.NodeVisitor):
@@ -57,43 +84,58 @@ def _split_dict_by_key(pred: Callable, d: dict):
 
 
 # TODO(tehrengruber): Revisit whether we want to move this behaviour to `domain_utils.domain_union`.
-def _domain_union_with_none(
-    *domains: domain_utils.SymbolicDomain | None,
-) -> domain_utils.SymbolicDomain | None:
-    filtered_domains: list[domain_utils.SymbolicDomain] = [d for d in domains if d is not None]
+def _domain_union(
+    *domains: domain_utils.SymbolicDomain | DomainAccessDescriptor,
+) -> domain_utils.SymbolicDomain | DomainAccessDescriptor:
+    if any(d == DomainAccessDescriptor.UNKNOWN for d in domains):
+        return DomainAccessDescriptor.UNKNOWN
+
+    filtered_domains: list[domain_utils.SymbolicDomain] = [
+        d  # type: ignore[misc]  # domain can never be unknown as these cases are filtered above
+        for d in domains
+        if d != DomainAccessDescriptor.NEVER
+    ]
     if len(filtered_domains) == 0:
-        return None
+        return DomainAccessDescriptor.NEVER
     return domain_utils.domain_union(*filtered_domains)
 
 
-def _canonicalize_domain_structure(d1: DOMAIN, d2: DOMAIN) -> tuple[DOMAIN, DOMAIN]:
+def _canonicalize_domain_structure(
+    d1: DomainAccess, d2: DomainAccess
+) -> tuple[DomainAccess, DomainAccess]:
     """
     Given two domains or composites thereof, canonicalize their structure.
 
     If one of the arguments is a tuple the other one will be promoted to a tuple of same structure
-    unless it already is a tuple. Missing values are replaced by None, meaning no domain is
-    specified.
+    unless it already is a tuple. Missing values are filled by :ref:`DomainAccessDescriptor.NEVER`.
 
     >>> domain = im.domain(common.GridType.CARTESIAN, {})
     >>> _canonicalize_domain_structure((domain,), (domain, domain)) == (
-    ...     (domain, None),
+    ...     (domain, DomainAccessDescriptor.NEVER),
     ...     (domain, domain),
     ... )
     True
 
-    >>> _canonicalize_domain_structure((domain, None), None) == ((domain, None), (None, None))
+    >>> _canonicalize_domain_structure(
+    ...     (domain, DomainAccessDescriptor.NEVER), DomainAccessDescriptor.NEVER
+    ... ) == (
+    ...     (domain, DomainAccessDescriptor.NEVER),
+    ...     (DomainAccessDescriptor.NEVER, DomainAccessDescriptor.NEVER),
+    ... )
     True
     """
-    if d1 is None and isinstance(d2, tuple):
-        return _canonicalize_domain_structure((None,) * len(d2), d2)
-    if d2 is None and isinstance(d1, tuple):
-        return _canonicalize_domain_structure(d1, (None,) * len(d1))
+    if d1 is DomainAccessDescriptor.NEVER and isinstance(d2, tuple):
+        return _canonicalize_domain_structure((DomainAccessDescriptor.NEVER,) * len(d2), d2)
+    if d2 is DomainAccessDescriptor.NEVER and isinstance(d1, tuple):
+        return _canonicalize_domain_structure(d1, (DomainAccessDescriptor.NEVER,) * len(d1))
     if isinstance(d1, tuple) and isinstance(d2, tuple):
         return tuple(
             zip(
                 *(
                     _canonicalize_domain_structure(el1, el2)
-                    for el1, el2 in itertools.zip_longest(d1, d2, fillvalue=None)
+                    for el1, el2 in itertools.zip_longest(
+                        d1, d2, fillvalue=DomainAccessDescriptor.NEVER
+                    )
                 )
             )
         )  # type: ignore[return-value]  # mypy not smart enough
@@ -101,16 +143,16 @@ def _canonicalize_domain_structure(d1: DOMAIN, d2: DOMAIN) -> tuple[DOMAIN, DOMA
 
 
 def _merge_domains(
-    original_domains: ACCESSED_DOMAINS,
-    additional_domains: ACCESSED_DOMAINS,
-) -> ACCESSED_DOMAINS:
+    original_domains: AccessedDomains,
+    additional_domains: AccessedDomains,
+) -> AccessedDomains:
     new_domains = {**original_domains}
 
     for key, domain in additional_domains.items():
         original_domain, domain = _canonicalize_domain_structure(
-            original_domains.get(key, None), domain
+            original_domains.get(key, DomainAccessDescriptor.NEVER), domain
         )
-        new_domains[key] = tree_map(_domain_union_with_none)(original_domain, domain)
+        new_domains[key] = tree_map(_domain_union)(original_domain, domain)
 
     return new_domains
 
@@ -118,44 +160,52 @@ def _merge_domains(
 def _extract_accessed_domains(
     stencil: itir.Expr,
     input_ids: list[str],
-    target_domain: domain_utils.SymbolicDomain,
+    target_domain: NonTupleDomainAccess,
     offset_provider: common.OffsetProvider,
     symbolic_domain_sizes: Optional[dict[str, str]],
-) -> ACCESSED_DOMAINS:
-    accessed_domains: dict[str, domain_utils.SymbolicDomain | None] = {}
+) -> dict[str, NonTupleDomainAccess]:
+    accessed_domains: dict[str, NonTupleDomainAccess] = {}
 
     shifts_results = trace_shifts.trace_stencil(stencil, num_args=len(input_ids))
 
     for in_field_id, shifts_list in zip(input_ids, shifts_results, strict=True):
+        # TODO(tehrengruber): Dynamic shifts are not supported by `SymbolicDomain.translate`. Use
+        #  special `UNKNOWN` marker for them until we have implemented a proper solution.
+        if any(s == trace_shifts.Sentinel.VALUE for shift in shifts_list for s in shift):
+            accessed_domains[in_field_id] = DomainAccessDescriptor.UNKNOWN
+            continue
+
         new_domains = [
             domain_utils.SymbolicDomain.translate(
                 target_domain, shift, offset_provider, symbolic_domain_sizes
             )
+            if not isinstance(target_domain, DomainAccessDescriptor)
+            else target_domain
             for shift in shifts_list
         ]
-        # `None` means field is never accessed
-        accessed_domains[in_field_id] = _domain_union_with_none(
-            accessed_domains.get(in_field_id, None), *new_domains
+        accessed_domains[in_field_id] = _domain_union(
+            accessed_domains.get(in_field_id, DomainAccessDescriptor.NEVER), *new_domains
         )
 
-    return typing.cast(ACCESSED_DOMAINS, accessed_domains)
+    return accessed_domains
 
 
 def _infer_as_fieldop(
     applied_fieldop: itir.FunCall,
-    target_domain: DOMAIN,
+    target_domain: DomainAccess,
+    *,
     offset_provider: common.OffsetProvider,
     symbolic_domain_sizes: Optional[dict[str, str]],
-) -> tuple[itir.FunCall, ACCESSED_DOMAINS]:
+    allow_uninferred: bool,
+) -> tuple[itir.FunCall, AccessedDomains]:
     assert isinstance(applied_fieldop, itir.FunCall)
     assert cpm.is_call_to(applied_fieldop.fun, "as_fieldop")
-    if target_domain is None:
-        raise ValueError("'target_domain' cannot be 'None'.")
+    if not allow_uninferred and target_domain is DomainAccessDescriptor.NEVER:
+        raise ValueError("'target_domain' cannot be 'NEVER' unless `allow_uninferred=True`.")
     # FIXME[#1582](tehrengruber): Temporary solution for `tuple_get` on scan result. See `test_solve_triag`.
     if isinstance(target_domain, tuple):
-        target_domain = _domain_union_with_none(*flatten_nested_tuple(target_domain))
-    if not isinstance(target_domain, domain_utils.SymbolicDomain):
-        raise ValueError("'target_domain' needs to be a 'domain_utils.SymbolicDomain'.")
+        target_domain = _domain_union(*flatten_nested_tuple(target_domain))  # type: ignore[arg-type]  # mypy not smart enough
+    assert isinstance(target_domain, (domain_utils.SymbolicDomain, DomainAccessDescriptor))
 
     # `as_fieldop(stencil)(inputs...)`
     stencil, inputs = applied_fieldop.fun.args[0], applied_fieldop.args
@@ -177,22 +227,29 @@ def _infer_as_fieldop(
             raise ValueError(f"Unsupported expression of type '{type(in_field)}'.")
         input_ids.append(id_)
 
-    inputs_accessed_domains: ACCESSED_DOMAINS = _extract_accessed_domains(
+    inputs_accessed_domains: dict[str, NonTupleDomainAccess] = _extract_accessed_domains(
         stencil, input_ids, target_domain, offset_provider, symbolic_domain_sizes
     )
 
     # Recursively infer domain of inputs and update domain arg of nested `as_fieldop`s
-    accessed_domains: ACCESSED_DOMAINS = {}
+    accessed_domains: AccessedDomains = {}
     transformed_inputs: list[itir.Expr] = []
     for in_field_id, in_field in zip(input_ids, inputs):
         transformed_input, accessed_domains_tmp = infer_expr(
-            in_field, inputs_accessed_domains[in_field_id], offset_provider, symbolic_domain_sizes
+            in_field,
+            inputs_accessed_domains[in_field_id],
+            offset_provider=offset_provider,
+            symbolic_domain_sizes=symbolic_domain_sizes,
+            allow_uninferred=allow_uninferred,
         )
         transformed_inputs.append(transformed_input)
 
         accessed_domains = _merge_domains(accessed_domains, accessed_domains_tmp)
 
-    target_domain_expr = domain_utils.SymbolicDomain.as_expr(target_domain)
+    if not isinstance(target_domain, DomainAccessDescriptor):
+        target_domain_expr = domain_utils.SymbolicDomain.as_expr(target_domain)
+    else:
+        target_domain_expr = None
     transformed_call = im.as_fieldop(stencil, target_domain_expr)(*transformed_inputs)
 
     accessed_domains_without_tmp = {
@@ -206,17 +263,15 @@ def _infer_as_fieldop(
 
 def _infer_let(
     let_expr: itir.FunCall,
-    input_domain: DOMAIN,
-    offset_provider: common.OffsetProvider,
-    symbolic_domain_sizes: Optional[dict[str, str]],
-) -> tuple[itir.FunCall, ACCESSED_DOMAINS]:
+    input_domain: DomainAccess,
+    **kwargs: Unpack[InferenceOptions],
+) -> tuple[itir.FunCall, AccessedDomains]:
     assert cpm.is_let(let_expr)
     assert isinstance(let_expr.fun, itir.Lambda)  # just to make mypy happy
-    transformed_calls_expr, accessed_domains = infer_expr(
-        let_expr.fun.expr, input_domain, offset_provider, symbolic_domain_sizes
-    )
-
     let_params = {param_sym.id for param_sym in let_expr.fun.params}
+
+    transformed_calls_expr, accessed_domains = infer_expr(let_expr.fun.expr, input_domain, **kwargs)
+
     accessed_domains_let_args, accessed_domains_outer = _split_dict_by_key(
         lambda k: k in let_params, accessed_domains
     )
@@ -227,10 +282,9 @@ def _infer_let(
             arg,
             accessed_domains_let_args.get(
                 param.id,
-                None,
+                DomainAccessDescriptor.NEVER,
             ),
-            offset_provider,
-            symbolic_domain_sizes,
+            **kwargs,
         )
         accessed_domains_outer = _merge_domains(accessed_domains_outer, accessed_domains_arg)
         transformed_calls_args.append(transformed_calls_arg)
@@ -247,13 +301,12 @@ def _infer_let(
 
 def _infer_make_tuple(
     expr: itir.Expr,
-    domain: DOMAIN,
-    offset_provider: common.OffsetProvider,
-    symbolic_domain_sizes: Optional[dict[str, str]],
-) -> tuple[itir.Expr, ACCESSED_DOMAINS]:
+    domain: DomainAccess,
+    **kwargs: Unpack[InferenceOptions],
+) -> tuple[itir.Expr, AccessedDomains]:
     assert cpm.is_call_to(expr, "make_tuple")
     infered_args_expr = []
-    actual_domains: ACCESSED_DOMAINS = {}
+    actual_domains: AccessedDomains = {}
     if not isinstance(domain, tuple):
         # promote domain to a tuple of domains such that it has the same structure as
         # the expression
@@ -261,13 +314,12 @@ def _infer_make_tuple(
         #  out @ c⟨ IDimₕ: [0, __out_size_0) ⟩ ← {__sym_1, __sym_2};
         domain = (domain,) * len(expr.args)
     assert len(expr.args) >= len(domain)
-    # There may be less domains than tuple args, pad the domain with `None` in that case.
-    #  e.g. `im.tuple_get(0, im.make_tuple(a, b), domain=domain)`
-    domain = (*domain, *(None for _ in range(len(expr.args) - len(domain))))
+    # There may be fewer domains than tuple args, pad the domain with `NEVER`
+    # in that case.
+    # e.g. `im.tuple_get(0, im.make_tuple(a, b), domain=domain)`
+    domain = (*domain, *(DomainAccessDescriptor.NEVER for _ in range(len(expr.args) - len(domain))))
     for i, arg in enumerate(expr.args):
-        infered_arg_expr, actual_domains_arg = infer_expr(
-            arg, domain[i], offset_provider, symbolic_domain_sizes
-        )
+        infered_arg_expr, actual_domains_arg = infer_expr(arg, domain[i], **kwargs)
         infered_args_expr.append(infered_arg_expr)
         actual_domains = _merge_domains(actual_domains, actual_domains_arg)
     result_expr = im.call(expr.fun)(*infered_args_expr)
@@ -276,19 +328,18 @@ def _infer_make_tuple(
 
 def _infer_tuple_get(
     expr: itir.Expr,
-    domain: DOMAIN,
-    offset_provider: common.OffsetProvider,
-    symbolic_domain_sizes: Optional[dict[str, str]],
-) -> tuple[itir.Expr, ACCESSED_DOMAINS]:
+    domain: DomainAccess,
+    **kwargs: Unpack[InferenceOptions],
+) -> tuple[itir.Expr, AccessedDomains]:
     assert cpm.is_call_to(expr, "tuple_get")
-    actual_domains: ACCESSED_DOMAINS = {}
+    actual_domains: AccessedDomains = {}
     idx_expr, tuple_arg = expr.args
     assert isinstance(idx_expr, itir.Literal)
     idx = int(idx_expr.value)
-    tuple_domain = tuple(None if i != idx else domain for i in range(idx + 1))
-    infered_arg_expr, actual_domains_arg = infer_expr(
-        tuple_arg, tuple_domain, offset_provider, symbolic_domain_sizes
+    tuple_domain = tuple(
+        DomainAccessDescriptor.NEVER if i != idx else domain for i in range(idx + 1)
     )
+    infered_arg_expr, actual_domains_arg = infer_expr(tuple_arg, tuple_domain, **kwargs)
 
     infered_args_expr = im.tuple_get(idx, infered_arg_expr)
     actual_domains = _merge_domains(actual_domains, actual_domains_arg)
@@ -297,18 +348,15 @@ def _infer_tuple_get(
 
 def _infer_if(
     expr: itir.Expr,
-    domain: DOMAIN,
-    offset_provider: common.OffsetProvider,
-    symbolic_domain_sizes: Optional[dict[str, str]],
-) -> tuple[itir.Expr, ACCESSED_DOMAINS]:
+    domain: DomainAccess,
+    **kwargs: Unpack[InferenceOptions],
+) -> tuple[itir.Expr, AccessedDomains]:
     assert cpm.is_call_to(expr, "if_")
     infered_args_expr = []
-    actual_domains: ACCESSED_DOMAINS = {}
+    actual_domains: AccessedDomains = {}
     cond, true_val, false_val = expr.args
     for arg in [true_val, false_val]:
-        infered_arg_expr, actual_domains_arg = infer_expr(
-            arg, domain, offset_provider, symbolic_domain_sizes
-        )
+        infered_arg_expr, actual_domains_arg = infer_expr(arg, domain, **kwargs)
         infered_args_expr.append(infered_arg_expr)
         actual_domains = _merge_domains(actual_domains, actual_domains_arg)
     result_expr = im.call(expr.fun)(cond, *infered_args_expr)
@@ -317,24 +365,23 @@ def _infer_if(
 
 def _infer_expr(
     expr: itir.Expr,
-    domain: DOMAIN,
-    offset_provider: common.OffsetProvider,
-    symbolic_domain_sizes: Optional[dict[str, str]],
-) -> tuple[itir.Expr, ACCESSED_DOMAINS]:
+    domain: DomainAccess,
+    **kwargs: Unpack[InferenceOptions],
+) -> tuple[itir.Expr, AccessedDomains]:
     if isinstance(expr, itir.SymRef):
         return expr, {str(expr.id): domain}
     elif isinstance(expr, itir.Literal):
         return expr, {}
     elif cpm.is_applied_as_fieldop(expr):
-        return _infer_as_fieldop(expr, domain, offset_provider, symbolic_domain_sizes)
+        return _infer_as_fieldop(expr, domain, **kwargs)
     elif cpm.is_let(expr):
-        return _infer_let(expr, domain, offset_provider, symbolic_domain_sizes)
+        return _infer_let(expr, domain, **kwargs)
     elif cpm.is_call_to(expr, "make_tuple"):
-        return _infer_make_tuple(expr, domain, offset_provider, symbolic_domain_sizes)
+        return _infer_make_tuple(expr, domain, **kwargs)
     elif cpm.is_call_to(expr, "tuple_get"):
-        return _infer_tuple_get(expr, domain, offset_provider, symbolic_domain_sizes)
+        return _infer_tuple_get(expr, domain, **kwargs)
     elif cpm.is_call_to(expr, "if_"):
-        return _infer_if(expr, domain, offset_provider, symbolic_domain_sizes)
+        return _infer_if(expr, domain, **kwargs)
     elif (
         cpm.is_call_to(expr, itir.ARITHMETIC_BUILTINS)
         or cpm.is_call_to(expr, itir.TYPEBUILTINS)
@@ -347,10 +394,12 @@ def _infer_expr(
 
 def infer_expr(
     expr: itir.Expr,
-    domain: DOMAIN,
+    domain: DomainAccess,
+    *,
     offset_provider: common.OffsetProvider,
     symbolic_domain_sizes: Optional[dict[str, str]] = None,
-) -> tuple[itir.Expr, ACCESSED_DOMAINS]:
+    allow_uninferred: bool = False,
+) -> tuple[itir.Expr, AccessedDomains]:
     """
     Infer the domain of all field subexpressions of `expr`.
 
@@ -362,30 +411,35 @@ def infer_expr(
     - domain: The domain `expr` is read at.
     - symbolic_domain_sizes: A dictionary mapping axes names, e.g., `I`, `Vertex`, to a symbol
       name that evaluates to the length of that axis.
+    - allow_uninferred: Allow `as_fieldop` expressions whose domain is either unknown (e.g.
+      because of a dynamic shift) or never accessed.
 
     Returns:
       A tuple containing the inferred expression with all applied `as_fieldop` (that are accessed)
       having a domain argument now, and a dictionary mapping symbol names referenced in `expr` to
       domain they are accessed at.
     """
-    # this is just a small wrapper that populates the `domain` annex
-    expr, accessed_domains = _infer_expr(expr, domain, offset_provider, symbolic_domain_sizes)
+    expr, accessed_domains = _infer_expr(
+        expr,
+        domain,
+        offset_provider=offset_provider,
+        symbolic_domain_sizes=symbolic_domain_sizes,
+        allow_uninferred=allow_uninferred,
+    )
     expr.annex.domain = domain
+
     return expr, accessed_domains
 
 
 def _infer_stmt(
     stmt: itir.Stmt,
-    offset_provider: common.OffsetProvider,
-    symbolic_domain_sizes: Optional[dict[str, str]],
+    **kwargs: Unpack[InferenceOptions],
 ):
     if isinstance(stmt, itir.SetAt):
-        transformed_call, _unused_domain = infer_expr(
-            stmt.expr,
-            domain_utils.SymbolicDomain.from_expr(stmt.domain),
-            offset_provider,
-            symbolic_domain_sizes,
+        transformed_call, _ = infer_expr(
+            stmt.expr, domain_utils.SymbolicDomain.from_expr(stmt.domain), **kwargs
         )
+
         return itir.SetAt(
             expr=transformed_call,
             domain=stmt.domain,
@@ -394,20 +448,18 @@ def _infer_stmt(
     elif isinstance(stmt, itir.IfStmt):
         return itir.IfStmt(
             cond=stmt.cond,
-            true_branch=[
-                _infer_stmt(c, offset_provider, symbolic_domain_sizes) for c in stmt.true_branch
-            ],
-            false_branch=[
-                _infer_stmt(c, offset_provider, symbolic_domain_sizes) for c in stmt.false_branch
-            ],
+            true_branch=[_infer_stmt(c, **kwargs) for c in stmt.true_branch],
+            false_branch=[_infer_stmt(c, **kwargs) for c in stmt.false_branch],
         )
     raise ValueError(f"Unsupported stmt: {stmt}")
 
 
 def infer_program(
     program: itir.Program,
+    *,
     offset_provider: common.OffsetProvider,
     symbolic_domain_sizes: Optional[dict[str, str]] = None,
+    allow_uninferred: bool = False,
 ) -> itir.Program:
     """
     Infer the domain of all field subexpressions inside a program.
@@ -423,5 +475,13 @@ def infer_program(
         function_definitions=program.function_definitions,
         params=program.params,
         declarations=program.declarations,
-        body=[_infer_stmt(stmt, offset_provider, symbolic_domain_sizes) for stmt in program.body],
+        body=[
+            _infer_stmt(
+                stmt,
+                offset_provider=offset_provider,
+                symbolic_domain_sizes=symbolic_domain_sizes,
+                allow_uninferred=allow_uninferred,
+            )
+            for stmt in program.body
+        ],
     )
diff --git a/src/gt4py/next/iterator/transforms/inline_dynamic_shifts.py b/src/gt4py/next/iterator/transforms/inline_dynamic_shifts.py
new file mode 100644
index 00000000..0af9d9da
--- /dev/null
+++ b/src/gt4py/next/iterator/transforms/inline_dynamic_shifts.py
@@ -0,0 +1,73 @@
+# GT4Py - GridTools Framework
+#
+# Copyright (c) 2014-2024, ETH Zurich
+# All rights reserved.
+#
+# Please, refer to the LICENSE file in the root directory.
+# SPDX-License-Identifier: BSD-3-Clause
+
+import dataclasses
+from typing import Optional
+
+import gt4py.next.iterator.ir_utils.common_pattern_matcher as cpm
+from gt4py import eve
+from gt4py.eve import utils as eve_utils
+from gt4py.next.iterator import ir as itir
+from gt4py.next.iterator.transforms import fuse_as_fieldop, inline_lambdas, trace_shifts
+from gt4py.next.iterator.transforms.symbol_ref_utils import collect_symbol_refs
+
+
+def _dynamic_shift_args(node: itir.Expr) -> None | list[bool]:
+    if not cpm.is_applied_as_fieldop(node):
+        return None
+    params_shifts = trace_shifts.trace_stencil(
+        node.fun.args[0],  # type: ignore[attr-defined]  # ensured by is_applied_as_fieldop
+        num_args=len(node.args),
+        save_to_annex=True,
+    )
+    dynamic_shifts = [
+        any(trace_shifts.Sentinel.VALUE in shifts for shifts in param_shifts)
+        for param_shifts in params_shifts
+    ]
+    return dynamic_shifts
+
+
+@dataclasses.dataclass
+class InlineDynamicShifts(eve.NodeTranslator, eve.VisitorWithSymbolTableTrait):
+    uids: eve_utils.UIDGenerator
+
+    @classmethod
+    def apply(cls, node: itir.Program, uids: Optional[eve_utils.UIDGenerator] = None):
+        if not uids:
+            uids = eve_utils.UIDGenerator()
+
+        return cls(uids=uids).visit(node)
+
+    def visit_FunCall(self, node: itir.FunCall, **kwargs):
+        node = self.generic_visit(node, **kwargs)
+
+        if cpm.is_let(node) and (
+            dynamic_shift_args := _dynamic_shift_args(let_body := node.fun.expr)  # type: ignore[attr-defined]  # ensured by is_let
+        ):
+            inline_let_params = {p.id: False for p in node.fun.params}  # type: ignore[attr-defined]  # ensured by is_let
+
+            for inp, is_dynamic_shift_arg in zip(let_body.args, dynamic_shift_args, strict=True):
+                for ref in collect_symbol_refs(inp):
+                    if ref in inline_let_params and is_dynamic_shift_arg:
+                        inline_let_params[ref] = True
+
+            if any(inline_let_params):
+                node = inline_lambdas.inline_lambda(
+                    node, eligible_params=list(inline_let_params.values())
+                )
+
+        if dynamic_shift_args := _dynamic_shift_args(node):
+            assert len(node.fun.args) in [1, 2]  # type: ignore[attr-defined]  # ensured by is_applied_as_fieldop in _dynamic_shift_args
+            fuse_args = [
+                not isinstance(inp, itir.SymRef) and dynamic_shift_arg
+                for inp, dynamic_shift_arg in zip(node.args, dynamic_shift_args, strict=True)
+            ]
+            if any(fuse_args):
+                return fuse_as_fieldop.fuse_as_fieldop(node, fuse_args, uids=self.uids)
+
+        return node
diff --git a/src/gt4py/next/iterator/transforms/pass_manager.py b/src/gt4py/next/iterator/transforms/pass_manager.py
index ec4207d7..d967c8fb 100644
--- a/src/gt4py/next/iterator/transforms/pass_manager.py
+++ b/src/gt4py/next/iterator/transforms/pass_manager.py
@@ -15,6 +15,7 @@ from gt4py.next.iterator.transforms import (
     fuse_as_fieldop,
     global_tmps,
     infer_domain,
+    inline_dynamic_shifts,
     inline_fundefs,
     inline_lifts,
 )
@@ -73,6 +74,9 @@ def apply_common_transforms(
     ir = InlineLambdas.apply(ir, opcount_preserving=True, force_inline_lambda_args=True)
     # required in order to get rid of expressions without a domain (e.g. when a tuple element is never accessed)
     ir = CollapseTuple.apply(ir, offset_provider_type=offset_provider_type)  # type: ignore[assignment]  # always an itir.Program
+    ir = inline_dynamic_shifts.InlineDynamicShifts.apply(
+        ir
+    )  # domain inference does not support dynamic offsets yet
     ir = infer_domain.infer_program(
         ir,
         offset_provider=offset_provider,
@@ -158,5 +162,8 @@ def apply_fieldview_transforms(
     ir = CollapseTuple.apply(
         ir, offset_provider_type=common.offset_provider_to_type(offset_provider)
     )  # type: ignore[assignment] # type is still `itir.Program`
+    ir = inline_dynamic_shifts.InlineDynamicShifts.apply(
+        ir
+    )  # domain inference does not support dynamic offsets yet
     ir = infer_domain.infer_program(ir, offset_provider=offset_provider)
     return ir


Test patch:
diff --git a/tests/next_tests/definitions.py b/tests/next_tests/definitions.py
index d7413f32..bed6e89a 100644
--- a/tests/next_tests/definitions.py
+++ b/tests/next_tests/definitions.py
@@ -130,7 +130,6 @@ COMMON_SKIP_TEST_LIST = [
 ]
 # Markers to skip because of missing features in the domain inference
 DOMAIN_INFERENCE_SKIP_LIST = [
-    (USES_DYNAMIC_OFFSETS, XFAIL, UNSUPPORTED_MESSAGE),
     (USES_STRIDED_NEIGHBOR_OFFSET, XFAIL, UNSUPPORTED_MESSAGE),
 ]
 DACE_SKIP_TEST_LIST = DOMAIN_INFERENCE_SKIP_LIST + [
diff --git a/tests/next_tests/unit_tests/iterator_tests/test_inline_dynamic_shifts.py b/tests/next_tests/unit_tests/iterator_tests/test_inline_dynamic_shifts.py
new file mode 100644
index 00000000..ff7a761c
--- /dev/null
+++ b/tests/next_tests/unit_tests/iterator_tests/test_inline_dynamic_shifts.py
@@ -0,0 +1,48 @@
+# GT4Py - GridTools Framework
+#
+# Copyright (c) 2014-2024, ETH Zurich
+# All rights reserved.
+#
+# Please, refer to the LICENSE file in the root directory.
+# SPDX-License-Identifier: BSD-3-Clause
+from typing import Callable, Optional
+
+from gt4py import next as gtx
+from gt4py.next.iterator import ir as itir
+from gt4py.next.iterator.ir_utils import ir_makers as im
+from gt4py.next.iterator.transforms import inline_dynamic_shifts
+from gt4py.next.type_system import type_specifications as ts
+
+IDim = gtx.Dimension("IDim")
+field_type = ts.FieldType(dims=[IDim], dtype=ts.ScalarType(kind=ts.ScalarKind.INT32))
+
+
+def test_inline_dynamic_shift_as_fieldop_arg():
+    testee = im.as_fieldop(im.lambda_("a", "b")(im.deref(im.shift("IOff", im.deref("b"))("a"))))(
+        im.as_fieldop("deref")("inp"), "offset_field"
+    )
+    expected = im.as_fieldop(
+        im.lambda_("inp", "offset_field")(
+            im.deref(im.shift("IOff", im.deref("offset_field"))("inp"))
+        )
+    )("inp", "offset_field")
+
+    actual = inline_dynamic_shifts.InlineDynamicShifts.apply(testee)
+    assert actual == expected
+
+
+def test_inline_dynamic_shift_let_var():
+    testee = im.let("tmp", im.as_fieldop("deref")("inp"))(
+        im.as_fieldop(im.lambda_("a", "b")(im.deref(im.shift("IOff", im.deref("b"))("a"))))(
+            "tmp", "offset_field"
+        )
+    )
+
+    expected = im.as_fieldop(
+        im.lambda_("inp", "offset_field")(
+            im.deref(im.shift("IOff", im.deref("offset_field"))("inp"))
+        )
+    )("inp", "offset_field")
+
+    actual = inline_dynamic_shifts.InlineDynamicShifts.apply(testee)
+    assert actual == expected
diff --git a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py
index 2492fc44..779ab738 100644
--- a/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py
+++ b/tests/next_tests/unit_tests/iterator_tests/transforms_tests/test_domain_inference.py
@@ -76,7 +76,7 @@ def setup_test_as_fieldop(
 def run_test_program(
     testee: itir.Program, expected: itir.Program, offset_provider: common.OffsetProvider
 ) -> None:
-    actual_program = infer_domain.infer_program(testee, offset_provider)
+    actual_program = infer_domain.infer_program(testee, offset_provider=offset_provider)
 
     folded_program = constant_fold_domain_exprs(actual_program)
     assert folded_program == expected
@@ -89,12 +89,14 @@ def run_test_expr(
     expected_domains: dict[str, itir.Expr | dict[str | Dimension, tuple[itir.Expr, itir.Expr]]],
     offset_provider: common.OffsetProvider,
     symbolic_domain_sizes: Optional[dict[str, str]] = None,
+    allow_uninferred: bool = False,
 ):
     actual_call, actual_domains = infer_domain.infer_expr(
         testee,
         domain_utils.SymbolicDomain.from_expr(domain),
-        offset_provider,
-        symbolic_domain_sizes,
+        offset_provider=offset_provider,
+        symbolic_domain_sizes=symbolic_domain_sizes,
+        allow_uninferred=allow_uninferred,
     )
     folded_call = constant_fold_domain_exprs(actual_call)
     folded_domains = constant_fold_accessed_domains(actual_domains) if actual_domains else None
@@ -104,10 +106,8 @@ def run_test_expr(
     def canonicalize_domain(d):
         if isinstance(d, dict):
             return im.domain(grid_type, d)
-        elif isinstance(d, itir.FunCall):
+        elif isinstance(d, (itir.FunCall, infer_domain.DomainAccessDescriptor)):
             return d
-        elif d is None:
-            return None
         raise AssertionError()
 
     expected_domains = {ref: canonicalize_domain(d) for ref, d in expected_domains.items()}
@@ -128,10 +128,12 @@ def constant_fold_domain_exprs(arg: itir.Node) -> itir.Node:
 
 
 def constant_fold_accessed_domains(
-    domains: infer_domain.ACCESSED_DOMAINS,
-) -> infer_domain.ACCESSED_DOMAINS:
-    def fold_domain(domain: domain_utils.SymbolicDomain | None):
-        if domain is None:
+    domains: infer_domain.AccessedDomains,
+) -> infer_domain.AccessedDomains:
+    def fold_domain(
+        domain: domain_utils.SymbolicDomain | Literal[infer_domain.DomainAccessDescriptor.NEVER],
+    ):
+        if isinstance(domain, infer_domain.DomainAccessDescriptor):
             return domain
         return constant_fold_domain_exprs(domain.as_expr())
 
@@ -154,7 +156,7 @@ def translate_domain(
     shift_list = [item for sublist in shift_tuples for item in sublist]
 
     translated_domain_expr = domain_utils.SymbolicDomain.from_expr(domain).translate(
-        shift_list, offset_provider
+        shift_list, offset_provider=offset_provider
     )
 
     return constant_fold_domain_exprs(translated_domain_expr.as_expr())
@@ -340,7 +342,7 @@ def test_nested_stencils(offset_provider):
         "in_field2": translate_domain(domain, {"Ioff": 0, "Joff": -2}, offset_provider),
     }
     actual_call, actual_domains = infer_domain.infer_expr(
-        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider
+        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider=offset_provider
     )
     folded_domains = constant_fold_accessed_domains(actual_domains)
     folded_call = constant_fold_domain_exprs(actual_call)
@@ -384,7 +386,7 @@ def test_nested_stencils_n_times(offset_provider, iterations):
     }
 
     actual_call, actual_domains = infer_domain.infer_expr(
-        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider
+        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider=offset_provider
     )
 
     folded_domains = constant_fold_accessed_domains(actual_domains)
@@ -397,7 +399,10 @@ def test_unused_input(offset_provider):
     stencil = im.lambda_("arg0", "arg1")(im.deref("arg0"))
 
     domain = im.domain(common.GridType.CARTESIAN, {IDim: (0, 11)})
-    expected_domains = {"in_field1": {IDim: (0, 11)}, "in_field2": None}
+    expected_domains = {
+        "in_field1": {IDim: (0, 11)},
+        "in_field2": infer_domain.DomainAccessDescriptor.NEVER,
+    }
     testee, expected = setup_test_as_fieldop(
         stencil,
         domain,
@@ -409,7 +414,7 @@ def test_let_unused_field(offset_provider):
     testee = im.let("a", "c")("b")
     domain = im.domain(common.GridType.CARTESIAN, {IDim: (0, 11)})
     expected = im.let("a", "c")("b")
-    expected_domains = {"b": {IDim: (0, 11)}, "c": None}
+    expected_domains = {"b": {IDim: (0, 11)}, "c": infer_domain.DomainAccessDescriptor.NEVER}
 
     run_test_expr(testee, expected, domain, expected_domains, offset_provider)
 
@@ -522,7 +527,7 @@ def test_cond(offset_provider):
     expected = im.if_(cond, expected_field_1, expected_field_2)
 
     actual_call, actual_domains = infer_domain.infer_expr(
-        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider
+        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider=offset_provider
     )
 
     folded_domains = constant_fold_accessed_domains(actual_domains)
@@ -579,7 +584,7 @@ def test_let(offset_provider):
 
     expected_domains_sym = {"in_field": translate_domain(domain, {"Ioff": 2}, offset_provider)}
     actual_call2, actual_domains2 = infer_domain.infer_expr(
-        testee2, domain_utils.SymbolicDomain.from_expr(domain), offset_provider
+        testee2, domain_utils.SymbolicDomain.from_expr(domain), offset_provider=offset_provider
     )
     folded_domains2 = constant_fold_accessed_domains(actual_domains2)
     folded_call2 = constant_fold_domain_exprs(actual_call2)
@@ -803,7 +808,7 @@ def test_make_tuple(offset_provider):
             domain_utils.SymbolicDomain.from_expr(domain1),
             domain_utils.SymbolicDomain.from_expr(domain2),
         ),
-        offset_provider,
+        offset_provider=offset_provider,
     )
 
     assert expected == actual
@@ -815,13 +820,13 @@ def test_tuple_get_1_make_tuple(offset_provider):
     domain = im.domain(common.GridType.CARTESIAN, {IDim: (0, 11)})
     expected = im.tuple_get(1, im.make_tuple(im.ref("a"), im.ref("b"), im.ref("c")))
     expected_domains = {
-        "a": None,
+        "a": infer_domain.DomainAccessDescriptor.NEVER,
         "b": im.domain(common.GridType.CARTESIAN, {IDim: (0, 11)}),
-        "c": None,
+        "c": infer_domain.DomainAccessDescriptor.NEVER,
     }
 
     actual, actual_domains = infer_domain.infer_expr(
-        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider
+        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider=offset_provider
     )
 
     assert expected == actual
@@ -833,7 +838,7 @@ def test_tuple_get_1_nested_make_tuple(offset_provider):
     domain1 = im.domain(common.GridType.CARTESIAN, {IDim: (0, 11)})
     domain2 = im.domain(common.GridType.CARTESIAN, {IDim: (0, 12)})
     expected = im.tuple_get(1, im.make_tuple(im.ref("a"), im.make_tuple(im.ref("b"), im.ref("c"))))
-    expected_domains = {"a": None, "b": domain1, "c": domain2}
+    expected_domains = {"a": infer_domain.DomainAccessDescriptor.NEVER, "b": domain1, "c": domain2}
 
     actual, actual_domains = infer_domain.infer_expr(
         testee,
@@ -841,7 +846,7 @@ def test_tuple_get_1_nested_make_tuple(offset_provider):
             domain_utils.SymbolicDomain.from_expr(domain1),
             domain_utils.SymbolicDomain.from_expr(domain2),
         ),
-        offset_provider,
+        offset_provider=offset_provider,
     )
 
     assert expected == actual
@@ -852,14 +857,18 @@ def test_tuple_get_let_arg_make_tuple(offset_provider):
     testee = im.tuple_get(1, im.let("a", im.make_tuple(im.ref("b"), im.ref("c")))("d"))
     domain = im.domain(common.GridType.CARTESIAN, {IDim: (0, 11)})
     expected = im.tuple_get(1, im.let("a", im.make_tuple(im.ref("b"), im.ref("c")))("d"))
-    expected_domains = {"b": None, "c": None, "d": (None, domain)}
+    expected_domains = {
+        "b": infer_domain.DomainAccessDescriptor.NEVER,
+        "c": infer_domain.DomainAccessDescriptor.NEVER,
+        "d": (infer_domain.DomainAccessDescriptor.NEVER, domain),
+    }
 
     actual, actual_domains = infer_domain.infer_expr(
         testee,
         domain_utils.SymbolicDomain.from_expr(
             im.domain(common.GridType.CARTESIAN, {IDim: (0, 11)})
         ),
-        offset_provider,
+        offset_provider=offset_provider,
     )
 
     assert expected == actual
@@ -870,12 +879,16 @@ def test_tuple_get_let_make_tuple(offset_provider):
     testee = im.tuple_get(1, im.let("a", "b")(im.make_tuple(im.ref("c"), im.ref("d"))))
     domain = im.domain(common.GridType.CARTESIAN, {IDim: (0, 11)})
     expected = im.tuple_get(1, im.let("a", "b")(im.make_tuple(im.ref("c"), im.ref("d"))))
-    expected_domains = {"c": None, "d": domain, "b": None}
+    expected_domains = {
+        "c": infer_domain.DomainAccessDescriptor.NEVER,
+        "d": domain,
+        "b": infer_domain.DomainAccessDescriptor.NEVER,
+    }
 
     actual, actual_domains = infer_domain.infer_expr(
         testee,
         domain_utils.SymbolicDomain.from_expr(domain),
-        offset_provider,
+        offset_provider=offset_provider,
     )
 
     assert expected == actual
@@ -903,7 +916,7 @@ def test_nested_make_tuple(offset_provider):
             ),
             domain_utils.SymbolicDomain.from_expr(domain3),
         ),
-        offset_provider,
+        offset_provider=offset_provider,
     )
 
     assert expected == actual
@@ -914,10 +927,10 @@ def test_tuple_get_1(offset_provider):
     testee = im.tuple_get(1, im.ref("a"))
     domain = im.domain(common.GridType.CARTESIAN, {IDim: (0, 11)})
     expected = im.tuple_get(1, im.ref("a"))
-    expected_domains = {"a": (None, domain)}
+    expected_domains = {"a": (infer_domain.DomainAccessDescriptor.NEVER, domain)}
 
     actual, actual_domains = infer_domain.infer_expr(
-        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider
+        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider=offset_provider
     )
 
     assert expected == actual
@@ -937,7 +950,7 @@ def test_domain_tuple(offset_provider):
             domain_utils.SymbolicDomain.from_expr(domain1),
             domain_utils.SymbolicDomain.from_expr(domain2),
         ),
-        offset_provider,
+        offset_provider=offset_provider,
     )
 
     assert expected == actual
@@ -953,7 +966,7 @@ def test_as_fieldop_tuple_get(offset_provider):
     expected_domains = {"a": (domain, domain)}
 
     actual, actual_domains = infer_domain.infer_expr(
-        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider
+        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider=offset_provider
     )
 
     assert expected == actual
@@ -973,7 +986,7 @@ def test_make_tuple_2tuple_get(offset_provider):
             domain_utils.SymbolicDomain.from_expr(domain1),
             domain_utils.SymbolicDomain.from_expr(domain2),
         ),
-        offset_provider,
+        offset_provider=offset_provider,
     )
 
     assert expected == actual
@@ -990,7 +1003,7 @@ def test_make_tuple_non_tuple_domain(offset_provider):
     expected_domains = {"in_field1": domain, "in_field2": domain}
 
     actual, actual_domains = infer_domain.infer_expr(
-        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider
+        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider=offset_provider
     )
 
     assert expected == actual
@@ -1004,7 +1017,7 @@ def test_arithmetic_builtin(offset_provider):
     expected_domains = {}
 
     actual_call, actual_domains = infer_domain.infer_expr(
-        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider
+        testee, domain_utils.SymbolicDomain.from_expr(domain), offset_provider=offset_provider
     )
     folded_call = constant_fold_domain_exprs(actual_call)
 
@@ -1048,3 +1061,35 @@ def test_symbolic_domain_sizes(unstructured_offset_provider):
         unstructured_offset_provider,
         symbolic_domain_sizes,
     )
+
+
+def test_unknown_domain(offset_provider):
+    stencil = im.lambda_("arg0", "arg1")(im.deref(im.shift("Ioff", im.deref("arg1"))("arg0")))
+    domain = im.domain(common.GridType.CARTESIAN, {IDim: (0, 10)})
+    expected_domains = {
+        "in_field1": infer_domain.DomainAccessDescriptor.UNKNOWN,
+        "in_field2": {IDim: (0, 10)},
+    }
+    testee, expected = setup_test_as_fieldop(stencil, domain)
+    run_test_expr(testee, expected, domain, expected_domains, offset_provider)
+
+
+def test_never_accessed_domain(offset_provider):
+    stencil = im.lambda_("arg0", "arg1")(im.deref("arg0"))
+    domain = im.domain(common.GridType.CARTESIAN, {IDim: (0, 10)})
+    expected_domains = {
+        "in_field1": {IDim: (0, 10)},
+        "in_field2": infer_domain.DomainAccessDescriptor.NEVER,
+    }
+    testee, expected = setup_test_as_fieldop(stencil, domain)
+    run_test_expr(testee, expected, domain, expected_domains, offset_provider)
+
+
+def test_never_accessed_domain_tuple(offset_provider):
+    testee = im.tuple_get(0, im.make_tuple("in_field1", "in_field2"))
+    domain = im.domain(common.GridType.CARTESIAN, {IDim: (0, 10)})
+    expected_domains = {
+        "in_field1": {IDim: (0, 10)},
+        "in_field2": infer_domain.DomainAccessDescriptor.NEVER,
+    }
+    run_test_expr(testee, testee, domain, expected_domains, offset_provider)