Repository: GridTools/gt4py
Pull request #1229 (2023-04-11 09:20:10)

test[next]: Move ffront laplacian tests


Patch:
diff --git a/.github/workflows/daily-ci.yml b/.github/workflows/daily-ci.yml
index c3a204ab..77ba39a3 100644
--- a/.github/workflows/daily-ci.yml
+++ b/.github/workflows/daily-ci.yml
@@ -5,9 +5,9 @@ on:
   - cron: '0 4 * * *'
 
   ## COMMENTED OUT: only for testing CI action changes
-  ## pull_request:
-  ##   branches:
-  ##   - main
+  # pull_request:
+  #   branches:
+  #   - main
   ## END
 
 jobs:
@@ -17,6 +17,7 @@ jobs:
         python-version: ["3.8", "3.9", "3.10"]
         tox-module-factor: ["cartesian", "eve", "next", "storage"]
         os: ["ubuntu-latest"]
+        requirements-file: ["requirements-dev.txt", "min-requirements-test.txt", "min-extra-requirements-test.txt"]
       fail-fast: false
 
     runs-on: ${{ matrix.os }}
@@ -56,7 +57,6 @@ jobs:
       run: |
         pyversion=${{ matrix.python-version }}
         pyversion_no_dot=${pyversion//./}
-        cp constraints.txt CURRENT-constraints.txt 
         tox run -e requirements-py${pyversion_no_dot}
     # TODO(egparedes): add notification for dependencies updates
     # - name: Check for updated requirements
@@ -102,6 +102,7 @@ jobs:
     - name: Run tests
       env:
         NUM_PROCESSES: auto
+        ENV_REQUIREMENTS_FILE: ${{ matrix.requirements-file }}
       run: |
         tox run --skip-missing-interpreters -m test-${{ matrix.tox-module-factor }}-cpu
     - name: Notify slack
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 660698d4..9d58e2de 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -62,7 +62,7 @@ repos:
   ## version = re.search('black==([0-9\.]*)', open("constraints.txt").read())[1] 
   ## print(f"rev: '{version}'  # version from constraints.txt")
   ##]]]
-  rev: '23.1.0'  # version from constraints.txt
+  rev: '23.3.0'  # version from constraints.txt
   ##[[[end]]]
   hooks:
   - id: black
@@ -97,13 +97,13 @@ repos:
     ##     print(f"- {pkg}==" + str(re.search(f'\n{pkg}==([0-9\.]*)', constraints)[1]))
     ##]]]
     - darglint==1.8.1
-    - flake8-bugbear==23.2.13
+    - flake8-bugbear==23.3.12
     - flake8-builtins==2.1.0
     - flake8-debugger==4.1.2
     - flake8-docstrings==1.7.0
     - flake8-eradicate==1.4.0
     - flake8-mutable==1.2.0
-    - flake8-pyproject==1.2.2
+    - flake8-pyproject==1.2.3
     - pygments==2.14.0
     ##[[[end]]]
     # - flake8-rst-docstrings  # Disabled for now due to random false positives
@@ -139,9 +139,9 @@ repos:
   ## version = re.search('mypy==([0-9\.]*)', open("constraints.txt").read())[1] 
   ## print(f"#========= FROM constraints.txt: v{version} =========")
   ##]]]
-  #========= FROM constraints.txt: v1.0.1 =========
+  #========= FROM constraints.txt: v1.1.1 =========
   ##[[[end]]]
-  rev: v1.0.1  # MUST match version ^^^^ in constraints.txt (if the mirror is up-to-date)
+  rev: v1.1.1  # MUST match version ^^^^ in constraints.txt (if the mirror is up-to-date)
   hooks:
   - id: mypy
     additional_dependencies:  # versions from constraints.txt
@@ -155,29 +155,26 @@ repos:
     ##]]]
     - astunparse==1.6.3
     - attrs==22.2.0
-    - black==23.1.0
+    - black==23.3.0
     - boltons==23.0.0
     - cached-property==1.5.2
     - click==8.1.3
-    - cmake==3.25.2
+    - cmake==3.26.1
     - cytoolz==0.12.1
-    - deepdiff==6.2.3
+    - deepdiff==6.3.0
     - devtools==0.10.0
-    - frozendict==2.3.5
+    - frozendict==2.3.6
     - gridtools-cpp==2.2.3
     - importlib-resources==5.12.0
     - jinja2==3.1.2
     - lark==1.1.5
     - mako==1.2.4
-    - networkx==3.0
     - ninja==1.11.1
     - numpy==1.24.2
     - packaging==23.0
-    - pybind11==2.10.3
-    - scipy==1.10.1
-    - setuptools==67.5.1
+    - pybind11==2.10.4
+    - setuptools==67.6.1
     - tabulate==0.9.0
-    - toolz==0.12.0
     - typing-extensions==4.5.0
     - xxhash==3.0.0
     ##[[[end]]]
diff --git a/README.md b/README.md
index 7c313375..4ef08dd9 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
 ![license](https://img.shields.io/github/license/GridTools/gt4py)
 [![slack](https://img.shields.io/badge/slack-join-orange?logo=slack&labelColor=3a3a3a)](https://join.slack.com/t/gridtools/shared_invite/zt-1mceuj747-59swuowC3MKAuCFyNAnc1g)
 
+[![Daily CI](https://github.com/GridTools/gt4py/actions/workflows/daily-ci.yml/badge.svg)](https://github.com/GridTools/gt4py/actions/workflows/daily-ci.yml)
 ![test-cartesian](https://github.com/GridTools/gt4py/actions/workflows/test-cartesian.yml/badge.svg?branch=main)
 ![test-next](https://github.com/GridTools/gt4py/actions/workflows/test-next.yml/badge.svg?branch=main)
 ![test-storage](https://github.com/GridTools/gt4py/actions/workflows/test-storage.yml/badge.svg?branch=main)
diff --git a/constraints.txt b/constraints.txt
index ac8b82ef..42ce055e 100644
--- a/constraints.txt
+++ b/constraints.txt
@@ -4,13 +4,13 @@
 #
 #    "tox run -e requirements-common"
 #
-aenum==3.1.11             # via dace
+aenum==3.1.12             # via dace
 alabaster==0.7.13         # via sphinx
 asttokens==2.2.1          # via devtools
 astunparse==1.6.3 ; python_version < "3.9"  # via dace, gt4py (pyproject.toml)
 attrs==22.2.0             # via flake8-bugbear, flake8-eradicate, gt4py (pyproject.toml), hypothesis, jsonschema, pytest
 babel==2.12.1             # via sphinx
-black==23.1.0             # via gt4py (pyproject.toml)
+black==23.3.0             # via gt4py (pyproject.toml)
 boltons==23.0.0           # via gt4py (pyproject.toml)
 build==0.10.0             # via pip-tools
 cached-property==1.5.2    # via gt4py (pyproject.toml)
@@ -20,46 +20,46 @@ cffi==1.15.1              # via cryptography
 cfgv==3.3.1               # via pre-commit
 chardet==5.1.0            # via tox
 charset-normalizer==3.1.0  # via requests
-clang-format==15.0.7      # via -r requirements-dev.in, gt4py (pyproject.toml)
+clang-format==16.0.0      # via -r requirements-dev.in, gt4py (pyproject.toml)
 click==8.1.3              # via black, flask, gt4py (pyproject.toml), pip-tools
-cmake==3.25.2             # via -r requirements-dev.in, gt4py (pyproject.toml)
+cmake==3.26.1             # via gt4py (pyproject.toml)
 cogapp==3.3.0             # via -r requirements-dev.in
 colorama==0.4.6           # via tox
-coverage==7.2.1           # via -r requirements-dev.in, pytest-cov
-cryptography==39.0.2      # via types-paramiko, types-pyopenssl, types-redis
+coverage==7.2.2           # via -r requirements-dev.in, pytest-cov
+cryptography==40.0.1      # via types-paramiko, types-pyopenssl, types-redis
 cytoolz==0.12.1           # via gt4py (pyproject.toml)
 dace==0.14.2              # via gt4py (pyproject.toml)
 darglint==1.8.1           # via -r requirements-dev.in
-deepdiff==6.2.3           # via gt4py (pyproject.toml)
-devtools==0.10.0          # via -r requirements-dev.in, gt4py (pyproject.toml)
+deepdiff==6.3.0           # via gt4py (pyproject.toml)
+devtools==0.10.0          # via gt4py (pyproject.toml)
 dill==0.3.6               # via dace
 distlib==0.3.6            # via virtualenv
 docutils==0.18.1          # via restructuredtext-lint, sphinx, sphinx-rtd-theme
 eradicate==2.2.0          # via flake8-eradicate
-exceptiongroup==1.1.0     # via hypothesis, pytest
+exceptiongroup==1.1.1     # via hypothesis, pytest
 execnet==1.9.0            # via pytest-cache, pytest-xdist
 executing==1.2.0          # via devtools
 factory-boy==3.2.1        # via -r requirements-dev.in, pytest-factoryboy
-faker==17.6.0             # via factory-boy
+faker==18.3.4             # via factory-boy
 fastjsonschema==2.16.3    # via nbformat
-filelock==3.9.0           # via tox, virtualenv
+filelock==3.10.7          # via tox, virtualenv
 flake8==5.0.4             # via -r requirements-dev.in, flake8-bugbear, flake8-builtins, flake8-debugger, flake8-docstrings, flake8-eradicate, flake8-mutable, flake8-pyproject, flake8-rst-docstrings
-flake8-bugbear==23.2.13   # via -r requirements-dev.in
+flake8-bugbear==23.3.12   # via -r requirements-dev.in
 flake8-builtins==2.1.0    # via -r requirements-dev.in
 flake8-debugger==4.1.2    # via -r requirements-dev.in
 flake8-docstrings==1.7.0  # via -r requirements-dev.in
 flake8-eradicate==1.4.0   # via -r requirements-dev.in
 flake8-mutable==1.2.0     # via -r requirements-dev.in
-flake8-pyproject==1.2.2   # via -r requirements-dev.in
+flake8-pyproject==1.2.3   # via -r requirements-dev.in
 flake8-rst-docstrings==0.3.0  # via -r requirements-dev.in
 flask==2.2.3              # via dace
-frozendict==2.3.5         # via gt4py (pyproject.toml)
+frozendict==2.3.6         # via gt4py (pyproject.toml)
 gridtools-cpp==2.2.3      # via gt4py (pyproject.toml)
-hypothesis==6.68.2        # via -r requirements-dev.in, gt4py (pyproject.toml)
-identify==2.5.18          # via pre-commit
+hypothesis==6.70.2        # via -r requirements-dev.in, gt4py (pyproject.toml)
+identify==2.5.22          # via pre-commit
 idna==3.4                 # via requests
 imagesize==1.4.1          # via sphinx
-importlib-metadata==6.0.0  # via flask, sphinx
+importlib-metadata==6.1.0  # via flask, sphinx
 importlib-resources==5.12.0 ; python_version < "3.9"  # via gt4py (pyproject.toml), jsonschema
 inflection==0.5.1         # via pytest-factoryboy
 iniconfig==2.0.0          # via pytest
@@ -67,7 +67,7 @@ isort==5.12.0             # via -r requirements-dev.in
 itsdangerous==2.1.2       # via flask
 jinja2==3.1.2             # via flask, gt4py (pyproject.toml), sphinx
 jsonschema==4.17.3        # via nbformat
-jupyter-core==5.2.0       # via nbformat
+jupyter-core==5.3.0       # via nbformat
 jupytext==1.14.5          # via -r requirements-dev.in
 lark==1.1.5               # via gt4py (pyproject.toml)
 mako==1.2.4               # via gt4py (pyproject.toml)
@@ -76,55 +76,53 @@ markupsafe==2.1.2         # via jinja2, mako, werkzeug
 mccabe==0.7.0             # via flake8
 mdit-py-plugins==0.3.5    # via jupytext
 mdurl==0.1.2              # via markdown-it-py
-mpmath==1.2.1             # via sympy
-mypy==1.0.1               # via -r requirements-dev.in
+mpmath==1.3.0             # via sympy
+mypy==1.1.1               # via -r requirements-dev.in
 mypy-extensions==1.0.0    # via black, mypy
-nbformat==5.7.3           # via jupytext
-networkx==3.0             # via dace, gt4py (pyproject.toml)
+nbformat==5.8.0           # via jupytext
+networkx==3.1             # via dace
 ninja==1.11.1             # via gt4py (pyproject.toml)
 nodeenv==1.7.0            # via pre-commit
-numpy==1.24.2             # via dace, gt4py (pyproject.toml), scipy, types-jack-client
+numpy==1.24.2             # via dace, gt4py (pyproject.toml), types-jack-client
 ordered-set==4.1.0        # via deepdiff
-orjson==3.8.7             # via deepdiff
 packaging==23.0           # via black, build, gt4py (pyproject.toml), pyproject-api, pytest, sphinx, tox
-pathspec==0.11.0          # via black
+pathspec==0.11.1          # via black
 pip-tools==6.12.3         # via -r requirements-dev.in
-pipdeptree==2.5.2         # via -r requirements-dev.in
+pipdeptree==2.7.0         # via -r requirements-dev.in
 pkgutil-resolve-name==1.3.10  # via jsonschema
-platformdirs==3.1.0       # via black, jupyter-core, tox, virtualenv
+platformdirs==3.2.0       # via black, jupyter-core, tox, virtualenv
 pluggy==1.0.0             # via pytest, tox
 ply==3.11                 # via dace
-pre-commit==3.1.1         # via -r requirements-dev.in
+pre-commit==3.2.2         # via -r requirements-dev.in
 psutil==5.9.4             # via -r requirements-dev.in, pytest-xdist
-pybind11==2.10.3          # via gt4py (pyproject.toml)
+pybind11==2.10.4          # via gt4py (pyproject.toml)
 pycodestyle==2.9.1        # via flake8, flake8-debugger
 pycparser==2.21           # via cffi
 pydocstyle==6.3.0         # via flake8-docstrings
 pyflakes==2.5.0           # via flake8
 pygments==2.14.0          # via -r requirements-dev.in, flake8-rst-docstrings, sphinx
-pyproject-api==1.5.0      # via tox
+pyproject-api==1.5.1      # via tox
 pyproject-hooks==1.0.0    # via build
 pyrsistent==0.19.3        # via jsonschema
 pytest==7.2.2             # via -r requirements-dev.in, gt4py (pyproject.toml), pytest-cache, pytest-cov, pytest-factoryboy, pytest-xdist
 pytest-cache==1.0         # via -r requirements-dev.in
 pytest-cov==4.0.0         # via -r requirements-dev.in
 pytest-factoryboy==2.5.1  # via -r requirements-dev.in
-pytest-xdist==3.2.0       # via -r requirements-dev.in
+pytest-xdist==3.2.1       # via -r requirements-dev.in
 python-dateutil==2.8.2    # via faker
-pytz==2022.7.1            # via babel
+pytz==2023.3              # via babel
 pyyaml==6.0               # via dace, jupytext, pre-commit
 requests==2.28.2          # via dace, sphinx
 restructuredtext-lint==1.4.0  # via flake8-rst-docstrings
-scipy==1.10.1             # via gt4py (pyproject.toml)
 six==1.16.0               # via asttokens, astunparse, python-dateutil
 snowballstemmer==2.2.0    # via pydocstyle, sphinx
 sortedcontainers==2.4.0   # via hypothesis
-sphinx==6.1.3             # via -r requirements-dev.in, sphinx-rtd-theme
+sphinx==6.1.3             # via -r requirements-dev.in, sphinx-rtd-theme, sphinxcontrib-jquery
 sphinx-rtd-theme==1.2.0   # via -r requirements-dev.in
 sphinxcontrib-applehelp==1.0.4  # via sphinx
 sphinxcontrib-devhelp==1.0.2  # via sphinx
 sphinxcontrib-htmlhelp==2.0.1  # via sphinx
-sphinxcontrib-jquery==2.0.0  # via sphinx-rtd-theme
+sphinxcontrib-jquery==4.1  # via sphinx-rtd-theme
 sphinxcontrib-jsmath==1.0.1  # via sphinx
 sphinxcontrib-qthelp==1.0.3  # via sphinx
 sphinxcontrib-serializinghtml==1.1.5  # via sphinx
@@ -132,36 +130,36 @@ sympy==1.9                # via dace, gt4py (pyproject.toml)
 tabulate==0.9.0           # via gt4py (pyproject.toml)
 toml==0.10.2              # via jupytext
 tomli==2.0.1              # via -r requirements-dev.in, black, build, coverage, flake8-pyproject, mypy, pyproject-api, pyproject-hooks, pytest, tox
-toolz==0.12.0             # via cytoolz, gt4py (pyproject.toml)
-tox==4.4.6                # via -r requirements-dev.in
+toolz==0.12.0             # via cytoolz
+tox==4.4.8                # via -r requirements-dev.in
 traitlets==5.9.0          # via jupyter-core, nbformat
-types-aiofiles==22.1.0.9  # via types-all
+types-aiofiles==23.1.0.1  # via types-all
 types-all==1.0.0          # via -r requirements-dev.in
-types-annoy==1.17.8.2     # via types-all
+types-annoy==1.17.8.3     # via types-all
 types-atomicwrites==1.4.5.1  # via types-all
 types-backports==0.1.3    # via types-all
 types-backports-abc==0.5.2  # via types-all
-types-bleach==6.0.0.1     # via types-all
-types-boto==2.49.18.6     # via types-all
-types-cachetools==5.3.0.4  # via types-all
+types-bleach==6.0.0.2     # via types-all
+types-boto==2.49.18.7     # via types-all
+types-cachetools==5.3.0.5  # via types-all
 types-certifi==2021.10.8.3  # via types-all
-types-cffi==1.15.1.7      # via types-jack-client
+types-cffi==1.15.1.10     # via types-jack-client
 types-characteristic==14.3.7  # via types-all
-types-chardet==5.0.4.2    # via types-all
+types-chardet==5.0.4.3    # via types-all
 types-click==7.1.8        # via types-all, types-flask
-types-click-spinner==0.1.13.3  # via types-all
-types-colorama==0.4.15.8  # via types-all
-types-contextvars==2.4.7.1  # via types-all
-types-croniter==1.3.2.6   # via types-all
+types-click-spinner==0.1.13.4  # via types-all
+types-colorama==0.4.15.11  # via types-all
+types-contextvars==2.4.7.2  # via types-all
+types-croniter==1.3.2.7   # via types-all
 types-cryptography==3.3.23.2  # via types-all, types-openssl-python, types-pyjwt
 types-dataclasses==0.6.6  # via types-all
-types-dateparser==1.1.4.8  # via types-all
-types-datetimerange==2.0.0.3  # via types-all
+types-dateparser==1.1.4.9  # via types-all
+types-datetimerange==2.0.0.5  # via types-all
 types-decorator==5.1.8.3  # via types-all
-types-deprecated==1.2.9.1  # via types-all
+types-deprecated==1.2.9.2  # via types-all
 types-docopt==0.6.11.3    # via types-all
-types-docutils==0.19.1.6  # via types-all
-types-emoji==2.1.0.2      # via types-all
+types-docutils==0.19.1.7  # via types-all
+types-emoji==2.1.0.3      # via types-all
 types-enum34==1.1.8       # via types-all
 types-fb303==1.0.0        # via types-all, types-scribe
 types-filelock==3.2.7     # via types-all
@@ -173,66 +171,66 @@ types-futures==3.3.8      # via types-all
 types-geoip2==3.0.0       # via types-all
 types-ipaddress==1.0.8    # via types-all, types-maxminddb
 types-itsdangerous==1.1.6  # via types-all
-types-jack-client==0.5.10.7  # via types-all
+types-jack-client==0.5.10.8  # via types-all
 types-jinja2==2.11.9      # via types-all, types-flask
 types-kazoo==0.1.3        # via types-all
-types-markdown==3.4.2.5   # via types-all
+types-markdown==3.4.2.7   # via types-all
 types-markupsafe==1.1.10  # via types-all, types-jinja2
 types-maxminddb==1.5.0    # via types-all, types-geoip2
-types-mock==5.0.0.5       # via types-all
+types-mock==5.0.0.6       # via types-all
 types-mypy-extensions==1.0.0.3  # via types-all
 types-nmap==0.1.6         # via types-all
 types-openssl-python==0.1.3  # via types-all
 types-orjson==3.6.2       # via types-all
-types-paramiko==3.0.0.4   # via types-all, types-pysftp
+types-paramiko==3.0.0.7   # via types-all, types-pysftp
 types-pathlib2==2.3.0     # via types-all
-types-pillow==9.4.0.17    # via types-all
+types-pillow==9.4.0.19    # via types-all
 types-pkg-resources==0.1.3  # via types-all
 types-polib==1.2.0.0      # via types-all
-types-protobuf==4.22.0.0  # via types-all
+types-protobuf==4.22.0.2  # via types-all
 types-pyaudio==0.2.16.6   # via types-all
-types-pycurl==7.45.2.3    # via types-all
+types-pycurl==7.45.2.4    # via types-all
 types-pyfarmhash==0.3.1.1  # via types-all
 types-pyjwt==1.7.1        # via types-all
 types-pymssql==2.1.0      # via types-all
-types-pymysql==1.0.19.5   # via types-all
-types-pyopenssl==23.0.0.4  # via types-redis
-types-pyrfc3339==1.1.1.3  # via types-all
-types-pysftp==0.2.17.2    # via types-all
-types-python-dateutil==2.8.19.10  # via types-all, types-datetimerange
+types-pymysql==1.0.19.6   # via types-all
+types-pyopenssl==23.1.0.1  # via types-redis
+types-pyrfc3339==1.1.1.4  # via types-all
+types-pysftp==0.2.17.5    # via types-all
+types-python-dateutil==2.8.19.12  # via types-all, types-datetimerange
 types-python-gflags==3.1.7.2  # via types-all
-types-python-slugify==8.0.0.1  # via types-all
-types-pytz==2022.7.1.2    # via types-all, types-tzlocal
+types-python-slugify==8.0.0.2  # via types-all
+types-pytz==2023.3.0.0    # via types-all, types-tzlocal
 types-pyvmomi==8.0.0.1    # via types-all
-types-pyyaml==6.0.12.8    # via types-all
-types-redis==4.5.1.4      # via types-all
-types-requests==2.28.11.15  # via types-all
-types-retry==0.9.9.2      # via types-all
+types-pyyaml==6.0.12.9    # via types-all
+types-redis==4.5.4.1      # via types-all
+types-requests==2.28.11.17  # via types-all
+types-retry==0.9.9.3      # via types-all
 types-routes==2.5.0       # via types-all
 types-scribe==2.0.0       # via types-all
-types-simplejson==3.18.0.1  # via types-all
+types-simplejson==3.18.0.3  # via types-all
 types-singledispatch==4.0.0.1  # via types-all
-types-six==1.16.21.6      # via types-all
-types-tabulate==0.9.0.1   # via types-all
-types-termcolor==1.1.6.1  # via types-all
-types-toml==0.10.8.5      # via types-all
+types-six==1.16.21.8      # via types-all
+types-tabulate==0.9.0.2   # via types-all
+types-termcolor==1.1.6.2  # via types-all
+types-toml==0.10.8.6      # via types-all
 types-tornado==5.1.1      # via types-all
-types-typed-ast==1.5.8.4  # via types-all
-types-tzlocal==4.2.2.3    # via types-all
+types-typed-ast==1.5.8.6  # via types-all
+types-tzlocal==4.3.0.0    # via types-all
 types-ujson==5.7.0.1      # via types-all
-types-urllib3==1.26.25.8  # via types-requests
-types-waitress==2.1.4.5   # via types-all
+types-urllib3==1.26.25.10  # via types-requests
+types-waitress==2.1.4.7   # via types-all
 types-werkzeug==1.0.9     # via types-all, types-flask
 types-xxhash==3.0.5.2     # via types-all
 typing-extensions==4.5.0  # via black, gt4py (pyproject.toml), mypy, pytest-factoryboy
-urllib3==1.26.14          # via requests
-virtualenv==20.20.0       # via pre-commit, tox
-websockets==10.4          # via dace
+urllib3==1.26.15          # via requests
+virtualenv==20.21.0       # via pre-commit, tox
+websockets==11.0          # via dace
 werkzeug==2.2.3           # via flask
-wheel==0.38.4             # via astunparse, pip-tools
+wheel==0.40.0             # via astunparse, pip-tools
 xxhash==3.0.0             # via gt4py (pyproject.toml)
 zipp==3.15.0              # via importlib-metadata, importlib-resources
 
 # The following packages are considered to be unsafe in a requirements file:
 pip==23.0.1               # via pip-tools
-setuptools==67.5.1        # via -r requirements-dev.in, gt4py (pyproject.toml), nodeenv, pip-tools, sphinxcontrib-jquery
+setuptools==67.6.1        # via gt4py (pyproject.toml), nodeenv, pip-tools
diff --git a/docs/development/ADRs/0011-On_The_Fly_Compilation.md b/docs/development/ADRs/0011-On_The_Fly_Compilation.md
index 4768c8c8..14f61bf0 100644
--- a/docs/development/ADRs/0011-On_The_Fly_Compilation.md
+++ b/docs/development/ADRs/0011-On_The_Fly_Compilation.md
@@ -7,7 +7,7 @@ tags: [backend, bindings, build, compile, otf]
 - **Status**: valid
 - **Authors**: Rico Häuselmann (@DropD)
 - **Created**: 2022-09-12
-- **Updated**: 2022-10-20
+- **Updated**: 2023-04-03
 
 This supersedes [0009 - Compiled Backend Integration](0009-Compiled_Backend_Integration.md) and concentrates on the API design for on-the-fly compilation of GT4Py programs and all the steps in between IR and compiled Python extension.
 
@@ -155,33 +155,82 @@ There is a good use case that justifies the extra effort. Such as implementing c
 ### Statically Typed Workflows
 
 - decided: 2022-09-13
-- revised: --
-
-Workflow steps are generic on input and output type. Workflows are generic on input, intermediary and output type. This allows the use of static type checkers to ensure that the composed workflow has the intended input and output types, as well as that it is passed the correct argument types and that it's return value is used correctly.
+- revised: 2023-03-29
 
-In order to achieve this, workflows are implemented as nested pairs (or more generally, finite sized tuples) of steps, with the output type of the first step matching the input type of the second step in the pair. More complex workflow components than pairs might be introduced if the "Linear Workflows" decision is revised.
+Workflow steps are generic on input and output type and the chaining function is generic on the input, intermediary and (new) output type. This allows the use of static type checkers to ensure that the composed workflow has the intended input and output types, as well as that it is passed the correct argument types and that it's return value is used correctly.
 
-To allow building workflows in a linear-looking way in code, the first step can be wrapped in a `otf.workflow.Step` instance, on which `.chain` can be called with the next step. The result will be a workflow instance with `.chain` available also.
+To allow building workflows in a linear-looking way in code, the `otf.workflow.ChainableWorkflowMixin` mixin is provided.
+The first step can be wrapped in any chainable step type on which `.chain` can then be called with the next step. The result will be a workflow instance with `.chain` available also.
 
 ```
-workflow = <@todo put class name here too>(step1).chain(step2).chain(step3).chain(step4)
-# results in
-CombinedStep(
-  first=CombinedStep(
-    first=CombinedStep(
-      first=<@todo class name>(step1),
-      second=step2
-    ),
-    second=step3
-  ),
-  second=step4
-)
+step1: Workflow[I, A]
+step2: Workflow[A, B]
+step3: Workflow[B, C]
+step4: Workflow[C, O]
+workflow: Workflow[I, O] = StepSequence.start(step1).chain(step2).chain(step3).chain(step4)
 ```
 
+`otf.workflow.StepSequence` is only one example of a chainable step type.
+
+_edit 2023-03-29_
+
+`otf.workflow.NamedStepSequence` provides another way in which workflow steps can be composed, while keeping static typing somewhat intact.
+
+```python
+@dataclasses.dataclass(frozen=True)
+class ExampleWorkflow(NamedStepSequence):
+    parsing: Workflow[A, B]
+    lowering: Workflow[B, C]
+    first_pass: Workflow[C, C]
+    second_pass: Workflow[C, C]
+```
+
+As can be seen in the above example, the step types are statically hinted and mypy will catch wrong types on constructor calls. However, making sure that the steps are compatible in the order specified is up to the implementer of `ExampleWorkflow`.
+
 #### Reason
 
 It is in line with the rest of GT4Py to make static type checking for it's code objects as useful as possible. Any linear (and with extensions, non-linear) workflow can be represented in this way.
 
+_edit 2023-03-29_
+
+`NamedStepSequence` solves the following problem:
+
+By making it possible to quickly create a variant of an existing workflow (using `workflow.replace`), they make it unneccessary to escalate sub-workflow configuration.
+
+Coming back to the above example of `ExampleWorkflow`, instead of:
+
+```python
+examplewf_foo = Parser().chain(Lowerer(lower_flag=True)).chain(...)
+examplewf_bar = Parser().chain(Lowerer(lower_flag=False)).chain(...)
+```
+
+which avoids escalation, but is tedious, one might be tempted to implement a pattern like this:
+
+```python
+def make_workflow(lower_flag):
+    return Parser().chain(Lowerer(lower_flag=lower_flag)).chain(...)
+```
+
+which hardcodes the step classes and requires all sub-workflow configuration that needs to be accessible to be collected up front (escalated). One might fix the hardcoding of steps but the problem of escalated configuration just gets worse: now all configuration of all potentially allowed step types need to be escalated. `workflow.replace` in combination with `NamedStepSequence` solves both:
+
+```python
+examplewf_foo = ExampleWorkflow(
+    parsing=Parser(),
+    lowering=Lowerer(lower_flag=True),
+    ...
+)
+
+examplewf_bar = workflow.replace(
+    examplewf_foo,
+    lowering=dataclasses.replace(  # assuming the lowerer is a dataclass
+        examplewf_foo.lowering,
+        lower_flag=False
+    )
+)
+```
+
+This may be verbose but is much less dense semantically and much less error prone. A more terse interface may be added on top if warranted by usecases. This is of course a compromise when it comes to static typing. `workflow.replace` will take any argument which fulfills the `workflow.Workflow` protocol. This is a conscious tradeoff between usefulness of static typing and convenience of interface.
+
 #### Revise If
 
 There has to be a very strong reason to drop static type checking for composition and / or the resulting workflow. The author of this document can not imagine one at present.
diff --git a/docs/development/ADRs/0012-GridTools_Cpp_OTF_Steps.md b/docs/development/ADRs/0012-GridTools_Cpp_OTF_Steps.md
index a8cf594b..a256ee09 100644
--- a/docs/development/ADRs/0012-GridTools_Cpp_OTF_Steps.md
+++ b/docs/development/ADRs/0012-GridTools_Cpp_OTF_Steps.md
@@ -41,9 +41,7 @@ It is an instance of `program_processors.codegens.gtfn_modules.GTFNTranslationSt
 
 #### Executor
 
-`program_processors.runners.gtfn_cpu.run_gtfn` is the default `program_processors.processor_interface.ProgramExecutor` implementation of the GTFN backend. It is an instance of `program_processors.runners.gtfn_cpu.GTFNExecutor`, with sensible defaults for the configuration options allowed by that class. These include language settings and which builder should be used. BuildSystemProject configuration can be achieved through passing a fully configured builder factory.
-
-When called the `GTFNExecutor` instance composes a workflow from the above steps, the configured builder, importer and an arguments conversion decorator. The composed workflow is then called just in time for execution.
+`program_processors.runners.gtfn_cpu.run_gtfn` is the default `program_processors.processor_interface.ProgramExecutor` implementation of the GTFN backend. It sets sensible defaults for the configuration options on the individual steps. These include language settings and which builder should be used. Substep configuration can be achieved through passing one or more step instance to `dataclasses.replace(workflow.replace(run_gtfn.workflow, <name>=<instance>))`. Step instances can be obtained by replacing in a similar fashion.
 
 ## Alternatives Considered
 
diff --git a/docs/development/CI/infrastructure.md b/docs/development/tools/ci-infrastructure.md
similarity index 86%
rename from docs/development/CI/infrastructure.md
rename to docs/development/tools/ci-infrastructure.md
index 0f5ab82a..0870e532 100644
--- a/docs/development/CI/infrastructure.md
+++ b/docs/development/tools/ci-infrastructure.md
@@ -1,8 +1,8 @@
 # CI infrastructure
 
-Any test job that runs on CI is encoded in automation tools like tox and pre-commit and can be run locally instead.
+Any test job that runs on CI is encoded in automation tools like **tox** and **pre-commit** and can be run locally instead.
 
-## Workflows
+## GitHub Workflows
 
 The following workflows are currently active:
 
@@ -85,13 +85,17 @@ flowchart LR
 
 The `Test Eve`, `Test Storage (CPU)`, `Test Cartesian (CPU)`, and `Test Next (CPU)` workflows run the automated tests for the respective subpackages. In all cases only tests are run that do not require the presence of a GPU.
 
-The `Code Quality` workflow runs pre-commit to check code quality requirements through tools like mypy or flake8.
+The `Code Quality` workflow runs pre-commit to check code quality requirements through tools like **mypy** or **flake8**.
 
 Code coverage workflows are currently disabled.
 
 ### When are workflows triggered
 
-The general idea is to run workflows only when needed. In this monorepo structure, this practically means that a set of tests are only run when the associated sources or the sources of a dependency change. For example, eve tests will not be run when only GT4Py sources are changed.
+The general idea is to run workflows only when needed. In this monorepo structure, this practically means that a set of tests are only run when the associated sources or the sources of a dependency change. For example, `eve` tests will not be run when only GT4Py sources are changed. In those cases where some subpackage workflows are not triggered, replacement (always-succeeding) `Fallback:` workflows will be executed instead to satisfy the GitHub merge protection rules.
+
+### Daily CI
+
+There is an extra CI workflow on GitHub scheduled to run daily and testing `main` with different sets of requirements: newest dependencies, lowest dependencies versions and lowest dependencies versions including extras. Failures are accessible in [GitHub web interface](https://github.com/GridTools/gt4py/actions/workflows/daily-ci.yml) and as the 'Daily CI' badge in the main [README.md](../../../README.md) file. Additionally, in case of failure a message _might_ be posted in the [#jenkins](https://app.slack.com/client/T0A5HP547/C0E145U65) channel of the GridTols slack, but those notifications do not work reliably.
 
 ## CSCS-CI
 
diff --git a/docs/development/CI/cscs-ci.md b/docs/development/tools/cscs-ci.md
similarity index 100%
rename from docs/development/CI/cscs-ci.md
rename to docs/development/tools/cscs-ci.md
diff --git a/docs/development/tools/requirements.md b/docs/development/tools/requirements.md
new file mode 100644
index 00000000..a58cf49a
--- /dev/null
+++ b/docs/development/tools/requirements.md
@@ -0,0 +1,27 @@
+# Requirements
+
+The specification of required third-party packages is scattered and partially duplicated across several configuration files used by several tools. Keeping all package requirements in sync manually is challenging and error-prone. Therefore, in this project we use [pip-tools](https://pip-tools.readthedocs.io/en/latest/) and the [cog](https://nedbatchelder.com/code/cog/) file generation tool to avoid inconsistencies.
+
+The following files in this repository contain information about required third-party packages:
+
+- `pyproject.toml`: GT4Py [package configuration](https://peps.python.org/pep-0621/) used by the build backend (`setuptools`). Install dependencies are specified in the _project.dependencies_ and _project.optional-dependencies_ tables.
+- `requirements-dev.in`: [requirements file](https://pip.pypa.io/en/stable/reference/requirements-file-format/) used by **pip**. It contains a list of packages required for the development of GT4Py. Part of its content is generated automatically from `pyproject.toml` using **cog**.
+- `requirements-dev.txt`: requirements file used by **pip**. It contains a completely frozen list of all packages required for installing and developing GT4Py. It is used by **pip** and **tox** to initialize the standard development and testing environments. It is automatically generated automatically from `requirements-dev.in` by **pip-compile**, when running the **tox** environment to update requirements.
+- `constraints.txt`: [constraints file](https://pip.pypa.io/en/stable/user_guide/#constraints-files) used by **pip** and **tox** to initialize a subset of the standard development environment making sure that if other packages are installed, transitive dependencies are taken from the frozen package list. It is generated automatically from `requirements-dev.in` using **pip-compile**.
+- `min-requirements-test.txt`: requirements file used by **pip**. It contains the minimum list of requirements to run GT4Py tests with the oldest compatible versions of all dependencies. It is generated automatically from `pyproject.toml` using **cog**.
+- `min-extra-requirements-test.txt`: requirements file used by **pip**. It contains the minimum list of requirements to run GT4Py tests with the oldest compatible versions of all dependencies, additionally including all GT4Py extras. It is generated automatically from `pyproject.toml` using **cog**.
+- `.pre-commit-config.yaml`: **pre-commit** configuration with settings for many linting and formatting tools. Part of its content is generated automatically from `pyproject.toml` using **cog**.
+
+The expected workflow to update GT4Py requirements is as follows:
+
+1. For changes in the GT4Py package dependencies, update the relevant table in `pyproject.toml`. When modifying the _project.optional-dependencies_ tables, make sure the `full` extra table **always** contains the combined dependencies from all the other extra tables.
+
+2. For changes in the development tools, update the `requirements-dev.in` file.
+
+3. Run the **tox** _requirements-common_ environment to update all files automatically with **pip-compile** and **cog**. Note that **pip-compile** will most likely update the versions of some unrelated tools if new versions are available in PyPI.
+
+   ```bash
+   tox r -e requirements-common
+   ```
+
+4. Check that the **mypy** mirror used by **pre-commit** (https://github.com/pre-commit/mirrors-mypy) in `.pre-commit-config.yaml` supports the same version as in `constraints.txt`, and manually update the `rev` version number.
diff --git a/pyproject.toml b/pyproject.toml
index a63bdbc2..d7e33633 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,67 +5,64 @@ requires = ["setuptools>=60.6", "wheel", "cython"]
 # ---- Project description ----
 # -- Standard options (PEP 621) --
 [project]
-authors = [{name = "ETH Zurich", email = "gridtools@cscs.ch"}]
+authors = [{name = 'ETH Zurich', email = 'gridtools@cscs.ch'}]
 classifiers = [
-  "Development Status :: 4 - Beta",
-  "Environment :: Console",
-  "Environment :: GPU :: NVIDIA CUDA",
-  "Intended Audience :: Science/Research",
-  "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
-  "Operating System :: POSIX",
-  "Programming Language :: Python",
-  "Programming Language :: Python :: 3.8",
-  "Programming Language :: Python :: 3.9",
-  "Programming Language :: Python :: 3.10",
-  "Programming Language :: Python :: Implementation :: CPython",
-  "Topic :: Scientific/Engineering :: Atmospheric Science",
-  "Topic :: Scientific/Engineering :: Mathematics",
-  "Topic :: Scientific/Engineering :: Physics"
+  'Development Status :: 4 - Beta',
+  'Environment :: Console',
+  'Environment :: GPU :: NVIDIA CUDA',
+  'Intended Audience :: Science/Research',
+  'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
+  'Operating System :: POSIX',
+  'Programming Language :: Python',
+  'Programming Language :: Python :: 3.8',
+  'Programming Language :: Python :: 3.9',
+  'Programming Language :: Python :: 3.10',
+  'Programming Language :: Python :: Implementation :: CPython',
+  'Topic :: Scientific/Engineering :: Atmospheric Science',
+  'Topic :: Scientific/Engineering :: Mathematics',
+  'Topic :: Scientific/Engineering :: Physics'
 ]
 dependencies = [
   "astunparse>=1.6.3;python_version<'3.9'",
   "attrs>=21.3",
   "black>=22.3",
-  "boltons>=20.0",
-  "cached-property>=1.5",
-  "click>=7.1",
+  "boltons>=20.1",
+  "cached-property>=1.5.1",
+  "click>=8.0.0",
   "cmake>=3.22",
-  "cytoolz>=0.11",
-  "deepdiff>=5.8",
+  "cytoolz>=0.12.0",
+  "deepdiff>=5.6.0",
   "devtools>=0.6",
   "frozendict>=2.3",
   "gridtools-cpp>=2.2.3,==2.*",
   "importlib-resources>=5.0;python_version<'3.9'",
-  "jinja2>=2.10",
+  "jinja2>=3.0.0",
   "lark>=1.1.2",
   "mako>=1.1",
-  "networkx>=2.4",
   "ninja>=1.10",
-  "numpy>=1.15",
+  "numpy>=1.21.2",
   "packaging>=20.0",
   "pybind11>=2.5",
-  "scipy>=1.4",
-  "setuptools>=60.6",
-  "tabulate>=0.8",
-  "toolz>=0.11",
+  "setuptools>=65.5.0",
+  "tabulate>=0.8.10",
   "typing-extensions>=4.2",
   "xxhash>=1.4.4,<3.1.0"
 ]
-description = "Python library for generating high-performance implementations of stencil kernels for weather and climate modeling from a domain-specific language (DSL)"
-dynamic = ["version"]
+description = 'Python library for generating high-performance implementations of stencil kernels for weather and climate modeling from a domain-specific language (DSL)'
+dynamic = ['version']
 keywords = [
-  "gridtools",
-  "stencil",
-  "weather",
-  "climate",
-  "performance",
-  "portable",
-  "hpc"
+  'gridtools',
+  'stencil',
+  'weather',
+  'climate',
+  'performance',
+  'portable',
+  'hpc'
 ]
-license = {text = "GPL-3.0-or-later"}
-name = "gt4py"
-readme = "README.md"
-requires-python = ">=3.8"
+license = {text = 'GPL-3.0-or-later'}
+name = 'gt4py'
+readme = 'README.md'
+requires-python = '>=3.8'
 
 [project.optional-dependencies]
 cuda = ["cupy"]
@@ -78,9 +75,19 @@ cuda115 = ["cupy-cuda115"]
 cuda116 = ["cupy-cuda116"]
 cuda117 = ["cupy-cuda117"]
 cuda11x = ["cupy-cuda11x"]
-dace = ["dace>=0.14.2,<0.15", "sympy"]
+dace = ["dace>=0.14.2,<0.15", "sympy>=1.7"]
 formatting = ["clang-format>=9.0"]
-testing = ["hypothesis>=4.14", "pytest>=6.1"]
+# Always add all extra packages to 'full' for a simple full gt4py installation
+full = [
+  "clang-format>=9.0",
+  "dace>=0.14.2,<0.15",
+  "hypothesis>=6.0.0",
+  "pytest>=7.0",
+  "sympy>=1.7",
+  "scipy>=1.7.2"
+]
+performance = ["scipy>=1.7.2"]
+testing = ["hypothesis>=6.0.0", "pytest>=7.0"]
 
 [project.scripts]
 gtpyc = "gt4py.cartesian.cli:gtpyc"
@@ -109,8 +116,7 @@ exclude = '''
 '''
 include = '\.pyi?$'
 line-length = 100
-max-line-length = 100
-target_version = ["py310"]
+target-version = ['py310']
 
 # -- coverage --
 [tool.coverage]
@@ -234,99 +240,99 @@ warn_unused_ignores = true
 # GT4Py configs
 [[tool.mypy.overrides]]
 ignore_missing_imports = false
-module = "gt4py.*"
+module = 'gt4py.*'
 
 [[tool.mypy.overrides]]
 # The following ignore_errors are only temporary.
 # TODO: Fix errors and enable these settings.
 disallow_incomplete_defs = false
 disallow_untyped_defs = false
-follow_imports = "silent"
-module = "gt4py.cartesian.*"
+follow_imports = 'silent'
+module = 'gt4py.cartesian.*'
 warn_unused_ignores = false
 
 [[tool.mypy.overrides]]
 ignore_errors = true
-module = "gt4py.cartesian.backend.pyext_builder"
+module = 'gt4py.cartesian.backend.pyext_builder'
 
 [[tool.mypy.overrides]]
 ignore_errors = true
-module = "gt4py.cartesian.frontend.nodes"
+module = 'gt4py.cartesian.frontend.nodes'
 
 [[tool.mypy.overrides]]
 ignore_errors = true
-module = "gt4py.cartesian.frontend.node_util"
+module = 'gt4py.cartesian.frontend.node_util'
 
 [[tool.mypy.overrides]]
 ignore_errors = true
-module = "gt4py.cartesian.frontend.gtscript_frontend"
+module = 'gt4py.cartesian.frontend.gtscript_frontend'
 
 [[tool.mypy.overrides]]
 ignore_errors = true
-module = "gt4py.cartesian.frontend.defir_to_gtir"
+module = 'gt4py.cartesian.frontend.defir_to_gtir'
 
 [[tool.mypy.overrides]]
 ignore_errors = true
-module = "gt4py.cartesian.frontend.meta"
+module = 'gt4py.cartesian.frontend.meta'
 
 [[tool.mypy.overrides]]
 disallow_untyped_defs = true
-module = "gt4py.eve.*"
+module = 'gt4py.eve.*'
 
 [[tool.mypy.overrides]]
-module = "gt4py.eve.extended_typing"
+module = 'gt4py.eve.extended_typing'
 warn_unused_ignores = false
 
 [[tool.mypy.overrides]]
 # TODO: Make this false and fix errors
 allow_untyped_defs = true
-follow_imports = "silent"
-module = "gt4py.storage.*"
+follow_imports = 'silent'
+module = 'gt4py.storage.*'
 warn_unused_ignores = false
 
 [[tool.mypy.overrides]]
 # # TODO: this should be changed to true after a transition period
 disallow_incomplete_defs = false
-module = "gt4py.next.*"
+module = 'gt4py.next.*'
 
 [[tool.mypy.overrides]]
 ignore_errors = true
-module = "gt4py.next.ffront.decorator"
+module = 'gt4py.next.ffront.decorator'
 
 [[tool.mypy.overrides]]
 ignore_errors = true
-module = "gt4py.next.type_system.type_translation"
+module = 'gt4py.next.type_system.type_translation'
 
 [[tool.mypy.overrides]]
 ignore_errors = true
-module = "gt4py.next.iterator.runtime"
+module = 'gt4py.next.iterator.runtime'
 
 [[tool.mypy.overrides]]
 ignore_errors = true
-module = "gt4py.next.iterator.transforms.global_tmps"
+module = 'gt4py.next.iterator.transforms.global_tmps'
 
 # -- pytest --
 [tool.pytest]
 
 [tool.pytest.ini_options]
 markers = [
-  "requires_atlas",  # mark tests that require 'atlas4py' bindings package
-  "requires_dace",  # mark tests that require 'dace' package
-  "requires_gpu:"  # mark tests that require a NVidia GPU (assume 'cupy' and 'cudatoolkit' are installed)
+  'requires_atlas',  # mark tests that require 'atlas4py' bindings package
+  'requires_dace',  # mark tests that require 'dace' package
+  'requires_gpu:'  # mark tests that require a NVidia GPU (assume 'cupy' and 'cudatoolkit' are installed)
 ]
-norecursedirs = ["dist", "build", "cpp_backend_tests/build*", "_local/*", ".*"]
-testpaths = "tests"
+norecursedirs = ['dist', 'build', 'cpp_backend_tests/build*', '_local/*', '.*']
+testpaths = 'tests'
 
 # -- Setuptools-specific --
 [tool.setuptools]
-platforms = ["Linux", "Mac"]
+platforms = ['Linux', 'Mac']
 
 [tool.setuptools.dynamic]
-version = {attr = "gt4py.__about__.__version__"}
+version = {attr = 'gt4py.__about__.__version__'}
 
 [tool.setuptools.package-data]
-"*" = ["*.in", "*.txt"]
-"gt4py" = ["py.typed", "*.md", "*.rst"]
+'*' = ['*.in', '*.txt']
+'gt4py' = ['py.typed', '*.md', '*.rst']
 
 [tool.setuptools.packages]
-find = {namespaces = false, where = ["src"]}
+find = {namespaces = false, where = ['src']}
diff --git a/requirements-dev.in b/requirements-dev.in
index e6856231..53f2ed98 100644
--- a/requirements-dev.in
+++ b/requirements-dev.in
@@ -1,20 +1,28 @@
+##[[[cog
+## import re, tomli
+## project = tomli.loads(open("pyproject.toml").read())
+## versions = "\n".join(project["project"]["optional-dependencies"]["full"])
+## for pkg in ['hypothesis', 'pytest']:
+##    print(re.search(f"\n({pkg} *[=>~!].*)\n", versions)[1])
+##]]]
+hypothesis>=6.0.0
+pytest>=7.0
+##[[[end]]]
+
 clang-format>=9.0
-cmake>=3.18.1
 cogapp>=3.3
 coverage[toml]>=5.0
 darglint>=1.6
-devtools>=0.6
 factory-boy>=3.1
 flake8>=5.0.4
 flake8-bugbear>=20.11.1
 flake8-builtins>=1.5.3
 flake8-debugger>=4.0.0
 flake8-docstrings>=1.5.0
-flake8-eradicate>=1.0.0
+flake8-eradicate>=1.3.0
 flake8-mutable>=1.2.0
 flake8-pyproject>=1.2.2
 flake8-rst-docstrings>=0.0.14
-hypothesis>=4.14
 isort>=5.10
 jupytext>=1.14
 mypy>=1.0
@@ -23,14 +31,12 @@ pip-tools>=6.10
 pre-commit>=2.17
 psutil>=5.0
 pygments>=2.7
-pytest>=6.1
 pytest-cache>=1.0
 pytest-cov>=2.8
-pytest-factoryboy>=2.0
+pytest-factoryboy>=2.0.3
 pytest-xdist[psutil]>=2.4
-setuptools>=40.8.0
 sphinx>=4.4
 sphinx_rtd_theme>=1.0
 tomli>=2.0.1
-tox>=4.2
-types-all
+tox>=3.2.0
+types-all>=1.0.0
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 5ab0b95d..e777e16b 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -4,13 +4,13 @@
 #
 #    "tox run -e requirements-common"
 #
-aenum==3.1.11             # via dace
+aenum==3.1.12             # via dace
 alabaster==0.7.13         # via sphinx
 asttokens==2.2.1          # via devtools
 astunparse==1.6.3 ; python_version < "3.9"  # via dace, gt4py (pyproject.toml)
 attrs==22.2.0             # via flake8-bugbear, flake8-eradicate, gt4py (pyproject.toml), hypothesis, jsonschema, pytest
 babel==2.12.1             # via sphinx
-black==23.1.0             # via gt4py (pyproject.toml)
+black==23.3.0             # via gt4py (pyproject.toml)
 boltons==23.0.0           # via gt4py (pyproject.toml)
 build==0.10.0             # via pip-tools
 cached-property==1.5.2    # via gt4py (pyproject.toml)
@@ -20,46 +20,46 @@ cffi==1.15.1              # via cryptography
 cfgv==3.3.1               # via pre-commit
 chardet==5.1.0            # via tox
 charset-normalizer==3.1.0  # via requests
-clang-format==15.0.7      # via -r requirements-dev.in, gt4py (pyproject.toml)
+clang-format==16.0.0      # via -r requirements-dev.in, gt4py (pyproject.toml)
 click==8.1.3              # via black, flask, gt4py (pyproject.toml), pip-tools
-cmake==3.25.2             # via -r requirements-dev.in, gt4py (pyproject.toml)
+cmake==3.26.1             # via gt4py (pyproject.toml)
 cogapp==3.3.0             # via -r requirements-dev.in
 colorama==0.4.6           # via tox
-coverage[toml]==7.2.1     # via -r requirements-dev.in, pytest-cov
-cryptography==39.0.2      # via types-paramiko, types-pyopenssl, types-redis
+coverage[toml]==7.2.2     # via -r requirements-dev.in, pytest-cov
+cryptography==40.0.1      # via types-paramiko, types-pyopenssl, types-redis
 cytoolz==0.12.1           # via gt4py (pyproject.toml)
 dace==0.14.2              # via gt4py (pyproject.toml)
 darglint==1.8.1           # via -r requirements-dev.in
-deepdiff==6.2.3           # via gt4py (pyproject.toml)
-devtools==0.10.0          # via -r requirements-dev.in, gt4py (pyproject.toml)
+deepdiff==6.3.0           # via gt4py (pyproject.toml)
+devtools==0.10.0          # via gt4py (pyproject.toml)
 dill==0.3.6               # via dace
 distlib==0.3.6            # via virtualenv
 docutils==0.18.1          # via restructuredtext-lint, sphinx, sphinx-rtd-theme
 eradicate==2.2.0          # via flake8-eradicate
-exceptiongroup==1.1.0     # via hypothesis, pytest
+exceptiongroup==1.1.1     # via hypothesis, pytest
 execnet==1.9.0            # via pytest-cache, pytest-xdist
 executing==1.2.0          # via devtools
 factory-boy==3.2.1        # via -r requirements-dev.in, pytest-factoryboy
-faker==17.6.0             # via factory-boy
+faker==18.3.4             # via factory-boy
 fastjsonschema==2.16.3    # via nbformat
-filelock==3.9.0           # via tox, virtualenv
+filelock==3.10.7          # via tox, virtualenv
 flake8==5.0.4             # via -r requirements-dev.in, flake8-bugbear, flake8-builtins, flake8-debugger, flake8-docstrings, flake8-eradicate, flake8-mutable, flake8-pyproject, flake8-rst-docstrings
-flake8-bugbear==23.2.13   # via -r requirements-dev.in
+flake8-bugbear==23.3.12   # via -r requirements-dev.in
 flake8-builtins==2.1.0    # via -r requirements-dev.in
 flake8-debugger==4.1.2    # via -r requirements-dev.in
 flake8-docstrings==1.7.0  # via -r requirements-dev.in
 flake8-eradicate==1.4.0   # via -r requirements-dev.in
 flake8-mutable==1.2.0     # via -r requirements-dev.in
-flake8-pyproject==1.2.2   # via -r requirements-dev.in
+flake8-pyproject==1.2.3   # via -r requirements-dev.in
 flake8-rst-docstrings==0.3.0  # via -r requirements-dev.in
 flask==2.2.3              # via dace
-frozendict==2.3.5         # via gt4py (pyproject.toml)
+frozendict==2.3.6         # via gt4py (pyproject.toml)
 gridtools-cpp==2.2.3      # via gt4py (pyproject.toml)
-hypothesis==6.68.2        # via -r requirements-dev.in, gt4py (pyproject.toml)
-identify==2.5.18          # via pre-commit
+hypothesis==6.70.2        # via -r requirements-dev.in, gt4py (pyproject.toml)
+identify==2.5.22          # via pre-commit
 idna==3.4                 # via requests
 imagesize==1.4.1          # via sphinx
-importlib-metadata==6.0.0  # via flask, sphinx
+importlib-metadata==6.1.0  # via flask, sphinx
 importlib-resources==5.12.0 ; python_version < "3.9"  # via gt4py (pyproject.toml), jsonschema
 inflection==0.5.1         # via pytest-factoryboy
 iniconfig==2.0.0          # via pytest
@@ -67,7 +67,7 @@ isort==5.12.0             # via -r requirements-dev.in
 itsdangerous==2.1.2       # via flask
 jinja2==3.1.2             # via flask, gt4py (pyproject.toml), sphinx
 jsonschema==4.17.3        # via nbformat
-jupyter-core==5.2.0       # via nbformat
+jupyter-core==5.3.0       # via nbformat
 jupytext==1.14.5          # via -r requirements-dev.in
 lark==1.1.5               # via gt4py (pyproject.toml)
 mako==1.2.4               # via gt4py (pyproject.toml)
@@ -76,55 +76,53 @@ markupsafe==2.1.2         # via jinja2, mako, werkzeug
 mccabe==0.7.0             # via flake8
 mdit-py-plugins==0.3.5    # via jupytext
 mdurl==0.1.2              # via markdown-it-py
-mpmath==1.2.1             # via sympy
-mypy==1.0.1               # via -r requirements-dev.in
+mpmath==1.3.0             # via sympy
+mypy==1.1.1               # via -r requirements-dev.in
 mypy-extensions==1.0.0    # via black, mypy
-nbformat==5.7.3           # via jupytext
-networkx==3.0             # via dace, gt4py (pyproject.toml)
+nbformat==5.8.0           # via jupytext
+networkx==3.1             # via dace
 ninja==1.11.1             # via gt4py (pyproject.toml)
 nodeenv==1.7.0            # via pre-commit
-numpy==1.24.2             # via dace, gt4py (pyproject.toml), scipy, types-jack-client
+numpy==1.24.2             # via dace, gt4py (pyproject.toml), types-jack-client
 ordered-set==4.1.0        # via deepdiff
-orjson==3.8.7             # via deepdiff
 packaging==23.0           # via black, build, gt4py (pyproject.toml), pyproject-api, pytest, sphinx, tox
-pathspec==0.11.0          # via black
+pathspec==0.11.1          # via black
 pip-tools==6.12.3         # via -r requirements-dev.in
-pipdeptree==2.5.2         # via -r requirements-dev.in
+pipdeptree==2.7.0         # via -r requirements-dev.in
 pkgutil-resolve-name==1.3.10  # via jsonschema
-platformdirs==3.1.0       # via black, jupyter-core, tox, virtualenv
+platformdirs==3.2.0       # via black, jupyter-core, tox, virtualenv
 pluggy==1.0.0             # via pytest, tox
 ply==3.11                 # via dace
-pre-commit==3.1.1         # via -r requirements-dev.in
+pre-commit==3.2.2         # via -r requirements-dev.in
 psutil==5.9.4             # via -r requirements-dev.in, pytest-xdist
-pybind11==2.10.3          # via gt4py (pyproject.toml)
+pybind11==2.10.4          # via gt4py (pyproject.toml)
 pycodestyle==2.9.1        # via flake8, flake8-debugger
 pycparser==2.21           # via cffi
 pydocstyle==6.3.0         # via flake8-docstrings
 pyflakes==2.5.0           # via flake8
 pygments==2.14.0          # via -r requirements-dev.in, flake8-rst-docstrings, sphinx
-pyproject-api==1.5.0      # via tox
+pyproject-api==1.5.1      # via tox
 pyproject-hooks==1.0.0    # via build
 pyrsistent==0.19.3        # via jsonschema
 pytest==7.2.2             # via -r requirements-dev.in, gt4py (pyproject.toml), pytest-cache, pytest-cov, pytest-factoryboy, pytest-xdist
 pytest-cache==1.0         # via -r requirements-dev.in
 pytest-cov==4.0.0         # via -r requirements-dev.in
 pytest-factoryboy==2.5.1  # via -r requirements-dev.in
-pytest-xdist[psutil]==3.2.0  # via -r requirements-dev.in
+pytest-xdist[psutil]==3.2.1  # via -r requirements-dev.in
 python-dateutil==2.8.2    # via faker
-pytz==2022.7.1            # via babel
+pytz==2023.3              # via babel
 pyyaml==6.0               # via dace, jupytext, pre-commit
 requests==2.28.2          # via dace, sphinx
 restructuredtext-lint==1.4.0  # via flake8-rst-docstrings
-scipy==1.10.1             # via gt4py (pyproject.toml)
 six==1.16.0               # via asttokens, astunparse, python-dateutil
 snowballstemmer==2.2.0    # via pydocstyle, sphinx
 sortedcontainers==2.4.0   # via hypothesis
-sphinx==6.1.3             # via -r requirements-dev.in, sphinx-rtd-theme
+sphinx==6.1.3             # via -r requirements-dev.in, sphinx-rtd-theme, sphinxcontrib-jquery
 sphinx-rtd-theme==1.2.0   # via -r requirements-dev.in
 sphinxcontrib-applehelp==1.0.4  # via sphinx
 sphinxcontrib-devhelp==1.0.2  # via sphinx
 sphinxcontrib-htmlhelp==2.0.1  # via sphinx
-sphinxcontrib-jquery==2.0.0  # via sphinx-rtd-theme
+sphinxcontrib-jquery==4.1  # via sphinx-rtd-theme
 sphinxcontrib-jsmath==1.0.1  # via sphinx
 sphinxcontrib-qthelp==1.0.3  # via sphinx
 sphinxcontrib-serializinghtml==1.1.5  # via sphinx
@@ -132,36 +130,36 @@ sympy==1.9                # via dace, gt4py (pyproject.toml)
 tabulate==0.9.0           # via gt4py (pyproject.toml)
 toml==0.10.2              # via jupytext
 tomli==2.0.1              # via -r requirements-dev.in, black, build, coverage, flake8-pyproject, mypy, pyproject-api, pyproject-hooks, pytest, tox
-toolz==0.12.0             # via cytoolz, gt4py (pyproject.toml)
-tox==4.4.6                # via -r requirements-dev.in
+toolz==0.12.0             # via cytoolz
+tox==4.4.8                # via -r requirements-dev.in
 traitlets==5.9.0          # via jupyter-core, nbformat
-types-aiofiles==22.1.0.9  # via types-all
+types-aiofiles==23.1.0.1  # via types-all
 types-all==1.0.0          # via -r requirements-dev.in
-types-annoy==1.17.8.2     # via types-all
+types-annoy==1.17.8.3     # via types-all
 types-atomicwrites==1.4.5.1  # via types-all
 types-backports==0.1.3    # via types-all
 types-backports-abc==0.5.2  # via types-all
-types-bleach==6.0.0.1     # via types-all
-types-boto==2.49.18.6     # via types-all
-types-cachetools==5.3.0.4  # via types-all
+types-bleach==6.0.0.2     # via types-all
+types-boto==2.49.18.7     # via types-all
+types-cachetools==5.3.0.5  # via types-all
 types-certifi==2021.10.8.3  # via types-all
-types-cffi==1.15.1.7      # via types-jack-client
+types-cffi==1.15.1.10     # via types-jack-client
 types-characteristic==14.3.7  # via types-all
-types-chardet==5.0.4.2    # via types-all
+types-chardet==5.0.4.3    # via types-all
 types-click==7.1.8        # via types-all, types-flask
-types-click-spinner==0.1.13.3  # via types-all
-types-colorama==0.4.15.8  # via types-all
-types-contextvars==2.4.7.1  # via types-all
-types-croniter==1.3.2.6   # via types-all
+types-click-spinner==0.1.13.4  # via types-all
+types-colorama==0.4.15.11  # via types-all
+types-contextvars==2.4.7.2  # via types-all
+types-croniter==1.3.2.7   # via types-all
 types-cryptography==3.3.23.2  # via types-all, types-openssl-python, types-pyjwt
 types-dataclasses==0.6.6  # via types-all
-types-dateparser==1.1.4.8  # via types-all
-types-datetimerange==2.0.0.3  # via types-all
+types-dateparser==1.1.4.9  # via types-all
+types-datetimerange==2.0.0.5  # via types-all
 types-decorator==5.1.8.3  # via types-all
-types-deprecated==1.2.9.1  # via types-all
+types-deprecated==1.2.9.2  # via types-all
 types-docopt==0.6.11.3    # via types-all
-types-docutils==0.19.1.6  # via types-all
-types-emoji==2.1.0.2      # via types-all
+types-docutils==0.19.1.7  # via types-all
+types-emoji==2.1.0.3      # via types-all
 types-enum34==1.1.8       # via types-all
 types-fb303==1.0.0        # via types-all, types-scribe
 types-filelock==3.2.7     # via types-all
@@ -173,66 +171,66 @@ types-futures==3.3.8      # via types-all
 types-geoip2==3.0.0       # via types-all
 types-ipaddress==1.0.8    # via types-all, types-maxminddb
 types-itsdangerous==1.1.6  # via types-all
-types-jack-client==0.5.10.7  # via types-all
+types-jack-client==0.5.10.8  # via types-all
 types-jinja2==2.11.9      # via types-all, types-flask
 types-kazoo==0.1.3        # via types-all
-types-markdown==3.4.2.5   # via types-all
+types-markdown==3.4.2.7   # via types-all
 types-markupsafe==1.1.10  # via types-all, types-jinja2
 types-maxminddb==1.5.0    # via types-all, types-geoip2
-types-mock==5.0.0.5       # via types-all
+types-mock==5.0.0.6       # via types-all
 types-mypy-extensions==1.0.0.3  # via types-all
 types-nmap==0.1.6         # via types-all
 types-openssl-python==0.1.3  # via types-all
 types-orjson==3.6.2       # via types-all
-types-paramiko==3.0.0.4   # via types-all, types-pysftp
+types-paramiko==3.0.0.7   # via types-all, types-pysftp
 types-pathlib2==2.3.0     # via types-all
-types-pillow==9.4.0.17    # via types-all
+types-pillow==9.4.0.19    # via types-all
 types-pkg-resources==0.1.3  # via types-all
 types-polib==1.2.0.0      # via types-all
-types-protobuf==4.22.0.0  # via types-all
+types-protobuf==4.22.0.2  # via types-all
 types-pyaudio==0.2.16.6   # via types-all
-types-pycurl==7.45.2.3    # via types-all
+types-pycurl==7.45.2.4    # via types-all
 types-pyfarmhash==0.3.1.1  # via types-all
 types-pyjwt==1.7.1        # via types-all
 types-pymssql==2.1.0      # via types-all
-types-pymysql==1.0.19.5   # via types-all
-types-pyopenssl==23.0.0.4  # via types-redis
-types-pyrfc3339==1.1.1.3  # via types-all
-types-pysftp==0.2.17.2    # via types-all
-types-python-dateutil==2.8.19.10  # via types-all, types-datetimerange
+types-pymysql==1.0.19.6   # via types-all
+types-pyopenssl==23.1.0.1  # via types-redis
+types-pyrfc3339==1.1.1.4  # via types-all
+types-pysftp==0.2.17.5    # via types-all
+types-python-dateutil==2.8.19.12  # via types-all, types-datetimerange
 types-python-gflags==3.1.7.2  # via types-all
-types-python-slugify==8.0.0.1  # via types-all
-types-pytz==2022.7.1.2    # via types-all, types-tzlocal
+types-python-slugify==8.0.0.2  # via types-all
+types-pytz==2023.3.0.0    # via types-all, types-tzlocal
 types-pyvmomi==8.0.0.1    # via types-all
-types-pyyaml==6.0.12.8    # via types-all
-types-redis==4.5.1.4      # via types-all
-types-requests==2.28.11.15  # via types-all
-types-retry==0.9.9.2      # via types-all
+types-pyyaml==6.0.12.9    # via types-all
+types-redis==4.5.4.1      # via types-all
+types-requests==2.28.11.17  # via types-all
+types-retry==0.9.9.3      # via types-all
 types-routes==2.5.0       # via types-all
 types-scribe==2.0.0       # via types-all
-types-simplejson==3.18.0.1  # via types-all
+types-simplejson==3.18.0.3  # via types-all
 types-singledispatch==4.0.0.1  # via types-all
-types-six==1.16.21.6      # via types-all
-types-tabulate==0.9.0.1   # via types-all
-types-termcolor==1.1.6.1  # via types-all
-types-toml==0.10.8.5      # via types-all
+types-six==1.16.21.8      # via types-all
+types-tabulate==0.9.0.2   # via types-all
+types-termcolor==1.1.6.2  # via types-all
+types-toml==0.10.8.6      # via types-all
 types-tornado==5.1.1      # via types-all
-types-typed-ast==1.5.8.4  # via types-all
-types-tzlocal==4.2.2.3    # via types-all
+types-typed-ast==1.5.8.6  # via types-all
+types-tzlocal==4.3.0.0    # via types-all
 types-ujson==5.7.0.1      # via types-all
-types-urllib3==1.26.25.8  # via types-requests
-types-waitress==2.1.4.5   # via types-all
+types-urllib3==1.26.25.10  # via types-requests
+types-waitress==2.1.4.7   # via types-all
 types-werkzeug==1.0.9     # via types-all, types-flask
 types-xxhash==3.0.5.2     # via types-all
 typing-extensions==4.5.0  # via black, gt4py (pyproject.toml), mypy, pytest-factoryboy
-urllib3==1.26.14          # via requests
-virtualenv==20.20.0       # via pre-commit, tox
-websockets==10.4          # via dace
+urllib3==1.26.15          # via requests
+virtualenv==20.21.0       # via pre-commit, tox
+websockets==11.0          # via dace
 werkzeug==2.2.3           # via flask
-wheel==0.38.4             # via astunparse, pip-tools
+wheel==0.40.0             # via astunparse, pip-tools
 xxhash==3.0.0             # via gt4py (pyproject.toml)
 zipp==3.15.0              # via importlib-metadata, importlib-resources
 
 # The following packages are considered to be unsafe in a requirements file:
 pip==23.0.1               # via pip-tools
-setuptools==67.5.1        # via -r requirements-dev.in, gt4py (pyproject.toml), nodeenv, pip-tools, sphinxcontrib-jquery
+setuptools==67.6.1        # via gt4py (pyproject.toml), nodeenv, pip-tools
diff --git a/src/gt4py/cartesian/gtc/common.py b/src/gt4py/cartesian/gtc/common.py
index 265c229b..c2a16418 100644
--- a/src/gt4py/cartesian/gtc/common.py
+++ b/src/gt4py/cartesian/gtc/common.py
@@ -17,12 +17,25 @@ from __future__ import annotations
 import enum
 import functools
 import typing
-from typing import Any, ClassVar, Dict, Generic, List, Optional, Tuple, Type, TypeVar, Union
+from typing import (
+    Any,
+    ClassVar,
+    Dict,
+    Final,
+    Generic,
+    List,
+    Mapping,
+    Optional,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+)
 
 import numpy as np
-import scipy.special
 
 from gt4py import eve
+from gt4py.cartesian.gtc import ufuncs
 from gt4py.cartesian.gtc.utils import dimension_flags_to_names, flatten_list
 from gt4py.eve import datamodels
 
@@ -808,82 +821,97 @@ def data_type_to_typestr(dtype: DataType) -> str:
     return np.dtype(table[dtype]).str
 
 
-@functools.lru_cache(maxsize=None, typed=True)  # typed since uniqueness is only guaranteed per enum
+# This is a mapping of mappings instead of a flat mapping because
+# different operators use the same key: UnaryOperator.POS == BinaryOperator.ADD
+OP_TO_UFUNC_NAME: Final[
+    Mapping[
+        Union[
+            Type[UnaryOperator],
+            Type[ArithmeticOperator],
+            Type[ComparisonOperator],
+            Type[LogicalOperator],
+            Type[NativeFunction],
+        ],
+        Mapping[
+            Union[
+                UnaryOperator,
+                ArithmeticOperator,
+                ComparisonOperator,
+                LogicalOperator,
+                NativeFunction,
+            ],
+            str,
+        ],
+    ]
+] = {
+    UnaryOperator: {
+        UnaryOperator.POS: "positive",
+        UnaryOperator.NEG: "negative",
+        UnaryOperator.NOT: "logical_not",
+    },
+    ArithmeticOperator: {
+        ArithmeticOperator.ADD: "add",
+        ArithmeticOperator.SUB: "subtract",
+        ArithmeticOperator.MUL: "multiply",
+        ArithmeticOperator.DIV: "true_divide",
+    },
+    ComparisonOperator: {
+        ComparisonOperator.GT: "greater",
+        ComparisonOperator.LT: "less",
+        ComparisonOperator.GE: "greater_equal",
+        ComparisonOperator.LE: "less_equal",
+        ComparisonOperator.EQ: "equal",
+        ComparisonOperator.NE: "not_equal",
+    },
+    LogicalOperator: {
+        LogicalOperator.AND: "logical_and",
+        LogicalOperator.OR: "logical_or",
+    },
+    NativeFunction: {
+        NativeFunction.ABS: "abs",
+        NativeFunction.MIN: "minimum",
+        NativeFunction.MAX: "maximum",
+        NativeFunction.MOD: "remainder",
+        NativeFunction.SIN: "sin",
+        NativeFunction.COS: "cos",
+        NativeFunction.TAN: "tan",
+        NativeFunction.ARCSIN: "arcsin",
+        NativeFunction.ARCCOS: "arccos",
+        NativeFunction.ARCTAN: "arctan",
+        NativeFunction.SINH: "sinh",
+        NativeFunction.COSH: "cosh",
+        NativeFunction.TANH: "tanh",
+        NativeFunction.ARCSINH: "arcsinh",
+        NativeFunction.ARCCOSH: "arccosh",
+        NativeFunction.ARCTANH: "arctanh",
+        NativeFunction.SQRT: "sqrt",
+        NativeFunction.POW: "power",
+        NativeFunction.EXP: "exp",
+        NativeFunction.LOG: "log",
+        NativeFunction.GAMMA: "gamma",
+        NativeFunction.CBRT: "cbrt",
+        NativeFunction.ISFINITE: "isfinite",
+        NativeFunction.ISINF: "isinf",
+        NativeFunction.ISNAN: "isnan",
+        NativeFunction.FLOOR: "floor",
+        NativeFunction.CEIL: "ceil",
+        NativeFunction.TRUNC: "trunc",
+    },
+}
+
+
 def op_to_ufunc(
     op: Union[
         UnaryOperator, ArithmeticOperator, ComparisonOperator, LogicalOperator, NativeFunction
     ]
 ) -> np.ufunc:
-    table: Dict[
-        Union[
-            UnaryOperator, ArithmeticOperator, ComparisonOperator, LogicalOperator, NativeFunction
-        ],
-        np.ufunc,
-    ]
-    # Can't put all in single table since UnaryOperator.POS == BinaryOperator.ADD
-    if isinstance(op, UnaryOperator):
-        table = {
-            UnaryOperator.POS: np.positive,
-            UnaryOperator.NEG: np.negative,
-            UnaryOperator.NOT: np.logical_not,
-        }
-    elif isinstance(op, ArithmeticOperator):
-        table = {
-            ArithmeticOperator.ADD: np.add,
-            ArithmeticOperator.SUB: np.subtract,
-            ArithmeticOperator.MUL: np.multiply,
-            ArithmeticOperator.DIV: np.true_divide,
-        }
-    elif isinstance(op, ComparisonOperator):
-        table = {
-            ComparisonOperator.GT: np.greater,
-            ComparisonOperator.LT: np.less,
-            ComparisonOperator.GE: np.greater_equal,
-            ComparisonOperator.LE: np.less_equal,
-            ComparisonOperator.EQ: np.equal,
-            ComparisonOperator.NE: np.not_equal,
-        }
-    elif isinstance(op, LogicalOperator):
-        table = {
-            LogicalOperator.AND: np.logical_and,
-            LogicalOperator.OR: np.logical_or,
-        }
-    elif isinstance(op, NativeFunction):
-        table = {
-            NativeFunction.ABS: np.abs,
-            NativeFunction.MIN: np.minimum,
-            NativeFunction.MAX: np.maximum,
-            NativeFunction.MOD: np.remainder,
-            NativeFunction.SIN: np.sin,
-            NativeFunction.COS: np.cos,
-            NativeFunction.TAN: np.tan,
-            NativeFunction.ARCSIN: np.arcsin,
-            NativeFunction.ARCCOS: np.arccos,
-            NativeFunction.ARCTAN: np.arctan,
-            NativeFunction.SINH: np.sinh,
-            NativeFunction.COSH: np.cosh,
-            NativeFunction.TANH: np.tanh,
-            NativeFunction.ARCSINH: np.arcsinh,
-            NativeFunction.ARCCOSH: np.arccosh,
-            NativeFunction.ARCTANH: np.arctanh,
-            NativeFunction.SQRT: np.sqrt,
-            NativeFunction.POW: np.power,
-            NativeFunction.EXP: np.exp,
-            NativeFunction.LOG: np.log,
-            NativeFunction.GAMMA: scipy.special.gamma,
-            NativeFunction.CBRT: np.cbrt,
-            NativeFunction.ISFINITE: np.isfinite,
-            NativeFunction.ISINF: np.isinf,
-            NativeFunction.ISNAN: np.isnan,
-            NativeFunction.FLOOR: np.floor,
-            NativeFunction.CEIL: np.ceil,
-            NativeFunction.TRUNC: np.trunc,
-        }
-    else:
+    if not isinstance(
+        op, (UnaryOperator, ArithmeticOperator, ComparisonOperator, LogicalOperator, NativeFunction)
+    ):
         raise TypeError(
             "Can only convert instances of GTC operators and supported native functions to typestr."
         )
-    return table[op]
+    return getattr(ufuncs, OP_TO_UFUNC_NAME[type(op)][op])
 
 
 @functools.lru_cache(maxsize=None)
diff --git a/src/gt4py/cartesian/gtc/numpy/npir_codegen.py b/src/gt4py/cartesian/gtc/numpy/npir_codegen.py
index a22f56bd..cf44ed41 100644
--- a/src/gt4py/cartesian/gtc/numpy/npir_codegen.py
+++ b/src/gt4py/cartesian/gtc/numpy/npir_codegen.py
@@ -244,17 +244,7 @@ class NpirCodegen(codegen.TemplatedGenerator, eve.VisitorWithSymbolTableTrait):
     def visit_NativeFunction(
         self, node: common.NativeFunction, **kwargs: Any
     ) -> Union[str, Collection[str]]:
-        if node == common.NativeFunction.MIN:
-            return "np.minimum"
-        elif node == common.NativeFunction.MAX:
-            return "np.maximum"
-        elif node == common.NativeFunction.POW:
-            return "np.power"
-        elif node == common.NativeFunction.GAMMA:
-            return "scipy.special.gamma"
-        name = self.generic_visit(node, **kwargs)
-        assert isinstance(name, str)
-        return f"np.{name}"
+        return f"ufuncs.{common.OP_TO_UFUNC_NAME[common.NativeFunction][node]}"
 
     def visit_NativeFuncCall(
         self, node: npir.NativeFuncCall, *, mask: Optional[str] = None, **kwargs: Any
@@ -398,7 +388,7 @@ class NpirCodegen(codegen.TemplatedGenerator, eve.VisitorWithSymbolTableTrait):
             from typing import Tuple
 
             import numpy as np
-            import scipy.special
+            from gt4py.cartesian.gtc import ufuncs
 
             {{ data_view_class }}
 
diff --git a/src/gt4py/cartesian/gtc/ufuncs.py b/src/gt4py/cartesian/gtc/ufuncs.py
new file mode 100644
index 00000000..980abe6b
--- /dev/null
+++ b/src/gt4py/cartesian/gtc/ufuncs.py
@@ -0,0 +1,70 @@
+# 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
+
+import numpy as np
+
+
+try:
+    from scipy.special import gamma as gamma_
+except ImportError:
+    import math
+
+    # If scipy is not available, emulate gamma function using math.gamma
+    gamma_ = np.vectorize(math.gamma)
+    gamma_.types = ["f->f", "d->d", "F->F", "D->D"]
+
+
+positive: np.ufunc = np.positive
+negative: np.ufunc = np.negative
+logical_not: np.ufunc = np.logical_not
+add: np.ufunc = np.add
+subtract: np.ufunc = np.subtract
+multiply: np.ufunc = np.multiply
+true_divide: np.ufunc = np.true_divide
+greater: np.ufunc = np.greater
+less: np.ufunc = np.less
+greater_equal: np.ufunc = np.greater_equal
+less_equal: np.ufunc = np.less_equal
+equal: np.ufunc = np.equal
+not_equal: np.ufunc = np.not_equal
+logical_and: np.ufunc = np.logical_and
+logical_or: np.ufunc = np.logical_or
+abs: np.ufunc = np.abs  # noqa: A001  # shadowing abs builtin
+minimum: np.ufunc = np.minimum
+maximum: np.ufunc = np.maximum
+remainder: np.ufunc = np.remainder
+sin: np.ufunc = np.sin
+cos: np.ufunc = np.cos
+tan: np.ufunc = np.tan
+arcsin: np.ufunc = np.arcsin
+arccos: np.ufunc = np.arccos
+arctan: np.ufunc = np.arctan
+sinh: np.ufunc = np.sinh
+cosh: np.ufunc = np.cosh
+tanh: np.ufunc = np.tanh
+arcsinh: np.ufunc = np.arcsinh
+arccosh: np.ufunc = np.arccosh
+arctanh: np.ufunc = np.arctanh
+sqrt: np.ufunc = np.sqrt
+power: np.ufunc = np.power
+exp: np.ufunc = np.exp
+log: np.ufunc = np.log
+gamma: np.ufunc = gamma_
+cbrt: np.ufunc = np.cbrt
+isfinite: np.ufunc = np.isfinite
+isinf: np.ufunc = np.isinf
+isnan: np.ufunc = np.isnan
+floor: np.ufunc = np.floor
+ceil: np.ufunc = np.ceil
+trunc: np.ufunc = np.trunc
diff --git a/src/gt4py/eve/concepts.py b/src/gt4py/eve/concepts.py
index 36361d79..d66e8583 100644
--- a/src/gt4py/eve/concepts.py
+++ b/src/gt4py/eve/concepts.py
@@ -197,9 +197,12 @@ class Node(datamodels.DataModel, trees.Tree, kw_only=True):  # type: ignore[call
     comparisons of the node, since it is not really a field. Thus, visitors
     and pipeline passes can freely attach computed attributes into the instance
     `annex`. Note that `annex` attribute is not implicitly copied in the
-    :class:`NodeTranslator`. If you want it to persist accross multiple
-    :class:`NodeTranslation` use a `root_validator` to dynamically (re)compute
-    the annex on node construction (see e.g. :class:`SymbolTableTrait`).
+    :class:`NodeTranslator`. If you want it to persist across multiple
+    :class:`NodeTranslator`s either use a `root_validator` to dynamically
+    (re)compute the annex on node construction (see e.g.
+    :class:`SymbolTableTrait`) or add the parts of the annex that should be
+    preserved to the `PRESERVED_ANNEX_ATTRS` attribute in the
+    :class:`NodeTranslator` class..
     """
 
     __slots__ = ()
diff --git a/src/gt4py/eve/visitors.py b/src/gt4py/eve/visitors.py
index 34660fa2..fe5f9e14 100644
--- a/src/gt4py/eve/visitors.py
+++ b/src/gt4py/eve/visitors.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 
 import collections.abc
 import copy
+from typing import ClassVar
 
 from . import concepts, trees
 from .extended_typing import Any
@@ -151,6 +152,8 @@ class NodeTranslator(NodeVisitor):
 
     """
 
+    PRESERVED_ANNEX_ATTRS: ClassVar[tuple[str, ...]] = ()
+
     def generic_visit(self, node: concepts.RootNode, **kwargs: Any) -> Any:
         memo = kwargs.get("__memo__", None)
 
@@ -162,6 +165,14 @@ class NodeTranslator(NodeVisitor):
                     if (new_child := self.visit(child, **kwargs)) is not NOTHING
                 },
             )
+            if self.PRESERVED_ANNEX_ATTRS and (old_annex := getattr(node, "__node_annex__", None)):
+                # access to `new_node.annex` implicitly creates the `__node_annex__` attribute in the property getter
+                new_annex_dict = new_node.annex.__dict__
+                for key in self.PRESERVED_ANNEX_ATTRS:
+                    if value := getattr(old_annex, key, None):
+                        assert key not in new_annex_dict
+                        new_annex_dict[key] = value
+
             return new_node
 
         if isinstance(node, (list, tuple, set, collections.abc.Set)) or (
diff --git a/src/gt4py/next/ffront/foast_to_itir.py b/src/gt4py/next/ffront/foast_to_itir.py
index 8062e26f..7c01bc62 100644
--- a/src/gt4py/next/ffront/foast_to_itir.py
+++ b/src/gt4py/next/ffront/foast_to_itir.py
@@ -15,7 +15,6 @@
 from typing import Any, Callable
 
 from gt4py.eve import NodeTranslator
-from gt4py.next.common import DimensionKind
 from gt4py.next.ffront import (
     dialect_ast_enums,
     fbuiltins,
@@ -28,21 +27,10 @@ from gt4py.next.iterator import ir as itir
 from gt4py.next.type_system import type_info, type_specifications as ts
 
 
-def is_local_kind(symbol_type: ts.FieldType) -> bool:
-    return any(dim.kind == DimensionKind.LOCAL for dim in symbol_type.dims)
-
-
-def is_local_type_kind(type_: ts.TypeSpec) -> bool:
-    return any(
-        isinstance(t, ts.FieldType) and is_local_kind(t)
-        for t in type_info.primitive_constituents(type_)
-    )
-
-
 def promote_to_list(
     node: foast.Symbol | foast.Expr,
 ) -> Callable[[itir.Expr], itir.Expr]:
-    if not is_local_type_kind(node.type):
+    if not type_info.contains_local_field(node.type):
         return lambda x: im.promote_to_lifted_stencil("make_const_list")(x)
     return lambda x: x
 
@@ -71,7 +59,7 @@ class FieldOperatorLowering(NodeTranslator):
     >>> lowered.id
     SymbolName('fieldop')
     >>> lowered.params
-    [Sym(id=SymbolName('inp'))]
+    [Sym(id=SymbolName('inp'), kind='Iterator', dtype=('float64', False))]
     """
 
     @classmethod
@@ -153,6 +141,12 @@ class FieldOperatorLowering(NodeTranslator):
         )
 
     def visit_Symbol(self, node: foast.Symbol, **kwargs) -> itir.Sym:
+        # TODO(tehrengruber): extend to more types
+        if isinstance(node.type, ts.FieldType):
+            kind = "Iterator"
+            dtype = node.type.dtype.kind.name.lower()
+            is_list = type_info.is_local_field(node.type)
+            return itir.Sym(id=node.id, kind=kind, dtype=(dtype, is_list))
         return im.sym(node.id)
 
     def visit_Name(self, node: foast.Name, **kwargs) -> itir.SymRef:
@@ -319,7 +313,7 @@ class FieldOperatorLowering(NodeTranslator):
 
     def _map(self, op, *args, **kwargs):
         lowered_args = [self.visit(arg, **kwargs) for arg in args]
-        if any(is_local_type_kind(arg.type) for arg in args):
+        if any(type_info.contains_local_field(arg.type) for arg in args):
             lowered_args = [promote_to_list(arg)(larg) for arg, larg in zip(args, lowered_args)]
             op = im.call_("map_")(op)
 
diff --git a/src/gt4py/next/ffront/itir_makers.py b/src/gt4py/next/ffront/itir_makers.py
index e655830f..f9f8cbd2 100644
--- a/src/gt4py/next/ffront/itir_makers.py
+++ b/src/gt4py/next/ffront/itir_makers.py
@@ -24,10 +24,10 @@ def sym(sym_or_name: Union[str, itir.Sym]) -> itir.Sym:
     Examples
     --------
     >>> sym("a")
-    Sym(id=SymbolName('a'))
+    Sym(id=SymbolName('a'), kind=None, dtype=None)
 
     >>> sym(itir.Sym(id="b"))
-    Sym(id=SymbolName('b'))
+    Sym(id=SymbolName('b'), kind=None, dtype=None)
     """
     if isinstance(sym_or_name, itir.Sym):
         return sym_or_name
@@ -99,7 +99,7 @@ class lambda__:
     Examples
     --------
     >>> lambda__("a")(deref_("a"))  # doctest: +ELLIPSIS
-    Lambda(params=[Sym(id=SymbolName('a'))], expr=FunCall(fun=SymRef(id=SymbolRef('deref')), args=[SymRef(id=SymbolRef('a'))]))
+    Lambda(params=[Sym(id=SymbolName('a'), kind=None, dtype=None)], expr=FunCall(fun=SymRef(id=SymbolRef('deref')), args=[SymRef(id=SymbolRef('a'))]))
     """
 
     def __init__(self, *args):
@@ -240,7 +240,7 @@ class let:
     Examples
     --------
     >>> let("a", "b")("a")  # doctest: +ELLIPSIS
-    FunCall(fun=Lambda(params=[Sym(id=SymbolName('a'))], expr=SymRef(id=SymbolRef('a'))), args=[SymRef(id=SymbolRef('b'))])
+    FunCall(fun=Lambda(params=[Sym(id=SymbolName('a'), kind=None, dtype=None)], expr=SymRef(id=SymbolRef('a'))), args=[SymRef(id=SymbolRef('b'))])
     """
 
     def __init__(self, var, init_form):
diff --git a/src/gt4py/next/ffront/past_to_itir.py b/src/gt4py/next/ffront/past_to_itir.py
index 8fc6d309..7c9ee5e6 100644
--- a/src/gt4py/next/ffront/past_to_itir.py
+++ b/src/gt4py/next/ffront/past_to_itir.py
@@ -348,6 +348,12 @@ class ProgramLowering(traits.VisitorWithSymbolTableTrait, NodeTranslator):
         return itir.SymRef(id=node.id)
 
     def visit_Symbol(self, node: past.Symbol, **kwargs) -> itir.Sym:
+        # TODO(tehrengruber): extend to more types
+        if isinstance(node.type, ts.FieldType):
+            kind = "Iterator"
+            dtype = node.type.dtype.kind.name.lower()
+            is_list = type_info.is_local_field(node.type)
+            return itir.Sym(id=node.id, kind=kind, dtype=(dtype, is_list))
         return itir.Sym(id=node.id)
 
     def visit_BinOp(self, node: past.BinOp, **kwargs) -> itir.FunCall:
diff --git a/src/gt4py/next/iterator/ir.py b/src/gt4py/next/iterator/ir.py
index e1789ddc..ede08d63 100644
--- a/src/gt4py/next/iterator/ir.py
+++ b/src/gt4py/next/iterator/ir.py
@@ -12,7 +12,8 @@
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
 
-from typing import ClassVar, List, Union
+import typing
+from typing import ClassVar, List, Optional, Union
 
 import gt4py.eve as eve
 from gt4py.eve import Coerced, SymbolName, SymbolRef, datamodels
@@ -38,6 +39,24 @@ class Node(eve.Node):
 
 class Sym(Node):  # helper
     id: Coerced[SymbolName]  # noqa: A003
+    # TODO(tehrengruber): Revisit. Using strings is a workaround to avoid coupling with the
+    #   type inference.
+    kind: typing.Literal["Iterator", "Value", None] = None
+    dtype: Optional[
+        tuple[str, bool]
+    ] = None  # format: name of primitive type, boolean indicating if it is a list
+
+    @datamodels.validator("kind")
+    def _kind_validator(self: datamodels.DataModelTP, attribute: datamodels.Attribute, value: str):
+        if value and value not in ["Iterator", "Value"]:
+            raise ValueError(f"Invalid kind `{value}`, must be one of `Iterator`, `Value`.")
+
+    @datamodels.validator("dtype")
+    def _dtype_validator(self: datamodels.DataModelTP, attribute: datamodels.Attribute, value: str):
+        if value and value[0] not in TYPEBUILTINS:
+            raise ValueError(
+                f"Invalid dtype `{value}`, must be one of `{'`, `'.join(TYPEBUILTINS)}`."
+            )
 
 
 @noninstantiable
@@ -186,15 +205,15 @@ class FencilDefinition(Node, ValidatedSymbolTableTrait):
 
 
 # TODO(fthaler): just use hashable types in nodes (tuples instead of lists)
-Sym.__hash__ = Node.__hash__  # type: ignore[assignment]
-Expr.__hash__ = Node.__hash__  # type: ignore[assignment]
-Literal.__hash__ = Node.__hash__  # type: ignore[assignment]
-NoneLiteral.__hash__ = Node.__hash__  # type: ignore[assignment]
-OffsetLiteral.__hash__ = Node.__hash__  # type: ignore[assignment]
-AxisLiteral.__hash__ = Node.__hash__  # type: ignore[assignment]
-SymRef.__hash__ = Node.__hash__  # type: ignore[assignment]
-Lambda.__hash__ = Node.__hash__  # type: ignore[assignment]
-FunCall.__hash__ = Node.__hash__  # type: ignore[assignment]
-FunctionDefinition.__hash__ = Node.__hash__  # type: ignore[assignment]
-StencilClosure.__hash__ = Node.__hash__  # type: ignore[assignment]
-FencilDefinition.__hash__ = Node.__hash__  # type: ignore[assignment]
+Sym.__hash__ = Node.__hash__  # type: ignore[method-assign]
+Expr.__hash__ = Node.__hash__  # type: ignore[method-assign]
+Literal.__hash__ = Node.__hash__  # type: ignore[method-assign]
+NoneLiteral.__hash__ = Node.__hash__  # type: ignore[method-assign]
+OffsetLiteral.__hash__ = Node.__hash__  # type: ignore[method-assign]
+AxisLiteral.__hash__ = Node.__hash__  # type: ignore[method-assign]
+SymRef.__hash__ = Node.__hash__  # type: ignore[method-assign]
+Lambda.__hash__ = Node.__hash__  # type: ignore[method-assign]
+FunCall.__hash__ = Node.__hash__  # type: ignore[method-assign]
+FunctionDefinition.__hash__ = Node.__hash__  # type: ignore[method-assign]
+StencilClosure.__hash__ = Node.__hash__  # type: ignore[method-assign]
+FencilDefinition.__hash__ = Node.__hash__  # type: ignore[method-assign]
diff --git a/src/gt4py/next/iterator/type_inference.py b/src/gt4py/next/iterator/type_inference.py
index a1594fb7..f39648d5 100644
--- a/src/gt4py/next/iterator/type_inference.py
+++ b/src/gt4py/next/iterator/type_inference.py
@@ -557,7 +557,28 @@ class _TypeInferrer(eve.traits.VisitorWithSymbolTableTrait, eve.NodeTranslator):
         return result
 
     def visit_Sym(self, node: ir.Sym, **kwargs) -> Type:
-        return TypeVar.fresh()
+        result = TypeVar.fresh()
+        if node.kind:
+            kind = {"Iterator": Iterator(), "Value": Value()}[node.kind]
+            self.constraints.add(
+                (Val(kind=kind, current_loc=TypeVar.fresh(), defined_loc=TypeVar.fresh()), result)
+            )
+        if node.dtype:
+            assert node.dtype is not None
+            dtype: Primitive | List = Primitive(name=node.dtype[0])
+            if node.dtype[1]:
+                dtype = List(dtype=dtype)
+            self.constraints.add(
+                (
+                    Val(
+                        dtype=dtype,
+                        current_loc=TypeVar.fresh(),
+                        defined_loc=TypeVar.fresh(),
+                    ),
+                    result,
+                )
+            )
+        return result
 
     def visit_SymRef(self, node: ir.SymRef, *, symtable, **kwargs) -> Type:
         if node.id in ir.BUILTINS:
@@ -718,7 +739,12 @@ class _TypeInferrer(eve.traits.VisitorWithSymbolTableTrait, eve.NodeTranslator):
 
     def _visit_shift(self, node: ir.FunCall, **kwargs) -> Type:
         # Calls to shift are handled as being part of the grammar, not
-        # as function calls, as the type depends on the offset provider
+        # as function calls, as the type depends on the offset provider.
+
+        # Visit arguments such that their type is also inferred (particularly important for
+        # dynamic offsets)
+        self.visit(node.args)
+
         current_loc_in, current_loc_out = _infer_shift_location_types(
             node.args, self.offset_provider, self.constraints
         )
diff --git a/src/gt4py/next/otf/compilation/compiler.py b/src/gt4py/next/otf/compilation/compiler.py
index df5e5124..32c54693 100644
--- a/src/gt4py/next/otf/compilation/compiler.py
+++ b/src/gt4py/next/otf/compilation/compiler.py
@@ -47,7 +47,15 @@ class BuildSystemProjectGenerator(Protocol[SrcL, LS, TgtL]):
 
 @dataclasses.dataclass(frozen=True)
 class Compiler(
-    step_types.CompilationStep[SourceLanguageType, LanguageSettingsType, languages.Python]
+    workflow.ChainableWorkflowMixin[
+        stages.CompilableSource[SourceLanguageType, LanguageSettingsType, languages.Python],
+        stages.CompiledProgram,
+    ],
+    workflow.ReplaceEnabledWorkflowMixin[
+        stages.CompilableSource[SourceLanguageType, LanguageSettingsType, languages.Python],
+        stages.CompiledProgram,
+    ],
+    step_types.CompilationStep[SourceLanguageType, LanguageSettingsType, languages.Python],
 ):
     """Use any build system (via configured factory) to compile a GT4Py program to a ``gt4py.next.otf.stages.CompiledProgram``."""
 
@@ -79,15 +87,6 @@ class Compiler(
             importer.import_from_path(src_dir / new_data.module), new_data.entry_point_name
         )
 
-    def chain(
-        self, step: workflow.Workflow[stages.CompiledProgram, T]
-    ) -> workflow.CombinedStep[
-        stages.CompilableSource[SourceLanguageType, LanguageSettingsType, languages.Python],
-        stages.CompiledProgram,
-        T,
-    ]:
-        return workflow.CombinedStep(first=self, second=step)
-
 
 class CompilationError(RuntimeError):
     ...
diff --git a/src/gt4py/next/otf/recipes.py b/src/gt4py/next/otf/recipes.py
new file mode 100644
index 00000000..d1445337
--- /dev/null
+++ b/src/gt4py/next/otf/recipes.py
@@ -0,0 +1,40 @@
+# GT4Py - GridTools Framework
+#
+# Copyright (c) 2014-2023, ETH Zurich
+# All rights reserved.
+#
+# This file is part of the GT4Py project and the GridTools framework.
+# GT4Py is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or any later
+# version. See the LICENSE.txt file at the top-level directory of this
+# distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+from __future__ import annotations
+
+import dataclasses
+from typing import Generic, TypeVar
+
+from gt4py.next.otf import languages, stages, step_types, workflow
+
+
+SrcL = TypeVar("SrcL", bound=languages.LanguageTag)
+TgtL = TypeVar("TgtL", bound=languages.LanguageTag)
+LS = TypeVar("LS", bound=languages.LanguageSettings)
+
+
+@dataclasses.dataclass(frozen=True)
+class OTFCompileWorkflow(workflow.NamedStepSequence, Generic[SrcL, LS, TgtL]):
+    """The typical compiled backend steps composed into a workflow."""
+
+    translation: step_types.TranslationStep[SrcL, LS]
+    bindings: workflow.Workflow[
+        stages.ProgramSource[SrcL, LS],
+        stages.CompilableSource[SrcL, LS, TgtL],
+    ]
+    compilation: workflow.Workflow[
+        stages.CompilableSource[SrcL, LS, TgtL],
+        stages.CompiledProgram,
+    ]
+    decoration: workflow.Workflow[stages.CompiledProgram, stages.CompiledProgram]
diff --git a/src/gt4py/next/otf/step_types.py b/src/gt4py/next/otf/step_types.py
index 49d7e380..54fe2e53 100644
--- a/src/gt4py/next/otf/step_types.py
+++ b/src/gt4py/next/otf/step_types.py
@@ -16,7 +16,7 @@ from __future__ import annotations
 
 from typing import Protocol, TypeVar
 
-from gt4py.next.otf import languages, stages
+from gt4py.next.otf import languages, stages, workflow
 
 
 SrcL = TypeVar("SrcL", bound=languages.LanguageTag)
@@ -27,11 +27,13 @@ TgtL_co = TypeVar("TgtL_co", bound=languages.LanguageTag, covariant=True)
 LS_co = TypeVar("LS_co", bound=languages.LanguageSettings, covariant=True)
 
 
-class TranslationStep(Protocol[SrcL, LS]):
+class TranslationStep(
+    workflow.ReplaceEnabledWorkflowMixin[stages.ProgramCall, stages.ProgramSource[SrcL, LS]],
+    Protocol[SrcL, LS],
+):
     """Translate a GT4Py program to source code (ProgramCall -> ProgramSource)."""
 
-    def __call__(self, program_call: stages.ProgramCall) -> stages.ProgramSource[SrcL, LS]:
-        ...
+    ...
 
 
 class BindingStep(Protocol[SrcL, LS, TgtL]):
diff --git a/src/gt4py/next/otf/workflow.py b/src/gt4py/next/otf/workflow.py
index 9045e6a7..6420a4dd 100644
--- a/src/gt4py/next/otf/workflow.py
+++ b/src/gt4py/next/otf/workflow.py
@@ -15,7 +15,11 @@
 from __future__ import annotations
 
 import dataclasses
-from typing import Callable, Generic, Protocol, TypeVar
+import functools
+import typing
+from typing import Any, Callable, Generic, Protocol, TypeVar
+
+from typing_extensions import Self
 
 
 StartT = TypeVar("StartT")
@@ -27,7 +31,7 @@ IntermediateT = TypeVar("IntermediateT")
 HashT = TypeVar("HashT")
 
 
-def make_step(function: Workflow[StartT, EndT]) -> Step[StartT, EndT]:
+def make_step(function: Workflow[StartT, EndT]) -> ChainableWorkflowMixin[StartT, EndT]:
     """
     Wrap a function in the workflow step convenience wrapper.
 
@@ -44,9 +48,10 @@ def make_step(function: Workflow[StartT, EndT]) -> Step[StartT, EndT]:
     >>> times_two.chain(stringify)(3)
     '6'
     """
-    return Step(function)
+    return StepSequence.start(function)
 
 
+@typing.runtime_checkable
 class Workflow(Protocol[StartT_contra, EndT_co]):
     """
     Workflow protocol.
@@ -60,38 +65,105 @@ class Workflow(Protocol[StartT_contra, EndT_co]):
         ...
 
 
-@dataclasses.dataclass(frozen=True)
-class Step(Generic[StartT, EndT]):
+class ReplaceEnabledWorkflowMixin(Workflow[StartT_contra, EndT_co], Protocol):
     """
-    Workflow step convenience wrapper.
+    Subworkflow replacement mixin.
+
+    Any subclass MUST be a dataclass for `.replace` to work
+    """
+
+    def replace(self, **kwargs: Any) -> Self:
+        """
+        Build a new instance with replaced substeps.
 
-    Can wrap any callable which implements the workflow step protocol,
-    adds the .chain(other_step) method for convenience.
+        Raises:
+            TypeError: If `self` is not a dataclass.
+        """
+        if not dataclasses.is_dataclass(self):
+            raise TypeError(f"{self.__class__} is not a dataclass")
+        assert not isinstance(self, type)
+        return dataclasses.replace(self, **kwargs)
+
+
+class ChainableWorkflowMixin(Workflow[StartT, EndT]):
+    def chain(self, next_step: Workflow[EndT, NewEndT]) -> ChainableWorkflowMixin[StartT, NewEndT]:
+        return make_step(self).chain(next_step)
+
+
+@dataclasses.dataclass(frozen=True)
+class NamedStepSequence(
+    ChainableWorkflowMixin[StartT, EndT],
+    ReplaceEnabledWorkflowMixin[StartT, EndT],
+):
+    """
+    Workflow with linear succession of named steps.
 
     Examples:
     ---------
-    >>> def times_two(x: int) -> int:
-    ...    return x * 2
+    >>> import dataclasses
 
-    >>> def stringify(x: int) -> str:
+    >>> def parse(x: str) -> int:
+    ...    return int(x)
+
+    >>> def plus_half(x: int) -> float:
+    ...    return x + 0.5
+
+    >>> def stringify(x: float) -> str:
     ...    return str(x)
 
-    >>> # create a workflow int -> int -> str
-    >>> Step(times_two).chain(stringify)(3)
-    '6'
-    """
+    >>> @dataclasses.dataclass(frozen=True)
+    ... class ParseOpPrint(NamedStepSequence[str, str]):
+    ...    parse: Workflow[str, int]
+    ...    op: Workflow[int, float]
+    ...    print: Workflow[float, str]
 
-    step: Workflow[StartT, EndT]
+    >>> pop = ParseOpPrint(
+    ...    parse=parse,
+    ...    op=plus_half,
+    ...    print=stringify
+    ... )
 
-    def __call__(self, inp: StartT) -> EndT:
-        return self.step(inp)
+    >>> pop.step_order
+    ['parse', 'op', 'print']
+
+    >>> pop(73)
+    '73.5'
 
-    def chain(self, step: Workflow[EndT, NewEndT]) -> CombinedStep[StartT, EndT, NewEndT]:
-        return CombinedStep(first=self.step, second=step)
+    >>> def plus_tenth(x: int) -> float:
+    ...   return x + 0.1
+
+
+    >>> pop.replace(op=plus_tenth)(73)
+    '73.1'
+    """
+
+    def __call__(self, inp: StartT) -> EndT:
+        """Compose the steps in the order defined in the `.step_order` class attribute."""
+        step_result: Any = inp
+        for step_name in self.step_order:
+            step_result = getattr(self, step_name)(step_result)
+        return step_result
+
+    @functools.cached_property
+    def step_order(self) -> list[str]:
+        """
+        Read step order from class definition by default.
+
+        Only attributes who are type hinted to be of a type that
+        conforms to the Workflow protocol are considered steps.
+        """
+        step_names: list[str] = []
+        annotations = typing.get_type_hints(self.__class__)
+        for field in dataclasses.fields(self):
+            field_type = annotations[field.name]
+            field_type = typing.get_origin(field_type) or field_type
+            if issubclass(field_type, Workflow):
+                step_names.append(field.name)
+        return step_names
 
 
 @dataclasses.dataclass(frozen=True)
-class CombinedStep(Generic[StartT, IntermediateT, EndT]):
+class StepSequence(ChainableWorkflowMixin[StartT, EndT]):
     """
     Composable workflow of single input callables.
 
@@ -106,33 +178,45 @@ class CombinedStep(Generic[StartT, IntermediateT, EndT]):
     >>> def stringify(x: float) -> str:
     ...    return str(x)
 
-    >>> CombinedStep(  # workflow (int -> float -> str)
-    ...    first=CombinedStep(  # workflow (int -> int -> float)
-    ...        first=plus_one,
-    ...        second=plus_half
-    ...    ),
-    ...    second=stringify
-    ... )(73)
-    '74.5'
-
-    >>> # is exactly equivalent to
-    >>> CombinedStep(first=plus_one, second=plus_half).chain(stringify)(73)
+    >>> StepSequence.start(plus_one).chain(plus_half).chain(stringify)(73)
     '74.5'
 
     """
 
-    first: Workflow[StartT, IntermediateT]
-    second: Workflow[IntermediateT, EndT]
+    @dataclasses.dataclass(frozen=True)
+    class __Steps:
+        inner: tuple[Workflow[Any, Any], ...]
+
+    # todo(ricoh): replace with normal tuple with TypeVarTuple hints
+    #   to enable automatic deduction StartT and EndT fom constructor
+    #   calls. TypeVarTuple is available in typing_extensions in
+    #   Python <= 3.11. Revise after mypy constraint is > 1.0.1,
+    #   which fails on trying to check TypeVarTuple.
+    steps: __Steps
 
     def __call__(self, inp: StartT) -> EndT:
-        return self.second(self.first(inp))
+        step_result: Any = inp
+        for step in self.steps.inner:
+            step_result = step(step_result)
+        return step_result
 
-    def chain(self, step: Workflow[EndT, NewEndT]) -> CombinedStep[StartT, EndT, NewEndT]:
-        return CombinedStep(first=self, second=step)
+    def chain(self, next_step: Workflow[EndT, NewEndT]) -> ChainableWorkflowMixin[StartT, NewEndT]:
+        return typing.cast(
+            ChainableWorkflowMixin[StartT, NewEndT],
+            self.__class__(self.__Steps((*self.steps.inner, next_step))),
+        )
+
+    @classmethod
+    def start(cls, first_step: Workflow[StartT, EndT]) -> ChainableWorkflowMixin[StartT, EndT]:
+        return cls(cls.__Steps((first_step,)))
 
 
 @dataclasses.dataclass(frozen=True)
-class CachedStep(Step[StartT, EndT], Generic[StartT, EndT, HashT]):
+class CachedStep(
+    ChainableWorkflowMixin[StartT, EndT],
+    ReplaceEnabledWorkflowMixin[StartT, EndT],
+    Generic[StartT, EndT, HashT],
+):
     """
     Cached workflow of single input callables.
 
@@ -157,11 +241,13 @@ class CachedStep(Step[StartT, EndT], Generic[StartT, EndT, HashT]):
     1
     """
 
+    step: Workflow[StartT, EndT]
     hash_function: Callable[[StartT], HashT] = dataclasses.field(default=hash)  # type: ignore[assignment]
 
     _cache: dict[HashT, EndT] = dataclasses.field(repr=False, init=False, default_factory=dict)
 
     def __call__(self, inp: StartT) -> EndT:
+        """Run the step only if the input is not cached, else return from cache."""
         hash_ = self.hash_function(inp)
         try:
             result = self._cache[hash_]
diff --git a/src/gt4py/next/program_processors/codegens/gtfn/gtfn_module.py b/src/gt4py/next/program_processors/codegens/gtfn/gtfn_module.py
index 18237c7f..91330243 100644
--- a/src/gt4py/next/program_processors/codegens/gtfn/gtfn_module.py
+++ b/src/gt4py/next/program_processors/codegens/gtfn/gtfn_module.py
@@ -12,6 +12,8 @@
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
 
+from __future__ import annotations
+
 import dataclasses
 from typing import Any, Final, TypeVar
 
@@ -37,6 +39,10 @@ def get_param_description(name: str, obj: Any) -> interface.Parameter:
 
 @dataclasses.dataclass(frozen=True)
 class GTFNTranslationStep(
+    workflow.ChainableWorkflowMixin[
+        stages.ProgramCall,
+        stages.ProgramSource[languages.Cpp, languages.LanguageWithHeaderFilesSettings],
+    ],
     step_types.TranslationStep[languages.Cpp, languages.LanguageWithHeaderFilesSettings],
 ):
     language_settings: languages.LanguageWithHeaderFilesSettings = cpp_interface.CPP_DEFAULT
@@ -182,18 +188,6 @@ class GTFNTranslationStep(
         )
         return module
 
-    def chain(
-        self,
-        step: workflow.Workflow[
-            stages.ProgramSource[languages.Cpp, languages.LanguageWithHeaderFilesSettings], T
-        ],
-    ) -> workflow.CombinedStep[
-        stages.ProgramCall,
-        stages.ProgramSource[languages.Cpp, languages.LanguageWithHeaderFilesSettings],
-        T,
-    ]:
-        return workflow.CombinedStep(first=self, second=step)
-
 
 translate_program: Final[
     step_types.TranslationStep[languages.Cpp, languages.LanguageWithHeaderFilesSettings]
diff --git a/src/gt4py/next/program_processors/otf_compile_executor.py b/src/gt4py/next/program_processors/otf_compile_executor.py
new file mode 100644
index 00000000..a2202841
--- /dev/null
+++ b/src/gt4py/next/program_processors/otf_compile_executor.py
@@ -0,0 +1,56 @@
+# 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
+
+import dataclasses
+from typing import Any, Generic, Optional, TypeVar
+
+from gt4py.next.iterator import ir as itir
+from gt4py.next.otf import languages, recipes, stages, workflow
+from gt4py.next.program_processors import processor_interface as ppi
+
+
+SrcL = TypeVar("SrcL", bound=languages.LanguageTag)
+TgtL = TypeVar("TgtL", bound=languages.LanguageTag)
+LS = TypeVar("LS", bound=languages.LanguageSettings)
+HashT = TypeVar("HashT")
+
+
+@dataclasses.dataclass(frozen=True)
+class OTFCompileExecutor(ppi.ProgramExecutor, Generic[SrcL, LS, TgtL, HashT]):
+    otf_workflow: recipes.OTFCompileWorkflow[SrcL, LS, TgtL]
+    name: Optional[str] = None
+
+    def __call__(self, program: itir.FencilDefinition, *args, **kwargs: Any) -> None:
+        self.otf_workflow(stages.ProgramCall(program, args, kwargs))(
+            *args, offset_provider=kwargs["offset_provider"]
+        )
+
+    @property
+    def __name__(self) -> str:
+        return self.name or repr(self)
+
+
+@dataclasses.dataclass(frozen=True)
+class CachedOTFCompileExecutor(ppi.ProgramExecutor, Generic[SrcL, LS, TgtL, HashT]):
+    otf_workflow: workflow.CachedStep[stages.ProgramCall, stages.CompiledProgram, HashT]
+    name: Optional[str] = None
+
+    def __call__(self, program: itir.FencilDefinition, *args, **kwargs: Any) -> None:
+        self.otf_workflow(stages.ProgramCall(program, args, kwargs))(
+            *args, offset_provider=kwargs["offset_provider"]
+        )
+
+    @property
+    def __name__(self) -> str:
+        return self.name or repr(self)
diff --git a/src/gt4py/next/program_processors/runners/gtfn_cpu.py b/src/gt4py/next/program_processors/runners/gtfn_cpu.py
index 966fc784..3800380f 100644
--- a/src/gt4py/next/program_processors/runners/gtfn_cpu.py
+++ b/src/gt4py/next/program_processors/runners/gtfn_cpu.py
@@ -12,21 +12,18 @@
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
 
-import dataclasses
-from typing import Any, Callable, Final, Optional
+from typing import Any
 
 import numpy as np
 import numpy.typing as npt
 
 from gt4py.eve.utils import content_hash
 from gt4py.next import common
-from gt4py.next.iterator import ir as itir
-from gt4py.next.otf import languages, stages, workflow
+from gt4py.next.otf import languages, recipes, stages, workflow
 from gt4py.next.otf.binding import cpp_interface, pybind
 from gt4py.next.otf.compilation import cache, compiler
 from gt4py.next.otf.compilation.build_systems import compiledb
-from gt4py.next.otf.workflow import CachedStep
-from gt4py.next.program_processors import processor_interface as ppi
+from gt4py.next.program_processors import otf_compile_executor
 from gt4py.next.program_processors.codegens.gtfn import gtfn_module
 from gt4py.next.type_system.type_translation import from_value
 
@@ -41,7 +38,7 @@ def convert_arg(arg: Any) -> Any:
         return arg
 
 
-def convert_args(inp: Callable) -> Callable:
+def convert_args(inp: stages.CompiledProgram) -> stages.CompiledProgram:
     def decorated_program(
         *args, offset_provider: dict[str, common.Connectivity | common.Dimension]
     ):
@@ -90,69 +87,39 @@ def compilation_hash(otf_closure: stages.ProgramCall) -> int:
     )
 
 
-@dataclasses.dataclass(frozen=True)
-class GTFNExecutor(ppi.ProgramExecutor):
-    language_settings: languages.LanguageWithHeaderFilesSettings = cpp_interface.CPP_DEFAULT
-    builder_factory: compiler.BuildSystemProjectGenerator = compiledb.CompiledbFactory()
-
-    name: Optional[str] = None
-    enable_itir_transforms: bool = True  # TODO replace by more general mechanism, see https://github.com/GridTools/gt4py/issues/1135
-    use_imperative_backend: bool = False
-
-    # TODO(tehrengruber): Revisit default value. The hash function for the caching currently
-    #  only uses a subset of the closure and implicitly relies on the workflow to only use that
-    #  information. As this is dangerous the caching is disabled by default and should be considered
-    #  experimental.
-    use_caching: bool = False
-    caching_strategy = cache.Strategy.SESSION
-
-    _otf_workflow: workflow.Workflow[
-        stages.ProgramCall, stages.CompiledProgram
-    ] = dataclasses.field(repr=False, init=False)
-
-    def __post_init__(self):
-        # TODO(tehrengruber): Restrict arguments of OTF workflow to the parts it actually needs
-        #  to compile the program instead of the entire closure.
-        otf_workflow: workflow.Workflow[stages.ProgramCall, stages.CompiledProgram] = (
-            gtfn_module.GTFNTranslationStep(
-                self.language_settings, self.enable_itir_transforms, self.use_imperative_backend
-            )
-            .chain(pybind.bind_source)
-            .chain(
-                compiler.Compiler(
-                    cache_strategy=self.caching_strategy, builder_factory=self.builder_factory
-                )
-            )
-            .chain(convert_args)
-        )
-
-        if self.use_caching:
-            otf_workflow = CachedStep(step=otf_workflow, hash_function=compilation_hash)
-
-        super().__setattr__("_otf_workflow", otf_workflow)
-
-    def __call__(self, program: itir.FencilDefinition, *args: Any, **kwargs: Any) -> None:
-        """
-        Execute the iterator IR program with the provided arguments.
+GTFN_DEFAULT_TRANSLATION_STEP = gtfn_module.GTFNTranslationStep(
+    cpp_interface.CPP_DEFAULT, enable_itir_transforms=True, use_imperative_backend=False
+)
 
-        The program is compiled to machine code with C++ as an intermediate step,
-        so the first execution is expected to have a significant overhead, while subsequent
-        calls are very fast. Only scalar and buffer arguments are supported currently.
+GTFN_DEFAULT_COMPILE_STEP = compiler.Compiler(
+    cache_strategy=cache.Strategy.SESSION, builder_factory=compiledb.CompiledbFactory()
+)
 
-        See ``ProgramExecutorFunction`` for details.
-        """
-        compiled_runner = self._otf_workflow(stages.ProgramCall(program, args, kwargs))
 
-        return compiled_runner(*args, offset_provider=kwargs["offset_provider"])
+GTFN_DEFAULT_WORKFLOW = recipes.OTFCompileWorkflow(
+    translation=GTFN_DEFAULT_TRANSLATION_STEP,
+    bindings=pybind.bind_source,
+    compilation=GTFN_DEFAULT_COMPILE_STEP,
+    decoration=convert_args,
+)
 
-    @property
-    def __name__(self) -> str:
-        return self.name or repr(self)
 
+run_gtfn = otf_compile_executor.OTFCompileExecutor[
+    languages.Cpp, languages.LanguageWithHeaderFilesSettings, languages.Python, Any
+](name="run_gtfn", otf_workflow=GTFN_DEFAULT_WORKFLOW)
 
-run_gtfn: Final[ppi.ProgramProcessor[None, ppi.ProgramExecutor]] = GTFNExecutor(
-    name="run_gtfn", use_imperative_backend=False
-)
-run_gtfn_imperative: Final[ppi.ProgramProcessor[None, ppi.ProgramExecutor]] = GTFNExecutor(
-    name="run_gtfn_imperative", use_imperative_backend=True
+run_gtfn_imperative = otf_compile_executor.OTFCompileExecutor[
+    languages.Cpp, languages.LanguageWithHeaderFilesSettings, languages.Python, Any
+](
+    name="run_gtfn_imperative",
+    otf_workflow=run_gtfn.otf_workflow.replace(
+        translation=run_gtfn.otf_workflow.translation.replace(use_imperative_backend=True),
+    ),
 )
+
+run_gtfn_cached = otf_compile_executor.CachedOTFCompileExecutor[
+    languages.Cpp, languages.LanguageWithHeaderFilesSettings, languages.Python, Any
+](
+    name="run_gtfn_cached",
+    otf_workflow=workflow.CachedStep(step=run_gtfn.otf_workflow, hash_function=compilation_hash),
+)  # todo(ricoh): add API for converting an executor to a cached version of itself and vice versa
diff --git a/src/gt4py/next/type_system/type_info.py b/src/gt4py/next/type_system/type_info.py
index 6bb3622b..7e1d7cf9 100644
--- a/src/gt4py/next/type_system/type_info.py
+++ b/src/gt4py/next/type_system/type_info.py
@@ -18,7 +18,7 @@ from typing import Callable, Iterator, Type, TypeGuard, cast
 import numpy as np
 
 from gt4py.eve.utils import XIterable, xiter
-from gt4py.next.common import Dimension, GTTypeError
+from gt4py.next.common import Dimension, DimensionKind, GTTypeError
 from gt4py.next.type_system import type_specifications as ts
 
 
@@ -302,6 +302,29 @@ def extract_dims(symbol_type: ts.TypeSpec) -> list[Dimension]:
     raise GTTypeError(f"Can not extract dimensions from {symbol_type}!")
 
 
+def is_local_field(type_: ts.FieldType) -> bool:
+    """
+    Return if `type_` is a field defined on a local dimension.
+
+    Examples:
+    ---------
+    >>> V = Dimension(value="V")
+    >>> V2E = Dimension(value="V2E", kind=DimensionKind.LOCAL)
+    >>> is_local_field(ts.FieldType(dims=[V, V2E], dtype=ts.ScalarType(kind=ts.ScalarKind.INT64)))
+    True
+    >>> is_local_field(ts.FieldType(dims=[V], dtype=ts.ScalarType(kind=ts.ScalarKind.INT64)))
+    False
+    """
+    return any(dim.kind == DimensionKind.LOCAL for dim in type_.dims)
+
+
+def contains_local_field(type_: ts.TypeSpec) -> bool:
+    """Return if primitive constitutens of `type_` contains a field defined on a local dimension."""
+    return any(
+        isinstance(t, ts.FieldType) and is_local_field(t) for t in primitive_constituents(type_)
+    )
+
+
 def is_concretizable(symbol_type: ts.TypeSpec, to_type: ts.TypeSpec) -> bool:
     """
     Check if ``symbol_type`` can be concretized to ``to_type``.
diff --git a/tox.ini b/tox.ini
index 0b54a2b0..730d8dca 100644
--- a/tox.ini
+++ b/tox.ini
@@ -28,7 +28,7 @@ labels =
     storage-py38-dace-cpu, storage-py39-dace-cpu, storage-py310-dace-cpu
 
 [testenv]
-deps = -r {tox_root}{/}requirements-dev.txt
+deps = -r {tox_root}{/}{env:ENV_REQUIREMENTS_FILE:requirements-dev.txt}
 constrain_package_deps = true
 use_frozen_constraints = true
 extras =
@@ -49,7 +49,7 @@ package = wheel
 wheel_build_env = .pkg
 pass_env = NUM_PROCESSES
 set_env =
-    PYTHONWARNINGS = ignore:Support for `[tool.setuptools]` in `pyproject.toml` is still *beta*:UserWarning
+    PYTHONWARNINGS = {env:PYTHONWARNINGS:ignore:Support for `[tool.setuptools]` in `pyproject.toml` is still *beta*:UserWarning}
 
 [testenv:cartesian-py{38,39,310}-{internal,dace}-{cpu,cuda,cuda110,cuda111,cuda112,cuda113,cuda114,cuda115,cuda116,cuda117,cuda11x}]
 description = Run 'gt4py.cartesian' tests
@@ -72,6 +72,12 @@ commands =
 ;    coverage json --rcfile=setup.cfg
 ;    coverage html --rcfile=setup.cfg --show-contexts
 
+[testenv:eve-py{38,39,310}]
+description = Run 'gt4py.eve' tests
+commands =
+    python -m pytest --cache-clear -v -n {env:NUM_PROCESSES:1} {posargs} tests{/}eve_tests
+    python -m pytest --doctest-modules src{/}gt4py{/}eve
+
 [testenv:next-py{310}-{nomesh,atlas}]
 description = Run 'gt4py.next' tests
 pass_env = {[testenv]pass_env}, BOOST_ROOT, BOOST_HOME, CUDA_HOME, CUDA_PATH
@@ -86,12 +92,6 @@ commands =
     atlas: python -m pytest --cache-clear -v -n {env:NUM_PROCESSES:1} -m "requires_atlas" {posargs} tests{/}next_tests
     pytest --doctest-modules src{/}gt4py{/}next
 
-[testenv:eve-py{38,39,310}]
-description = Run 'gt4py.eve' tests
-commands =
-    python -m pytest --cache-clear -v -n {env:NUM_PROCESSES:1} {posargs} tests{/}eve_tests
-    python -m pytest --doctest-modules src{/}gt4py{/}eve
-
 [testenv:storage-py{38,39,310}-{internal,dace}-{cpu,cuda,cuda110,cuda111,cuda112,cuda113,cuda114,cuda115,cuda116,cuda117,cuda11x}]
 description = Run 'gt4py.storage' tests
 commands =
@@ -155,6 +155,8 @@ allowlist_externals =
 commands =
     -mv constraints.txt constraints.txt.old
     -mv requirements-dev.txt requirements-dev.old
+    # Run cog to update requirements files from pyproject
+    cog -r -P requirements-dev.in min-requirements-test.txt min-extra-requirements-test.txt
     # Generate constraints file removing extras
     # (extras are not supported by pip in constraints files)
     pip-compile -r --resolver=backtracking \
@@ -178,7 +180,6 @@ commands =
     --extra testing \
     -o requirements-dev.txt \
     pyproject.toml requirements-dev.in
-
     # Run cog to update .pre-commit-config.yaml with new versions
     common: cog -r -P .pre-commit-config.yaml
 


Test patch:
diff --git a/min-extra-requirements-test.txt b/min-extra-requirements-test.txt
new file mode 100644
index 00000000..78bc265a
--- /dev/null
+++ b/min-extra-requirements-test.txt
@@ -0,0 +1,77 @@
+##[[[cog
+## import re, tomli
+## project = tomli.loads(open("pyproject.toml").read())
+## requirements = set(
+##     rr
+##     for r in (
+##         project["project"]["dependencies"]
+##         + project["project"]["optional-dependencies"]["full"]
+##         + open("requirements-dev.in").readlines()
+##     )
+##     if (rr := r.strip()) and not rr.startswith('#')
+## )
+## for r in sorted(requirements):
+##     m = re.match("^([\w-][\w\d\[\]-]*)[=>~][=]([^,]+)", r)
+##     print(f"{m[1]}=={m[2].strip()}")
+##]]]
+astunparse==1.6.3;python_version<'3.9'
+attrs==21.3
+black==22.3
+boltons==20.1
+cached-property==1.5.1
+clang-format==9.0
+click==8.0.0
+cmake==3.22
+cogapp==3.3
+coverage[toml]==5.0
+cytoolz==0.12.0
+dace==0.14.2
+darglint==1.6
+deepdiff==5.6.0
+devtools==0.6
+factory-boy==3.1
+flake8-bugbear==20.11.1
+flake8-builtins==1.5.3
+flake8-debugger==4.0.0
+flake8-docstrings==1.5.0
+flake8-eradicate==1.3.0
+flake8-mutable==1.2.0
+flake8-pyproject==1.2.2
+flake8-rst-docstrings==0.0.14
+flake8==5.0.4
+frozendict==2.3
+gridtools-cpp==2.2.3
+hypothesis==6.0.0
+importlib-resources==5.0;python_version<'3.9'
+isort==5.10
+jinja2==3.0.0
+jupytext==1.14
+lark==1.1.2
+mako==1.1
+mypy==1.0
+ninja==1.10
+numpy==1.21.2
+packaging==20.0
+pip-tools==6.10
+pipdeptree==2.3
+pre-commit==2.17
+psutil==5.0
+pybind11==2.5
+pygments==2.7
+pytest-cache==1.0
+pytest-cov==2.8
+pytest-factoryboy==2.0.3
+pytest-xdist[psutil]==2.4
+pytest==7.0
+scipy==1.7.2
+setuptools==65.5.0
+sphinx==4.4
+sphinx_rtd_theme==1.0
+sympy==1.7
+tabulate==0.8.10
+tomli==2.0.1
+tox==3.2.0
+types-all==1.0.0
+typing-extensions==4.2
+xxhash==1.4.4
+##[[[end]]]
diff --git a/min-requirements-test.txt b/min-requirements-test.txt
new file mode 100644
index 00000000..ba296256
--- /dev/null
+++ b/min-requirements-test.txt
@@ -0,0 +1,73 @@
+##[[[cog
+## import re, tomli
+## project = tomli.loads(open("pyproject.toml").read())
+## requirements = set(
+##     rr
+##     for r in (
+##         project["project"]["dependencies"]
+##         + open("requirements-dev.in").readlines()
+##     )
+##     if (rr := r.strip()) and not rr.startswith('#')
+## )
+## for r in sorted(requirements):
+##     m = re.match("^([\w-][\w\d\[\]-]*)[=>~][=]([^,]+)", r)
+##     print(f"{m[1]}=={m[2].strip()}")
+##]]]
+astunparse==1.6.3;python_version<'3.9'
+attrs==21.3
+black==22.3
+boltons==20.1
+cached-property==1.5.1
+clang-format==9.0
+click==8.0.0
+cmake==3.22
+cogapp==3.3
+coverage[toml]==5.0
+cytoolz==0.12.0
+darglint==1.6
+deepdiff==5.6.0
+devtools==0.6
+factory-boy==3.1
+flake8-bugbear==20.11.1
+flake8-builtins==1.5.3
+flake8-debugger==4.0.0
+flake8-docstrings==1.5.0
+flake8-eradicate==1.3.0
+flake8-mutable==1.2.0
+flake8-pyproject==1.2.2
+flake8-rst-docstrings==0.0.14
+flake8==5.0.4
+frozendict==2.3
+gridtools-cpp==2.2.3
+hypothesis==6.0.0
+importlib-resources==5.0;python_version<'3.9'
+isort==5.10
+jinja2==3.0.0
+jupytext==1.14
+lark==1.1.2
+mako==1.1
+mypy==1.0
+ninja==1.10
+numpy==1.21.2
+packaging==20.0
+pip-tools==6.10
+pipdeptree==2.3
+pre-commit==2.17
+psutil==5.0
+pybind11==2.5
+pygments==2.7
+pytest-cache==1.0
+pytest-cov==2.8
+pytest-factoryboy==2.0.3
+pytest-xdist[psutil]==2.4
+pytest==7.0
+setuptools==65.5.0
+sphinx==4.4
+sphinx_rtd_theme==1.0
+tabulate==0.8.10
+tomli==2.0.1
+tox==3.2.0
+types-all==1.0.0
+typing-extensions==4.2
+xxhash==1.4.4
+##[[[end]]]
diff --git a/tests/cartesian_tests/unit_tests/test_gtc/test_npir_codegen.py b/tests/cartesian_tests/unit_tests/test_gtc/test_npir_codegen.py
index 9be83d4b..de3269ff 100644
--- a/tests/cartesian_tests/unit_tests/test_gtc/test_npir_codegen.py
+++ b/tests/cartesian_tests/unit_tests/test_gtc/test_npir_codegen.py
@@ -173,7 +173,7 @@ def test_native_function() -> None:
         is_serial=False,
     )
     print(result)
-    match = re.match(r"np.minimum\(a\[.*\],\s*p\)", result)
+    match = re.match(r"ufuncs.minimum\(a\[.*\],\s*p\)", result)
     assert match
 
 
@@ -299,7 +299,7 @@ def test_computation() -> None:
             r"import numbers\n"
             r"from typing import Tuple\n+"
             r"import numpy as np\n"
-            r"import scipy.special\n+"
+            r"from gt4py.cartesian.gtc import ufuncs\n+"
             r"class Field:\n"
             r"(.*\n)+"
             r"def run\(\*, a, b, _domain_, _origin_\):\n"
diff --git a/tests/eve_tests/unit_tests/test_visitors.py b/tests/eve_tests/unit_tests/test_visitors.py
new file mode 100644
index 00000000..70b7e2e4
--- /dev/null
+++ b/tests/eve_tests/unit_tests/test_visitors.py
@@ -0,0 +1,30 @@
+# GT4Py - GridTools Framework
+#
+# Copyright (c) 2014-2023, ETH Zurich
+# All rights reserved.
+#
+# This file is part of the GT4Py project and the GridTools framework.
+# GT4Py is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the
+# Free Software Foundation, either version 3 of the License, or any later
+# version. See the LICENSE.txt file at the top-level directory of this
+# distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from __future__ import annotations
+
+from gt4py import eve
+
+
+def test_annex_preservation(compound_node: eve.Node):
+    compound_node.annex.foo = 1
+    compound_node.annex.bar = 2
+
+    class SampleTranslator(eve.NodeTranslator):
+        PRESERVED_ANNEX_ATTRS = ("foo",)
+
+    translated_node = SampleTranslator().visit(compound_node)
+
+    assert translated_node.annex.foo == 1
+    assert not hasattr(translated_node.annex, "bar")
diff --git a/tests/next_tests/integration_tests/feature_tests/iterator_tests/test_builtins.py b/tests/next_tests/integration_tests/feature_tests/iterator_tests/test_builtins.py
index 8bf4d867..d9c607b9 100644
--- a/tests/next_tests/integration_tests/feature_tests/iterator_tests/test_builtins.py
+++ b/tests/next_tests/integration_tests/feature_tests/iterator_tests/test_builtins.py
@@ -12,6 +12,7 @@
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
 
+import dataclasses
 import math
 from typing import Callable, Iterable
 
@@ -51,7 +52,8 @@ from gt4py.next.iterator.builtins import (
 )
 from gt4py.next.iterator.embedded import NeighborTableOffsetProvider, np_as_located_field
 from gt4py.next.iterator.runtime import CartesianAxis, closure, fendef, fundef, offset
-from gt4py.next.program_processors.runners.gtfn_cpu import GTFNExecutor
+from gt4py.next.otf import workflow
+from gt4py.next.program_processors.runners.gtfn_cpu import run_gtfn
 
 from next_tests.integration_tests.feature_tests.math_builtin_test_data import math_builtin_test_data
 from next_tests.unit_tests.conftest import program_processor, run_processor
@@ -186,15 +188,13 @@ def test_arithmetic_and_logical_functors_gtfn(builtin, inputs, expected):
     inps = asfield(*asarray(*inputs))
     out = asfield((np.zeros_like(*asarray(expected))))[0]
 
-    gtfn_without_transforms = GTFNExecutor(
-        name="run_gtfn", enable_itir_transforms=False
+    gtfn_without_transforms = dataclasses.replace(
+        run_gtfn,
+        otf_workflow=run_gtfn.otf_workflow.replace(
+            translation=run_gtfn.otf_workflow.translation.replace(enable_itir_transforms=False),
+        ),
     )  # avoid inlining the function
-    fencil(
-        builtin,
-        out,
-        *inps,
-        processor=GTFNExecutor(name="run_gtfn", enable_itir_transforms=False),
-    )
+    fencil(builtin, out, *inps, processor=gtfn_without_transforms)
 
     assert np.allclose(np.asarray(out), expected)
 
diff --git a/tests/next_tests/integration_tests/feature_tests/otf_tests/test_pybind_build.py b/tests/next_tests/integration_tests/feature_tests/otf_tests/test_pybind_build.py
index aac1947c..4eee84c8 100644
--- a/tests/next_tests/integration_tests/feature_tests/otf_tests/test_pybind_build.py
+++ b/tests/next_tests/integration_tests/feature_tests/otf_tests/test_pybind_build.py
@@ -28,7 +28,7 @@ from next_tests.unit_tests.otf_tests.compilation_tests.build_systems_tests.conft
 
 def test_gtfn_cpp_with_cmake(program_source_with_name):
     example_program_source = program_source_with_name("gtfn_cpp_with_cmake")
-    build_the_program = workflow.Step(pybind.bind_source).chain(
+    build_the_program = workflow.make_step(pybind.bind_source).chain(
         compiler.Compiler(
             cache_strategy=cache.Strategy.SESSION, builder_factory=cmake.CMakeFactory()
         ),
@@ -43,7 +43,7 @@ def test_gtfn_cpp_with_cmake(program_source_with_name):
 
 def test_gtfn_cpp_with_compiledb(program_source_with_name):
     example_program_source = program_source_with_name("gtfn_cpp_with_compiledb")
-    build_the_program = workflow.Step(pybind.bind_source).chain(
+    build_the_program = workflow.make_step(pybind.bind_source).chain(
         compiler.Compiler(
             cache_strategy=cache.Strategy.SESSION,
             builder_factory=compiledb.CompiledbFactory(),
diff --git a/tests/next_tests/unit_tests/ffront_tests/test_ffront_lap.py b/tests/next_tests/integration_tests/multi_feature_tests/ffront_tests/test_laplacian.py
similarity index 100%
rename from tests/next_tests/unit_tests/ffront_tests/test_ffront_lap.py
rename to tests/next_tests/integration_tests/multi_feature_tests/ffront_tests/test_laplacian.py
diff --git a/tests/next_tests/unit_tests/ffront_tests/test_foast_to_itir.py b/tests/next_tests/unit_tests/ffront_tests/test_foast_to_itir.py
index 6476c9fd..34909b71 100644
--- a/tests/next_tests/unit_tests/ffront_tests/test_foast_to_itir.py
+++ b/tests/next_tests/unit_tests/ffront_tests/test_foast_to_itir.py
@@ -11,6 +11,11 @@
 # distribution for a copy of the license or check <https://www.gnu.org/licenses/>.
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
+# TODO(tehrengruber): The style of the tests in this file is not optimal as a single change in the
+#  lowering can (and often does) make all of them fail. Once we have embedded field view we want to
+#  switch to executing the different cases here; once with a regular backend (i.e. including
+#  parsing) and then with embedded field view (i.e. no parsing). If the results match the lowering
+#  should be correct.
 
 from __future__ import annotations
 
@@ -31,6 +36,7 @@ from gt4py.next.ffront.fbuiltins import (
 )
 from gt4py.next.ffront.foast_to_itir import FieldOperatorLowering
 from gt4py.next.ffront.func_to_foast import FieldOperatorParser
+from gt4py.next.iterator import ir as itir
 from gt4py.next.type_system import type_specifications as ts, type_translation
 
 
@@ -140,8 +146,12 @@ def test_temp_assignment():
     parsed = FieldOperatorParser.apply_to_function(copy_field)
     lowered = FieldOperatorLowering.apply(parsed)
 
-    reference = im.let("tmp__0", "inp")(
-        im.let("inp__0", "tmp__0")(im.let("tmp2__0", "inp__0")("tmp2__0"))
+    reference = im.let(itir.Sym(id="tmp__0", dtype=("float64", False), kind="Iterator"), "inp")(
+        im.let(itir.Sym(id="inp__0", dtype=("float64", False), kind="Iterator"), "tmp__0")(
+            im.let(itir.Sym(id="tmp2__0", dtype=("float64", False), kind="Iterator"), "inp__0")(
+                "tmp2__0"
+            )
+        )
     )
 
     assert lowered.expr == reference
@@ -157,13 +167,13 @@ def test_unary_ops():
     lowered = FieldOperatorLowering.apply(parsed)
 
     reference = im.let(
-        "tmp__0",
+        itir.Sym(id="tmp__0", dtype=("float64", False), kind="Iterator"),
         im.promote_to_lifted_stencil("plus")(
             im.promote_to_const_iterator(im.literal_("0", "float64")), "inp"
         ),
     )(
         im.let(
-            "tmp__1",
+            itir.Sym(id="tmp__1", dtype=("float64", False), kind="Iterator"),
             im.promote_to_lifted_stencil("minus")(
                 im.promote_to_const_iterator(im.literal_("0", "float64")), "tmp__0"
             ),
@@ -190,7 +200,11 @@ def test_unpacking():
     tuple_access_1 = im.promote_to_lifted_stencil(lambda x: im.tuple_get_(1, x))("__tuple_tmp_0")
 
     reference = im.let("__tuple_tmp_0", tuple_expr)(
-        im.let("tmp1__0", tuple_access_0)(im.let("tmp2__0", tuple_access_1)("tmp1__0"))
+        im.let(itir.Sym(id="tmp1__0", dtype=("float64", False), kind="Iterator"), tuple_access_0)(
+            im.let(
+                itir.Sym(id="tmp2__0", dtype=("float64", False), kind="Iterator"), tuple_access_1
+            )("tmp1__0")
+        )
     )
 
     assert lowered.expr == reference
@@ -484,7 +498,10 @@ def test_reduction_lowering_expr():
         im.promote_to_lifted_stencil(im.map__("plus"))("e1_nbh__0", "e2"),
     )
 
-    reference = im.let("e1_nbh__0", im.lifted_neighbors("V2E", "e1"))(
+    reference = im.let(
+        itir.Sym(id="e1_nbh__0", dtype=("float64", True), kind="Iterator"),
+        im.lifted_neighbors("V2E", "e1"),
+    )(
         im.promote_to_lifted_stencil(
             im.call_(
                 im.call_("reduce")(
diff --git a/tests/next_tests/unit_tests/iterator_tests/test_type_inference.py b/tests/next_tests/unit_tests/iterator_tests/test_type_inference.py
index 7f005df2..1ef6cde8 100644
--- a/tests/next_tests/unit_tests/iterator_tests/test_type_inference.py
+++ b/tests/next_tests/unit_tests/iterator_tests/test_type_inference.py
@@ -93,6 +93,26 @@ def test_lambda():
     assert ti.pformat(inferred) == "(T₀) → T₀"
 
 
+def test_typed_lambda():
+    testee = ir.Lambda(
+        params=[ir.Sym(id="x", kind="Iterator", dtype=("float", False))], expr=ir.SymRef(id="x")
+    )
+    expected_val = ti.Val(
+        kind=ti.Iterator(),
+        dtype=ti.Primitive(name="float"),
+        size=ti.TypeVar(idx=0),
+        current_loc=ti.TypeVar(idx=1),
+        defined_loc=ti.TypeVar(idx=2),
+    )
+    expected = ti.FunctionType(
+        args=ti.Tuple.from_elems(expected_val),
+        ret=expected_val,
+    )
+    inferred = ti.infer(testee)
+    assert inferred == expected
+    assert ti.pformat(inferred) == "(It[T₁, T₂, float⁰]) → It[T₁, T₂, float⁰]"
+
+
 def test_plus():
     testee = ir.SymRef(id="plus")
     t = ti.Val(kind=ti.Value(), dtype=ti.TypeVar(idx=0), size=ti.TypeVar(idx=1))
@@ -606,6 +626,21 @@ def test_function_definition():
     assert ti.pformat(inferred.dtype) == "(T₀) → T₀"
 
 
+def test_dynamic_offset():
+    """Test that the type of a dynamic offset is correctly inferred."""
+    offset_it = ir.SymRef(id="offset_it")
+    testee = ir.FunCall(
+        fun=ir.SymRef(id="shift"),
+        args=[
+            ir.OffsetLiteral(value="V2E"),
+            ir.FunCall(fun=ir.SymRef(id="deref"), args=[offset_it]),
+        ],
+    )
+    inferred_all: dict[int, ti.Type] = ti.infer_all(testee)
+    offset_it_type = inferred_all[id(offset_it)]
+    assert isinstance(offset_it_type, ti.Val) and offset_it_type.kind == ti.Iterator()
+
+
 CARTESIAN_DOMAIN = ir.FunCall(
     fun=ir.SymRef(id="cartesian_domain"),
     args=[
diff --git a/tests/next_tests/unit_tests/otf_tests/test_workflow.py b/tests/next_tests/unit_tests/otf_tests/test_workflow.py
index c6b18b77..9e1e14ed 100644
--- a/tests/next_tests/unit_tests/otf_tests/test_workflow.py
+++ b/tests/next_tests/unit_tests/otf_tests/test_workflow.py
@@ -28,8 +28,15 @@ class StageTwo:
     y: str
 
 
-def step_zero(inp: int) -> StageOne:
-    return StageOne(inp)
+@dataclasses.dataclass(frozen=True)
+class NamedStepsExample(workflow.NamedStepSequence[int, str]):
+    repeat: workflow.Workflow[int, list[int]]
+    strify: workflow.Workflow[list[int], str]
+
+
+@dataclasses.dataclass(frozen=True)
+class SingleStep(workflow.NamedStepSequence[int, StageTwo]):
+    step: workflow.Workflow[int, StageTwo]
 
 
 def step_one(inp: StageOne) -> StageTwo:
@@ -41,17 +48,43 @@ def step_two(inp: StageTwo) -> str:
 
 
 def test_single_step():
-    step1: workflow.Workflow[StageOne, StageTwo] = workflow.Step(step_one)
+    step1: workflow.Workflow[StageOne, StageTwo] = workflow.make_step(step_one)
     assert step1(StageOne(3)) == step_one(StageOne(3))
 
 
-def test_chain_step_step():
-    wf: workflow.Workflow[StageOne, str] = workflow.Step(step_one).chain(step_two)
+def test_chain_step_sequence():
+    wf: workflow.Workflow[StageOne, str] = workflow.StepSequence.start(step_one).chain(step_two)
     inp = StageOne(5)
     assert wf(inp) == step_two(step_one(inp))
 
 
-def test_chain_combinedstep_step():
-    initial_workflow: workflow.Workflow[int, StageTwo] = workflow.CombinedStep(step_zero, step_one)
-    full_workflow: workflow.Workflow[int, str] = initial_workflow.chain(step_two)
-    assert full_workflow(42) == "42"
+def test_named_steps():
+    """Test composing named steps"""
+
+    wf = NamedStepsExample(repeat=lambda inp: [inp] * 3, strify=lambda inp: str(inp))
+    assert wf.repeat(4) == [4, 4, 4]
+    assert wf.strify([1, 2, 3]) == "[1, 2, 3]"
+    assert wf(4) == "[4, 4, 4]"
+
+
+def test_chain_from_named():
+    initial_workflow: workflow.Workflow[StageOne, StageTwo] = SingleStep(step=step_one)
+    full_workflow: workflow.Workflow[StageOne, str] = initial_workflow.chain(step_two)
+    assert full_workflow(StageOne(42)) == "42"
+
+
+def test_cached_with_hashing():
+    def hashing(inp: list[int]) -> int:
+        return hash(sum(inp))
+
+    wf = workflow.CachedStep(step=lambda inp: inp + [1], hash_function=hashing)
+
+    assert wf([1, 2, 3]) == [1, 2, 3, 1]
+    assert wf([3, 2, 1]) == [1, 2, 3, 1]
+
+
+def test_replace():
+    """Test replacing a named step."""
+    wf = NamedStepsExample(repeat=lambda inp: [inp] * 3, strify=lambda inp: str(inp))
+    wf_repl = wf.replace(repeat=lambda inp: [inp] * 4)
+    assert wf_repl(4) == "[4, 4, 4, 4]"