Skip to content

fix[next]: symbolic domain union for empty domains (static case)#2677

Merged
tehrengruber merged 5 commits into
mainfrom
fix_concat_where_static_domains
Jul 2, 2026
Merged

fix[next]: symbolic domain union for empty domains (static case)#2677
tehrengruber merged 5 commits into
mainfrom
fix_concat_where_static_domains

Conversation

@tehrengruber

@tehrengruber tehrengruber commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Domain inference computes the union of two domains as the per-dimension convex hull:

union(A, B) = [ min(A.start, B.start), max(A.stop, B.stop) [

This is wrong when the inputs are empty (start >= stop). The union of two empty domains should stay empty, but min/max produces a non-empty range:

  • A = [10, 10[ (empty)
  • B = [11, 11[ (empty)
union(A, B) = [ min(10, 11), max(10, 11) [ = [10, 11[

so two empty domains wrongly union to the non-empty [10, 11[.

This shows up in practice with concat_where: a branch that is never selected produces an empty domain, and previously the empty range leaked into the min/max and over-approximated the inferred domain (#2205).

Fix

An empty domain is the identity element of union, so empty ranges are dropped before folding rather than fed into min/max (_reduce_ranges in domain_utils.py). The union of only empty domains then correctly stays empty.

Scope

This PR only handles static domains — concrete integer bounds, where emptiness is decidable. The symbolic case (runtime-sized bounds like [0, n[, where emptiness can't be decided at compile time and empty ranges must instead be guarded with conditional expressions) is considerably more involved and is left to a follow-up. A prototype implementation can be found in #2673.

@tehrengruber
tehrengruber force-pushed the fix_concat_where_static_domains branch from cc072a8 to 2a19d8a Compare July 1, 2026 09:01
@@ -69,53 +61,86 @@ def test_domain_op_preconditions():


def test_domain_union():

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These tests have a static domain now. In #2673 both cases are covered again.


actual = global_tmps.create_global_tmps(testee, offset_provider, uids=uids)
actual = ct.CollapseTuple.apply(actual, uids=uids)
actual = cf.ConstantFolding.apply(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a detail originating from the fact that empty domains are now returned as is, without constant folding.

@tehrengruber
tehrengruber requested a review from havogt July 1, 2026 12:32

This function only computes the correct value if the (non-empty) ranges are either
overlapping / adjacent or empty, as calculation is by means of the convex hull. Non-static
ranges may not be empty for now.

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.

What does "may not be empty" mean? Does it mean they are not detected as empty or that something breaks if they are not empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It breaks (in the sense that the bug that this PR addresses for static ranges occurs).

@havogt havogt 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.

lgtm

@tehrengruber
tehrengruber merged commit d651e4d into main Jul 2, 2026
24 checks passed
tehrengruber added a commit that referenced this pull request Jul 14, 2026
## Problem

`_reduce_ranges` in `domain_utils.py` is shared by `domain_union` and
`domain_intersection`:

```python
_range_union        = functools.partial(_reduce_ranges, start_reduce_op=im.minimum, stop_reduce_op=im.maximum)
_range_intersection = functools.partial(_reduce_ranges, start_reduce_op=im.maximum, stop_reduce_op=im.minimum)
```

#2677 taught it to drop statically empty ranges before folding. That is
correct for the **union**, where an empty range is the identity element,
but wrong for the **intersection**, where an empty range is the
*absorbing* element.

Dropping the empty range from an intersection returns the remaining
range instead of staying empty:

```
[1, 1[  &  [0, 10[   ->  [0, 10[     # should be empty
[1, 1[  &  [0, inf[  ->  [0, inf[    # should be empty
```

The second form is reached in practice: `promote_domain` fills
non-condition dimensions with `[-inf, inf[`, and `_infer_concat_where`
intersects that with the target domain. If the target range is
statically empty, the half-infinite range survives verbatim and an
`InfinityLiteral` leaks into the inferred domain.

## Symptoms

Both show up in ICON4Py with `concat_where` over a
statically-known-empty vertical range (a Rayleigh damping layer of zero
thickness, where the layer bound is bound as a compile-time static arg):

- **gtfn** — the leaked `InfinityLiteral` reaches
`itir_to_gtfn_ir._make_domain`:

  ```
  TypeError: 'BinaryExpr.lhs' must be <class '...gtfn_ir_common.Expr'>
(got 'InfinityLiteral.POSITIVE' which is a <class
'...iterator.ir.InfinityLiteral'>).
  ```

- **dace** — `gtir_to_sdfg_concat_where` calls
`domain_intersection(source_domain, output_domain)` directly at lowering
time, with finite bounds. No infinity, no crash: a scalar `concat_where`
branch whose range is empty (`[1, 1[`) is widened to the full output
domain (`[1, 10[`), so the constant branch is broadcast over the whole
domain and silently overwrites the real values.

The dace case is a silent wrong-results bug, so it is worth backporting
alongside the crash.

## Fix

Split the two operations. The union keeps dropping empty ranges; the
intersection returns the empty range directly. Emptiness remains
decidable only for static ranges — non-static ranges may not be empty,
as before.

Also restores the constant folding that #2677 skipped in the
single-surviving-range case.

## Testing

- Two regression tests in `test_domain_utils.py` covering the finite and
the half-infinite case. Both fail on `main`.
- `tests/next_tests/unit_tests/` A/B'd with and without the change: no
new failures.
- Verified against ICON4Py (C2SM/icon4py#1350): the gtfn `TypeError` and
the dace `diff_multfac_n2w` mismatch both disappear, and the
previously-failing GAUSS3D driver integration test passes.

Follow-up on #2677, related to #2205. The symbolic (non-static) case is
still left to #2673.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Hannes Vogt <claude@mail.havogt.de>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Till Ehrengruber <till.ehrengruber@cscs.ch>
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