Skip to content

Automatic test-tolerance measurement and tightening (RBF pilot) - #1357

Draft
jcanton wants to merge 4 commits into
mainfrom
auto-tighten-tolerances
Draft

jcanton wants to merge 4 commits into
mainfrom
auto-tighten-tolerances

Conversation

@jcanton

@jcanton jcanton commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Related issue: #967
from a friend:

Automatic test-tolerance measurement and tightening (RBF pilot)

Numeric test tolerances are currently chosen by hand: run with
ICON4PY_DALLCLOSE_PRINT_INSTEAD_OF_FAIL=true, read the printed max diffs, and paste values into
the tests. Nothing revisits a tolerance once a fix makes it too loose, so stale tolerances can hide
regressions.

This PR adds machinery to measure tolerances automatically and tighten them, and pilots it
on the RBF interpolation tolerances. The bulk of the ~300 inline atol/rtol literals elsewhere
are intentionally left for a follow-up.

How it works

  • Record mode (ICON4PY_RECORD_TOLERANCES=<file>): dallclose/assert_dallclose measure the
    max absolute/relative difference instead of asserting, tagged with {backend, experiment, field}
    via the pytest plugin, and dump to JSON lines.
  • Drift warning (ICON4PY_TOLERANCE_DRIFT_WARN): a non-failing terminal summary flags
    tolerances that are much looser than the measured difference.
  • Updater (scripts/python/update_tolerances.py, nox -s update_tolerances): aggregates
    measurements (max over the deterministic CPU backends, scaled by a safety factor, rounded up)
    and only tightens a stored tolerance — never loosens it.
  • CI (report-only): a job in the weekly all pipeline runs the nox session on gtfn_cpu/
    embedded and publishes the proposed tightenings as a tolerance-updates.patch artifact. It
    never commits or pushes — a human reviews and applies the patch. (No GitHub token needed in CSCS
    CI; the all pipeline already has the serialized data, the backends and the weekly cadence.)

Design notes

  • Backend spread: deltas differ per backend; the updater takes the highest across the
    auto-managed backends, matching current practice. GPU/dace backends are excluded because they are
    documented as non-deterministic; their per-backend tolerances stay hand-maintained. The recorder
    keeps per-backend data in the JSONL, so a future per-backend store schema is a compatible
    extension.
  • Tighten-only means the automated output can only make tests stricter, so it cannot mask a
    regression.
  • The RBF store keeps the same {dimension: {experiment: atol}} shape, so consumers are unchanged.

Validation (locally, real serialized data)

  • Normal assert mode unchanged: RBF datatests pass on embedded and gtfn_cpu.
  • Record mode captures correctly-keyed measurements on both backends.
  • Updater correctly no-ops on the already-tight RBF tolerances; when entries were artificially
    loosened it tightened them from the measured diffs (e.g. Vertex/gauss3d 1e-6 -> 9e-15); the
    dace_gpu sample was ignored and MCH was never loosened.
  • pre-commit (ruff/format/tach/mypy/…) is green.

