Skip to content

Commit da8db4c

Browse files
committed
math: Add IntervalSet class
Add IntervalSet[T] to frequenz.core.math: a normalized, immutable set of Interval[T] values that supports O(log n) membership testing via the `in` operator. On construction, IntervalSet sorts the passed intervals by start and merges any that overlap or share an inclusive endpoint. Only the normalized form is stored, so equality, hashing, and iteration are well-defined regardless of input order; callers that need the original inputs can keep them separately. Unbounded intervals are handled via the existing Interval[T] convention: a None start is treated as -infinity and a None end as +infinity during sort/merge, so for example IntervalSet((Interval(None, 5), Interval(3, None))) collapses to a single Interval(None, None). The generic container-of-containers variant discussed during design is deferred: there is no immediate use case, and adding IntervalSet alone is a fully additive step that does not foreclose a future generic type in a separate module. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
1 parent 7999dfe commit da8db4c

3 files changed

Lines changed: 446 additions & 4 deletions

File tree

RELEASE_NOTES.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Summary
44

5-
<!-- Here goes a general summary of what this release is about -->
5+
This release updates the `Interval` type to no longer include `None` in its type parameter, and introduces a new `IntervalSet` type to the `frequenz.core.math` module, which allows for efficient membership testing of normalized sets of intervals.
66

77
## Upgrading
88

@@ -18,8 +18,19 @@
1818

1919
## New Features
2020

21-
<!-- Here goes the main new features and examples or instructions on how to use them -->
21+
- Add [`IntervalSet`][frequenz.core.math.IntervalSet] to [`frequenz.core.math`][frequenz.core.math], a normalized set of [`Interval`][frequenz.core.math.Interval] values with `O(log n)` membership testing.
2222

23-
## Bug Fixes
23+
Overlapping or touching intervals are merged on construction, and `None` bounds (`-∞` / `+∞`) are handled during merging.
2424

25-
<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
25+
Useful for allow/forbid-list style checks that previously required iterating a `Sequence[Interval]`:
26+
27+
```python
28+
from frequenz.core.math import Interval, IntervalSet
29+
30+
allowed = IntervalSet(
31+
(Interval(1, 5), Interval(3, 10), Interval(15, 20))
32+
)
33+
assert tuple(allowed) == (Interval(1, 10), Interval(15, 20))
34+
assert 7 in allowed
35+
assert 12 not in allowed
36+
```

src/frequenz/core/math.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""Math tools."""
55

66
import math
7+
from collections.abc import Iterable, Iterator
78
from dataclasses import dataclass
89
from typing import Generic, Protocol, Self, TypeVar
910

@@ -106,3 +107,190 @@ def __str__(self) -> str:
106107
start = "∞" if self.start is None else str(self.start)
107108
end = "∞" if self.end is None else str(self.end)
108109
return f"[{start}, {end}]"
110+
111+
112+
@dataclass(frozen=True, repr=False)
113+
class IntervalSet(Generic[LessThanComparableT]):
114+
"""A normalized set of intervals for efficient membership testing.
115+
116+
An [`IntervalSet`][.] represents the union of a collection of
117+
[`Interval`][..Interval] values. On construction, the passed intervals are sorted
118+
by their start and any overlapping or touching intervals are merged into one, so
119+
the stored [`.intervals`][.] are canonical: sorted by start and pairwise
120+
non-overlapping.
121+
122+
Membership testing via the `in` operator is `O(log n)` on the number of stored
123+
intervals (after normalization), using binary search on the interval starts.
124+
125+
Only the normalized form is stored; the original input order and any redundant
126+
intervals are discarded. Two [`IntervalSet`][.] instances constructed from
127+
different input sequences that represent the same union of values are equal and
128+
hash the same.
129+
130+
Note: Unbounded intervals
131+
[`Interval`][..Interval] supports `None` as the start (meaning -∞) or the end
132+
(meaning +∞). [`IntervalSet`][.] handles these correctly during merging: for
133+
example, the set `{Interval(None, 5), Interval(3, None)}` normalizes to
134+
`{Interval(None, None)}` (the whole comparable space). `None` is a bound
135+
marker only; it is never a value in the set.
136+
137+
Example:
138+
```python
139+
from frequenz.core.math import Interval, IntervalSet
140+
141+
allowed = IntervalSet(
142+
(Interval(1, 5), Interval(3, 10), Interval(15, 20))
143+
)
144+
assert tuple(allowed) == (Interval(1, 10), Interval(15, 20))
145+
assert 7 in allowed
146+
assert 12 not in allowed
147+
```
148+
"""
149+
150+
intervals: tuple[Interval[LessThanComparableT], ...] = ()
151+
"""The normalized intervals in this set: sorted by start and non-overlapping."""
152+
153+
def __post_init__(self) -> None:
154+
"""Normalize the passed intervals by sorting and merging overlapping ones."""
155+
object.__setattr__(self, "intervals", _sort_and_merge(self.intervals))
156+
157+
def __contains__(self, item: LessThanComparableT) -> bool:
158+
"""Check whether the value is within any interval of this set.
159+
160+
Args:
161+
item: The value to check.
162+
163+
Returns:
164+
Whether `item` is within any interval of this set.
165+
"""
166+
if not self.intervals:
167+
return False
168+
169+
# Binary search for the rightmost interval whose start is `<= item`. A `None`
170+
# start means -∞, which is always `<= item`.
171+
lo, hi = 0, len(self.intervals)
172+
while lo < hi:
173+
mid = (lo + hi) // 2
174+
start = self.intervals[mid].start
175+
if start is None or not item < start:
176+
lo = mid + 1
177+
else:
178+
hi = mid
179+
180+
idx = lo - 1
181+
return idx >= 0 and item in self.intervals[idx]
182+
183+
def __iter__(self) -> Iterator[Interval[LessThanComparableT]]:
184+
"""Iterate over the normalized intervals in ascending order of start."""
185+
return iter(self.intervals)
186+
187+
def __len__(self) -> int:
188+
"""Return the number of intervals in the normalized form."""
189+
return len(self.intervals)
190+
191+
def __repr__(self) -> str:
192+
"""Return a string representation of this instance."""
193+
return f"IntervalSet({self.intervals!r})"
194+
195+
def __str__(self) -> str:
196+
"""Return a string representation of this instance."""
197+
if not self.intervals:
198+
return "∅"
199+
return " ∪ ".join(str(iv) for iv in self.intervals)
200+
201+
202+
def _sort_and_merge(
203+
intervals: Iterable[Interval[LessThanComparableT]],
204+
) -> tuple[Interval[LessThanComparableT], ...]:
205+
"""Sort intervals by start and merge overlapping or touching ones.
206+
207+
A `None` start is treated as -∞ and a `None` end as +∞. Intervals are inclusive
208+
on both bounds, so `[1, 5]` and `[5, 10]` are considered touching and merged
209+
into `[1, 10]`.
210+
211+
Args:
212+
intervals: The intervals to normalize.
213+
214+
Returns:
215+
A tuple of sorted, pairwise non-overlapping intervals covering the same
216+
values as the input.
217+
"""
218+
all_ivs = list(intervals)
219+
if not all_ivs:
220+
return ()
221+
222+
# Left-unbounded intervals (start is None) can't be sorted alongside real starts,
223+
# so pre-merge them into at most one interval spanning -∞ up to the largest end.
224+
# Pair the real-start intervals with their (narrowed) non-None start so the sort
225+
# key is directly typed as `T` — no casts needed.
226+
with_none_start: list[Interval[LessThanComparableT]] = []
227+
with_real_start: list[tuple[LessThanComparableT, Interval[LessThanComparableT]]] = (
228+
[]
229+
)
230+
for iv in all_ivs:
231+
if iv.start is None:
232+
with_none_start.append(iv)
233+
else:
234+
with_real_start.append((iv.start, iv))
235+
236+
with_real_start.sort(key=lambda pair: pair[0])
237+
ordered_real = [pair[1] for pair in with_real_start]
238+
239+
initial: list[Interval[LessThanComparableT]] = []
240+
if with_none_start:
241+
if any(iv.end is None for iv in with_none_start):
242+
initial.append(Interval(None, None))
243+
else:
244+
non_none_ends = [iv.end for iv in with_none_start if iv.end is not None]
245+
initial.append(Interval(None, max(non_none_ends)))
246+
247+
ordered = initial + ordered_real
248+
249+
result: list[Interval[LessThanComparableT]] = [ordered[0]]
250+
for current in ordered[1:]:
251+
last = result[-1]
252+
if _end_covers_start(last.end, current.start):
253+
new_end = _max_end(last.end, current.end)
254+
result[-1] = Interval(last.start, new_end)
255+
else:
256+
result.append(current)
257+
258+
return tuple(result)
259+
260+
261+
def _end_covers_start(
262+
end_val: LessThanComparableT | None,
263+
start_val: LessThanComparableT | None,
264+
) -> bool:
265+
"""Return whether `end_val >= start_val`, treating `None` as ±∞.
266+
267+
Args:
268+
end_val: An interval end, where `None` means +∞.
269+
start_val: An interval start, where `None` means -∞.
270+
271+
Returns:
272+
Whether `end_val >= start_val` under the ±∞ convention.
273+
"""
274+
if end_val is None:
275+
return True
276+
if start_val is None:
277+
return True
278+
return not end_val < start_val
279+
280+
281+
def _max_end(
282+
a: LessThanComparableT | None,
283+
b: LessThanComparableT | None,
284+
) -> LessThanComparableT | None:
285+
"""Return the larger of two interval ends, where `None` means +∞.
286+
287+
Args:
288+
a: An interval end.
289+
b: Another interval end.
290+
291+
Returns:
292+
The larger of `a` and `b` under the +∞ convention.
293+
"""
294+
if a is None or b is None:
295+
return None
296+
return b if a < b else a

0 commit comments

Comments
 (0)