Follow-ups

  • Wire more test suites into the store (e.g. standalone_driver prognostic-field tolerances once
    Add more experiments to standalone driver tests #1333 lands).
  • Add a notification (Slack) when the weekly job proposes changes — the artifact is the concrete
    deliverable; a webhook secret would let CI ping instead of relying on manual monitoring.

Draft: opening for review of the approach before wiring more test suites into the store.

jcanton and others added 3 commits July 1, 2026 19:12
Introduce a mechanism to measure numeric test tolerances instead of
asserting them, so they can be tightened automatically.

- config: ICON4PY_RECORD_TOLERANCES (record measured differences to a
  file) and ICON4PY_TOLERANCE_DRIFT_WARN (non-failing warning when a
  tolerance is much looser than the measured difference).
- tolerances: ToleranceRecorder plus helpers to aggregate measurements
  (max over deterministic CPU backends) and propose tightened tolerances.
- test_utils: dallclose/assert_dallclose record or drift-warn via shared
  helpers and take a 'key' label identifying the compared field.
- pytest_hooks: activate the recorder, attach per-test context
  (test id, backend, experiment), dump measurements as JSON lines, and
  print a drift summary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the inline RBF_TOLERANCES dict with a JSON file loaded at import
time, and label the comparisons with the dimension name so they can be
recorded. This is the pilot for the automatic tolerance-tightening
tooling; the loaded table keeps the same shape so consumers are
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- update_tolerances.py: tighten a JSON tolerance store from recorded
  measurements (tighten-only, deterministic CPU backends, max over
  backends scaled by a safety factor).
- noxfile: 'update_tolerances' session that records tolerances on the
  deterministic CPU backends and runs the updater.
- ci: a job in the weekly 'all' pipeline that runs the session and
  publishes the proposed tolerance tightenings as an artifact
  ('tolerance-updates.patch'); it never commits, so a human reviews and
  applies the change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jcanton
jcanton force-pushed the auto-tighten-tolerances branch from 951502c to 2f32a3b Compare July 2, 2026 08:55
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Mandatory Tests

Before merging, run the merge pipeline with cscs-ci run merge. Merging is blocked unless this pipeline passes.

When developing, you can test your changes on CSCS CI before merge with the default pipeline: cscs-ci run default. This will run a default subset of tests.

You can pass options to override pipeline variables, for example:

  • cscs-ci run default;BACKENDS=gtfn_cpu;LEVELS=unit
  • cscs-ci run default;MODEL_SUBPACKAGES=common:driver;SESSIONS=model
    Avoid running the pipeline for all tests when you are developing.

Available options are:

  • SESSIONS: model, model_mpi, or tools (correspond to nox sessions)
  • MODEL_SUBSETS: datatest, basic, or stencils (correspond to nox session selections)
  • MODEL_SUBPACKAGES: subpackages for non-MPI tests (last component, e.g. diffusion, standalone_driver)
  • MODEL_MPI_SUBPACKAGES: subpackages for MPI tests (as above)
  • BACKENDS: backends
  • GRIDS: grids for stencil tests (simple, icon_regional, or icon_global)
  • LEVELS: testing level for non-stencil tests (unit or integration)

For each option, all can be used as a shorthand for all possible values of that variable, e.g. LEVELS=all.

See scripts/python/generate_ci_pipeline.py and noxfile.py for available values for each option.

The all pipeline can be run with cscs-ci run all. This will run all icon4py tests in CSCS CI which can be expensive. This pipeline runs on a schedule on main, and can be run when extensive validation is needed (e.g. before releases).

Optional Tests

To run benchmarks you can use:

  • cscs-ci run benchmark-bencher

For more detailed information please look at CI in the EXCLAIM universe.

@jcanton

jcanton commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

may benefit from rel delta computation from #1333 as well

@nfarabullini nfarabullini left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some initial comments

Comment on lines +46 to +48
# Absolute tolerances for the RBF interpolation coefficients, keyed by dimension and experiment
# name. Maintained via the tolerance-tightening tooling (see 'scripts/python/update_tolerances.py');
# edit 'rbf_tolerances.json' rather than hardcoding values here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Absolute tolerances for the RBF interpolation coefficients, keyed by dimension and experiment
# name. Maintained via the tolerance-tightening tooling (see 'scripts/python/update_tolerances.py');
# edit 'rbf_tolerances.json' rather than hardcoding values here.
# Absolute tolerances for the RBF interpolation coefficients, maintained via the tolerance-tightening tooling.

Comment on lines +36 to +37
# When set to a file path, 'assert_dallclose' records the measured max absolute/relative
# differences (instead of asserting) so that tolerances can be measured and tightened automatically.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# When set to a file path, 'assert_dallclose' records the measured max absolute/relative
# differences (instead of asserting) so that tolerances can be measured and tightened automatically.

You don't need this

Comment on lines +43 to +44
# When enabled, 'assert_dallclose' emits a non-failing warning whenever a passed tolerance is much
# larger than the measured difference, flagging tolerances that have become too loose.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# When enabled, 'assert_dallclose' emits a non-failing warning whenever a passed tolerance is much
# larger than the measured difference, flagging tolerances that have become too loose.

Neither this

config.option.markexpr = " and ".join(["not datatest", *m_option])

# Activate tolerance recording/drift detection when the corresponding options are enabled.
if testing_config.RECORD_TOLERANCES_PATH is not None or testing_config.TOLERANCE_DRIFT_WARN:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if testing_config.RECORD_TOLERANCES_PATH is not None or testing_config.TOLERANCE_DRIFT_WARN:
if testing_config.RECORD_TOLERANCES_PATH or testing_config.TOLERANCE_DRIFT_WARN:

I think you can also just say this

Comment on lines +51 to +58
# A stored tolerance is flagged as drifted (too loose) when it exceeds the measured difference by
# more than this factor.
DRIFT_FACTOR = 100.0
# Proposed tolerances are the measured difference scaled by this factor to leave headroom.
SAFETY_FACTOR = 4.0
# Backends whose results are deterministic enough that measured differences may be used to tighten
# tolerances automatically. GPU and dace backends are excluded (documented as non-deterministic).
DETERMINISTIC_CPU_BACKENDS = frozenset({"gtfn_cpu", "embedded"})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you summarize comments?

Comment on lines +63 to +65
actual_array = np.asarray(actual, dtype=float)
desired_array = np.asarray(desired, dtype=float)
absolute = np.abs(actual_array - desired_array)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
actual_array = np.asarray(actual, dtype=float)
desired_array = np.asarray(desired, dtype=float)
absolute = np.abs(actual_array - desired_array)
absolute = np.abs(actual_array - desired_array)

Maybe you can dimply do this?

return _RecorderState.active


def activate_recorder() -> ToleranceRecorder:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def activate_recorder() -> ToleranceRecorder:
def activate_recorder() -> ToleranceRecorder | None:

Comment on lines +209 to +211
exponent = math.floor(math.log10(scaled))
fraction = scaled / 10.0**exponent
return math.ceil(fraction) * 10.0**exponent

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why all of this is effectively this is the same as:

return math.ceil(scaled)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants