intervals & progressions: name the boundary, get Allen's algebra, stop re-inventing the for loop
Every library in the world has a range type. None of them
agree on what a range is. Most of them only support one or
two of the four boundary semantics, and almost none of them
ship the algebra that makes ranges useful. So I shipped one
Java pair that does — and discovered, on the way, that the
for loop has always been a value.
Two ideas. Intervals describe a continuous region of an ordered domain: "is this temperature reading inside the safe operating range?" Progressions describe a discrete sequence stepped through that domain: "every fifth pound from 5 to 100 on the dumbbell rack." They are two of the most basic shapes in software, and we have spent fifty years re-implementing them, badly, in every language and every library.
What follows is the engineering. The argument underneath it is older — Allen's interval algebra is from 1983, the off-by-one bug is from before that, the "is this number in this range?" question goes back to the first time a human sorted two grains of wheat.
The same library, six different ways
There are, modulo bikeshedding, exactly four ways an interval over an ordered set can decide what it contains at its boundaries:
- Closed
[a, b]— both endpoints included. - Open
(a, b)— both endpoints excluded. - Left-closed, right-open
[a, b)— the canonical Dijkstra half-open, the subject of EWD831. - Left-open, right-closed
(a, b]— the half-open you reach for when measuring elapsed time ("the interval since the last tick").
Pick any mainstream language or framework that ships a range / interval type and watch it commit to a subset, with its own algebra (or none), in glorious incompatibility with every other one:
| Library | Type | Boundary semantics | Algebra |
|---|---|---|---|
Joda-Time Interval |
time only | only [a, b) — hard-coded |
overlaps, abuts, contains — ad hoc |
JSR-310 java.time |
time only | no interval type at all — explicitly cut | — |
ThreeTen-Extra Interval / LocalDateRange |
time only | [a, b) |
encloses, abuts, isConnected, overlaps |
Guava Range<C extends Comparable> |
any Comparable |
all four (via BoundType on each end) |
encloses, isConnected, intersection, span |
Apache Commons Lang Range |
any Comparable |
closed only | containsRange, isOverlappedBy, intersectionWith |
Spring Data Range |
any Comparable |
all four (via Bound on each end) |
contains only |
Kotlin ClosedRange / OpenEndRange |
any Comparable |
closed and [a, b) — no (a, b], no open-open |
contains, isEmpty |
Rust Range, RangeInclusive, RangeFrom, RangeTo, RangeToInclusive, RangeFull |
any PartialOrd |
six(!) distinct types — closed, half-open, unbounded variants | contains only |
C++20 std::ranges |
iterator pairs | iterator-semantic [begin, end) |
views & adaptors, not relations |
The pattern is unmistakable. Each library
tightened the previous mess — Joda-Time was a
real upgrade over java.util.Date; Guava's
Range was a real upgrade over rolling your
own with raw Comparable; Rust's six-type
split is a real upgrade over Kotlin's two. But each
library still picks a different subset, ships a
different algebra, and refuses to compose with any
other. A Joda Interval cannot answer
"does this contain that Guava Range?"
because they don't share a single common ancestor below
Object. Most damning of all,
JSR-310
— the standardization of Joda-Time that became
java.time in Java 8 — explicitly removed
the interval type. Stephen Colebourne, the same
author who wrote Joda-Time's Interval,
decided the design didn't generalize and shipped the
smaller surface. Four years later he added the type
back to
ThreeTen-Extra,
because users needed it, with subtly different
semantics. The library has been re-implemented, at
cross purposes, by some of the most rigorous engineers
in our field.
And the boundary mistake that motivates all of this
keeps shipping. The most-cited
off-by-one
bug in software has the same shape every time: the
author wrote i <= n when they meant
i < n, or made the same swap in their
head when they wrote start < x && x <=
end instead of start <= x && x <=
end. Naming the boundary semantics at the
type level — ClosedInterval versus
LeftClosedRightOpenInterval — is the
oldest known fix for it, and we still don't do it.
The story is the same outside of dates. The most common piece of validation code in any business backend is some flavor of
if (percent < 0 || percent > 100) throw new IllegalArgumentException(...);
if (cents < 0) throw new IllegalArgumentException(...);
if (page < 1 || page > total) throw new IllegalArgumentException(...);
if (!ALLOWED_CHARS.contains(c)) throw new IllegalArgumentException(...);
if (name.compareTo("A") < 0 || name.compareTo("M") > 0) ...
Every one of those is an interval check. The author wrote it longhand because the language gave them no better way; the next person to touch the code can't tell from the signature which boundary semantics were intended; the bug-tracker accumulates tickets where the boundary was inclusive in one place and exclusive in another. None of this is new. We have known how to fix it for forty years. We just keep not fixing it.
Name the boundary; type the variant
intervals ships four interfaces — one per
boundary semantic — each with a contains(value)
default that encodes its inclusivity. You pick the
variant that matches your intent; the membership test
is the variant's problem, not yours.
| Type | Notation | contains(v) body |
|---|---|---|
ClosedInterval<T> |
[a, b] |
start <= v AND v <= end |
OpenInterval<T> |
(a, b) |
start < v AND v < end |
LeftClosedRightOpenInterval<T> |
[a, b) |
start <= v AND v < end |
LeftOpenRightClosedInterval<T> |
(a, b] |
start < v AND v <= end |
The bodies are one line each. That isn't the point.
The point is that the type tells the reader
which one was meant, the
TEMPLATE METHOD
default tells the compiler, and the variant you pick
propagates through every consumer. A scheduling
function declared
void book(ClosedInterval<Instant> window)
cannot accidentally be called with a half-open one.
The package-private umbrella
There is one more shape — a package-private
Interval<T> base that the four public
variants all extend, and that nobody outside the
package can name. This is deliberate. Read it from
the library's own
module-info:
Not public because it usually adds conceptual clarity to specify which kind of interval is in hand — open, closed, or half-open — rather than the ambiguous union.
That paragraph is the most honest API-design decision
in the library. If consumers could write
Interval<Integer> they would, and the
boundary semantics that the whole library exists to
name would get smuggled back into ambiguity. Making
the umbrella package-private means callers always pick
a specific variant. The base earns its keep as the
home of the Allen-algebra defaults; it does not earn
its keep as a thing you can declare a parameter of.
Any totally-ordered domain — numbers, characters, words
The T in every interval is bounded by
Compare<? super T>, the framework's
own
comparisons
protocol — the same idea as
java.lang.Comparable, but returning a
Comparison sum type (lessThan / equalTo /
greaterThan) instead of an integer with arithmetic
gotchas. Any type that can be ordered fits — boxed
numerics ship as the
Compared
adapter, and your own domain types fit in three lines.
// numbers — the built-in Compared<N extends Number & Comparable<N>> adapter
record SafePercent(Compared<Integer> start, Compared<Integer> end)
implements ClosedInterval<Compared<Integer>> {}
final SafePercent opacity = new SafePercent(Compared.of(0), Compared.of(100));
opacity.contains(Compared.of(42)); // Bit.ONE
opacity.contains(Compared.of(150)); // Bit.ZERO
// characters — a four-line Compare<Letter> lift
record Letter(char value) implements Compare<Letter> {
@Override public Comparison to(final Letter o) {
return Comparison.fromInt(Character.compare(value, o.value()));
}
}
record AtoM(Letter start, Letter end) implements ClosedInterval<Letter> {}
new AtoM(new Letter('A'), new Letter('M')).contains(new Letter('K')); // Bit.ONE
// words — same lift, same shape, lexicographic order
record Word(String value) implements Compare<Word> {
@Override public Comparison to(final Word o) {
return Comparison.fromInt(value.compareTo(o.value()));
}
}
record DictionaryRange(Word start, Word end) implements LeftClosedRightOpenInterval<Word> {}
new DictionaryRange(new Word("apple"), new Word("banana"))
.contains(new Word("avocado")); // Bit.ONE — avocado falls in [apple, banana)
Three records, three different value types, three
different boundary semantics — same library, same
contains default, same algebra (coming
next). The page-table validator, the dictionary range
in a B-tree query, the 'A'..'M' bucket in
a name-sorter — none of those have to invent their
own range check anymore. The boundary semantics live
in the type. The algebra lives on the interface. The
domain-specific arithmetic — and only the domain-specific
arithmetic — lives in the consumer's record.
Every method on Bounded<T> and every
Allen relation returns
Bit
instead of boolean, so results compose
directly with the framework's boolean-pipeline
machinery without unboxing — contains chains
with and, or, not
without the JDK boolean[] dance.
Allen's algebra, free with every interval
In 1983, James F. Allen published Maintaining Knowledge About Temporal Intervals in Communications of the ACM 26(11):832–843. He proved that there are exactly thirteen mutually-exclusive, jointly-exhaustive relations that can hold between two intervals on a linear order:
| Method | Allen notation | Holds when |
|---|---|---|
precedes | p | end < other.start |
precededBy | P | other.end < start |
meets | m | end == other.start |
metBy | M | other.end == start |
overlaps | o | start < other.start < end < other.end |
overlappedBy | O | other.start < start < other.end < end |
starts | s | start == other.start AND end < other.end |
startedBy | S | start == other.start AND other.end < end |
during | d | other.start < start AND end < other.end |
contains(Bounded) | di | start < other.start AND other.end < end |
finishes | f | other.start < start AND end == other.end |
finishedBy | F | start < other.start AND end == other.end |
equals(Bounded) | e | start == other.start AND end == other.end |
That paper has been cited tens of thousands of times.
It is the foundational result for temporal reasoning
in AI; it underlies the W3C Time Ontology; it
is taught in undergraduate Knowledge Representation;
it is, in plain English, the dictionary of every
possible way two intervals can be arranged on a line.
And yet, of all the libraries in the parade above,
not one of them ships all thirteen.
Joda-Time has four. ThreeTen-Extra has four. Guava
has three. Spring has one (contains).
Kotlin has one. Allen wrote the answer down in 1983
and the industry never opened the book.
intervals opens it. Every interval
variant inherits all thirteen relations as default
methods — one line each, each grounded in
start() and end()
comparisons with no further state to manage. Here is
the entire body of Allen's overlaps:
@PostCondition(IsNotNull.class)
default Bit overlaps(@PreCondition(IsNotNull.class) final Bounded<T> other) {
return start().lessThan(other.start())
.and(other.start().lessThan(end()))
.and(end().lessThan(other.end()));
}
Note the
@PreCondition
and @PostCondition from
contrax — both libraries lean on the
compile-time guarantee that the algebra never sees a
null, while the runtime stays free of defensive checks.
Four cohesive mixins, one interface umbrella
The thirteen relations are split across four cohesive
mixin interfaces grouped by topology — each one obeys
the
Interface
Segregation Principle on its own, and the
package-private Interval<T>
umbrella composes all four for variants that want the
whole algebra:
| Mixin | Topology | Methods |
|---|---|---|
Adjacency<T> |
share at most 1 point | precedes / precededBy / meets / metBy |
Overlap<T> |
interiors overlap, neither contains the other | overlaps / overlappedBy |
ProperContainment<T> |
one strictly inside the other (no shared endpoint) | during / contains(Bounded) |
SharedBoundary<T> |
at least one endpoint coincides | starts / startedBy / finishes / finishedBy / equals(Bounded) |
A scheduling routine that only needs to know
whether two windows abut declares its parameter as
Adjacency<T>, not
Interval<T>. A containment validator
depends on ProperContainment<T>. A
merge-overlapping-windows pass over a sorted stream
depends on Overlap<T>. ISP gives
every consumer exactly the surface area it asked for,
and no more.
The cross-shape reach
Every Allen relation takes
Bounded<T> — the minimal
start/end/contains(value)
surface — as its other parameter, not a
specific concrete variant. That means a closed
interval can ask whether an open one starts during it;
a discrete progression (next section) can ask whether
it precedes a continuous interval; a third-party
Bounded implementation that doesn't even
know about this library can be compared against any of
them. The relations are structural, the way
Allen wrote them in 1983.
final ClosedInterval<Compared<Integer>> morning = /* [09:00, 12:00] */;
final ClosedInterval<Compared<Integer>> afternoon = /* [13:00, 17:00] */;
final ClosedInterval<Compared<Integer>> lunch = /* [12:00, 13:00] */;
morning.precedes(afternoon); // Bit.ONE — there is a gap between them
morning.meets(lunch); // Bit.ONE — end of morning == start of lunch
lunch.during(/* whole workday */); // Bit.ONE — lunch sits strictly inside
Three calls, three of Allen's thirteen relations,
zero defensive guards in your code, zero room for the
author to have written < instead of
<=. The 1983 paper has been compiled
into the type system.
From continuous to discrete: why progressions exist at all
Before the for-loop punchline, a small
confession about how this library got here. I started
wanting to make Interval<T>
implement
Traversable<T>.
It felt obvious: an interval has a start and an end and
an ordering, so just walk it. I sat down to write
traverse() on
ClosedInterval<Compared<Double>>
and stopped halfway through, because the type was
about to lie.
The mathematical interval [0, 1] over the
real numbers contains uncountably many
points. Cantor proved it in
1891:
no enumeration of the reals exists. traverse()
is, by its signature, an enumeration —
"give me the next element, and the next, and the next."
Putting traverse() on
Interval<T> would be a type-level
promise that any interval can be enumerated. That is
false for [0, 1] on the reals; it is
false for any continuous interval; it is true only
when the underlying domain is
discrete,
which the type bound T extends Compare<? super T>
does not guarantee.
The fix is not to add a runtime check. The fix is to
introduce a second type that means
"this is the discrete case." Discreteness, in
practice, is exactly the existence of a step
— a primitive arithmetic operation
(advance / retreat) that
moves from one element to the next without skipping
any. The continuous interval [0, 1] has
no honest step: there is no
next real number after 0. The
discrete progression
{0, 0.1, 0.2, …, 1.0} does — its step is
0.1. The step parameter is not a feature
bolted onto a progression; it is the witness
that the value type is being treated discretely, and
therefore that enumeration is legitimate.
That is why intervals stays continuous-
and discrete-capable while progressions
is the one that ships Traversable. The
boundary semantics still belong on the interval side —
a closed progression is a closed interval that happens
to also be steppable. The Allen algebra still belongs
on the interval side — relating two intervals on a
linear order doesn't care whether either of them is
countable. What progressions add — and the
only thing they add — is the step that turns
an abstract interval into a sequence you can actually
walk. The type system tells the user, at every
signature, which they have in hand.
Progressions: the for loop was always a value
Pause for a second over this line of code that you have written ten thousand times:
for (int i = 0; i < n; i++) { ... }
Three values — start = 0,
end = n, step = 1 — plus a
boundary semantic (i < n, so
right-open) and a direction (ascending). The triple
fully determines the sequence 0, 1, 2, …, n-1.
It is a value — describable,
copyable, composable, comparable. It has been a value
the entire time. Java just made you write it as
syntax.
Kotlin
noticed. IntRange is literally
defined as
class IntRange : IntProgression, ClosedRange<Int>
— the discrete range is both a progression and a
closed interval, an
intersection
type at the class declaration. The Lisp and APL
people noticed earlier still —
Iverson's
APL ⍳n produces the integer sequence
as a first-class value, in 1962. Mainstream Java is
sixty-three years late.
progressions finally writes it down. A
Progression<T> is a value that
fully describes a discrete sequence — same four
boundary semantics as intervals, with an added
step() and the arithmetic to move along
it:
public sealed interface Progression<T extends Compare<? super T>>
extends Bounded<T>, Traversable<T>
permits Progression.Closed, Progression.Open,
Progression.LeftClosedRightOpen, Progression.LeftOpenRightClosed,
AbstractProgression {
T step(); // the increment between successive values
T advance(T current); // step forward by step()
T retreat(T current); // step backward by step()
// Each variant intersection-types with the matching *Interval — so a Progression IS-AN interval.
non-sealed interface Closed<T extends Compare<? super T>>
extends Progression<T>, ClosedInterval<T> {}
non-sealed interface Open<T extends Compare<? super T>>
extends Progression<T>, OpenInterval<T> {}
non-sealed interface LeftClosedRightOpen<T extends Compare<? super T>>
extends Progression<T>, LeftClosedRightOpenInterval<T> {}
non-sealed interface LeftOpenRightClosed<T extends Compare<? super T>>
extends Progression<T>, LeftOpenRightClosedInterval<T> {}
}
Read the four non-sealed inner interfaces
carefully. Each one extends both
Progression<T>
and the matching *Interval<T>.
That's how a discrete sequence picks up the entire
13-relation Allen algebra without re-deriving it —
the boundary-aware contains(value) default
comes from the interval side, every relation
(precedes, meets,
during, …) inherits from the algebra
mixins via the package-private Interval
umbrella, and the progression side adds
step / advance /
retreat / traverse on top.
You can ask whether two progressions overlap without
ever realizing either sequence.
Off the screen, hiding everywhere
Once you have the type, you start seeing progressions in places where you used to see "obvious" three-number tuples that the author meant to be a sequence.
-
The for loop.
for (int i = 0; i < n; i++)isLeftClosedRightOpenProgression(0, n, 1).for (int i = n - 1; i >= 0; i--)is the same value, walked viadescending(). -
Dumbbells on a rack. Every
commercial gym rack is a closed progression: 5
lb, 10 lb, 15 lb, …, 100 lb. The trainer who
tells you "work up by fives to your eight-rep
max" is reading off
ClosedProgression(5lb, 100lb, 5lb). Some racks step by 2.5 lb at the bottom and 5 lb in the middle — that is a sealed family of two progressions stitched together at a shared boundary, which the Allenmeetsrelation answers in a single call. -
Speedometers and gauges. Your car's
speedometer is
ClosedProgression(0 mph, 120 mph, 1 mph)for the minor ticks andClosedProgression(0 mph, 120 mph, 10 mph)for the major ones — a closed progression nested inside another, sharing thestartandend. A thermometer's gradations, a fuel gauge's eighths, the kilogram increments on a kitchen scale, the click-wheel on a digital camera's exposure compensation — every measurement instrument that reads in steps is a progression. "What's the next finer scale?" is a step change. "Is this reading admissible?" iscontains. - Volume knobs, brightness sliders, BPM dials. Anywhere a user picks one of a discrete set of evenly-spaced values, you have a progression with the UI affordance pretending it is continuous.
-
Audio buffer sizes, jitter buffers, page
sizes, ZFS record sizes. Powers of two,
from 64 to 8192, are a geometric
progression — same shape, multiplicative step. The
current API takes an arithmetic step; lifting it
to a geometric one is a custom
advance/retreaton the sameAbstractProgression, because arithmetic is the leaf, structure is the base. -
Retail clothing sizes. XS, S, M,
L, XL, XXL, XXXL is a progression over an
ordered enum. Spec the value type as
Compare<Size>whereSizeorders by ordinal, and "do we carry the next size up from this one?" isadvance. -
Tax brackets, billing tiers, age
bands. Every progressive tax code is a
sealed family of intervals back-to-back with
shared boundaries; "which bracket does this
income fall in?" is a stream of
contains(value)calls. The bracket edges are themselves a progression of currency amounts.
None of these are intervals or progressions in a metaphorical sense. They are intervals and progressions in the strict mathematical sense, and every program that touches one of them is silently re-implementing the same three fields plus the same four boundary checks plus the same step arithmetic. Once you have the type, you delete that boilerplate.
Lazy specification
Critically, a Progression is a
specification, not a realization. Writing
new ClosedProgression<Val>(new Val(1), new Val(11), new Val(2)) { ... }
fully determines the sequence
(1, 3, 5, 7, 9, 11) — but allocates
nothing. No array, no list, no
Iterable. The sequence comes alive only
when you call traverse(),
ascending(), or descending(),
each of which returns a
Traversal<T>
that streams elements one at a time. You can describe
a progression of every nanosecond from 1970 to 2070
without your JVM dying.
This is the same architectural seam that
java.util.stream.IntStream.range(0, n)
promises — but the JDK stream is a one-shot pipeline,
not a re-traversable value. The Visionary progression
is the value; you can walk it forward and backward,
compare two of them via Allen, hand one to a
function that doesn't realize it, and never pay for
the elements you don't visit.
State in the base; arithmetic in the leaves
Every progression needs the same three fields
(start, end,
step) with the same null checks; only
advance and retreat vary by
value type. Bert Meyer would call that a
TEMPLATE
METHOD, and so
AbstractProgression<T> is exactly
that — sealed to four shape-specific bases
(ClosedProgression,
OpenProgression,
LeftClosedRightOpenProgression,
LeftOpenRightClosedProgression), each
non-sealed so consumers can extend them.
Extending costs three lines of arithmetic; the rest
is inherited:
record Val(int value) implements Compare<Val> {
@Override public Comparison to(final Val other) {
return Comparison.fromInt(Integer.compare(value, other.value()));
}
}
ClosedProgression<Val> zeroToFour = new ClosedProgression<>(
new Val(0), new Val(4), new Val(1)) {
@Override public Val advance(final Val current) {
return new Val(current.value() + step().value());
}
@Override public Val retreat(final Val current) {
return new Val(current.value() - step().value());
}
};
Traversal<Val> up = zeroToFour.ascending(); // streams 1, 2, 3, 4
Traversal<Val> down = zeroToFour.descending(); // streams 3, 2, 1, 0
zeroToFour.contains(new Val(0)); // Bit.ONE — closed includes endpoints
zeroToFour.precedes(otherProgression); // Allen, inherited via ClosedInterval
There is no default advance /
retreat. There can't honestly be
one for an arbitrary Compare-comparable
type — what would the default be for
BigDecimal with monetary rounding rules?
For a Geohash? For an enum of
clothing sizes? A wrong default is worse than no
default. The leaves do their own arithmetic; the
base hands them everything else.
Future work: why not intersection?
Or maybe union?
The most natural follow-up to Allen's
"do these two overlap?" is
"well, what's the overlap?" — and the
article you are reading does not yet ship the
answer. The general-purpose Java range libraries do:
Guava's
Range
ships intersection, Apache Commons Lang's
Range
ships intersectionWith, Joda-Time's
Interval ships
overlap (the same thing), and
ThreeTen-Extra
ships intersection outright. The
absence of an equivalent in intervals
today is a real gap that will be filled in a minor
release.
Much more interesting is what those libraries
deliberately do not ship. Guava's
Range has span — the
smallest range enclosing both inputs, which equals
their union when the two ranges are connected
and over-approximates when they aren't — and
gap, the empty region between two
disjoint ranges. It does not ship
union, complement, or
difference on Range at all;
those are routed, by design, to a separate type,
RangeSet.
ThreeTen-Extra is sharper still: its
union method returns
Optional<Interval> and succeeds
only when the two intervals are connected — i.e.
span renamed to union and
constrained to its honest case. Commons Lang's
Range has only
intersectionWith and a containment
family; no union of any flavor, no
complement, no difference. Joda's
Interval has overlap and
gap; same. The pattern across thirty
years of independent Java interval-library design
is unanimous: the operations that ship on
the interval type are the ones that stay inside the
interval type. Union without qualification,
complement, and difference are not among them, in
any of those libraries. Those are not a Visionary
oversight; they are a decision every
interval-library author quietly makes when they sit
down to write one.
What intervals does differently is make
the decision explicit, and explain it from
first principles rather than leave it as a curious
gap in the Javadoc. The natural impulse, when you
are building one of these libraries, is to keep
stapling operations onto the interval type —
"obviously intervals should know how to take the
union of two time windows" — and you have to
actively notice that the type is no longer closed
under what you are about to add. Guava's authors
noticed (and shipped span instead of
unconditional union). The ThreeTen-Extra
author noticed (and made union partial
via Optional). Joda's team noticed.
Commons Lang's team noticed. None of them wrote down
why. The convexity argument, the topology
pedigree, and the layered architecture below are
that why — the explanation underneath all
of those private design moments.
The convexity argument
An interval isn't just a set with an ordering — it is
a convex subset of a
linearly
ordered (a.k.a. totally ordered) domain. If
a and b are in the
interval, every c with a ≤ c ≤ b
is too. No gaps. That single property is what makes
Allen's algebra well-defined, and it is exactly the
property that the four set operations preserve or
break:
- Intersection of two intervals stays convex → the result is always an interval (or empty). Closed under the operation.
-
Union of two intervals can
break convexity →
[0, 1] ∪ [3, 4]is not an interval. -
Complement of an interval
always breaks convexity →
R \ [0, 1] = (-∞, 0) ∪ (1, +∞). -
Difference of two intervals
can break convexity →
[0, 5] \ [2, 3] = [0, 2) ∪ (3, 5].
So intersection earns its place inside
intervals — the type is closed under it.
The other three honestly cannot return an
Interval<T> without lying about the
math. They escape into a richer type. That is the
precise moment the abstraction crosses from
convex region into general subset of a
totally ordered domain. Different abstraction,
different library.
The topology pedigree
The words "open" and "closed" on interval boundaries
aren't algebra — they are
topology.
In the standard topology on R, an open
set is exactly a union of open intervals; a closed
set is the complement of an open one. The boundary
semantics in this library are inherited from that
topological pedigree, and the same pedigree justifies
the layer separation: as soon as you take unions or
complements, you have left the realm of
individual intervals and entered the realm
of measurable / topological
subsets that intervals happen to generate.
The natural Boolean-algebra closure of those subsets
— finite disjoint unions of intervals — is literally
the canonical first example in
measure
theory before completing to Borel sets. The
mathematicians solved this factoring problem decades
ago; the software just has to ship the types.
The layered architecture this implies
Pulling on that thread gives a clean five-library
decomposition — a three-layer trunk
(sets → ordered-sets →
intervals) with intervals
branching into two sibling specializations
(interval-sets for the
Boolean-algebra closure, progressions
for the discrete-enumeration closure). The published
libraries today are intervals and
progressions; sets,
ordered-sets, and
interval-sets are explicitly planned,
deferred until a real consumer asks for them.
┌─────────────────────────────────────────────────────────────────────────┐
│ sets — general element-membership operations on any unordered Set<T> │
│ • union / intersection / difference / complement / symmetricDifference│
│ • subset / superset / disjoint / cardinality │
│ • LAZY VIEWS, immutable — the Guava-Sets ergonomics Java should ship │
└─────────────────────────────────────────────────────────────────────────┘
▲
│ specializes T → T extends Compare<? super T>
│
┌─────────────────────────────────────────────────────────────────────────┐
│ ordered-sets — adds order-theoretic operations │
│ • min / max / successor / predecessor │
│ • infimum / supremum (lattice operations) │
│ • ascending() / descending() Traversal │
│ • bounds() : Maybe<Interval<T>> when the set is convex │
└─────────────────────────────────────────────────────────────────────────┘
▲
│ specializes "convex" + "named boundary semantics"
│
┌─────────────────────────────────────────────────────────────────────────┐
│ intervals — TODAY: convex ordered sets + Allen's algebra │
│ • Closed / Open / LeftClosedRightOpen / LeftOpenRightClosed │
│ • 13-relation Allen algebra (does not depend on set ops at all) │
│ • intersection : Bounded × Bounded → Maybe<Interval> ← legitimate │
└─────────────────────────────────────────────────────────────────────────┘
▲ ▲
│ finite disjoint │ adds a step
│ unions │ (discrete enumeration)
│ │
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ interval-sets │ │ progressions — TODAY │
│ Boolean algebra over │ │ convex + discrete + │
│ intervals (≈ RangeSet) │ │ steppable │
│ • finite disjoint unions │ │ • step / advance / retreat │
│ of intervals │ │ • traverse / ascending / │
│ • CLOSED under union / │ │ descending │
│ intersection / difference / │ │ • inherits Allen algebra via │
│ complement │ │ intersection-typing with │
│ • symmetricDifference │ │ the four *Interval variants │
│ • auto-coalescing on insert: │ │ │
│ [0,1] ∪ [1,2] → [0,2] │ │ │
└─────────────────────────────────┘ └─────────────────────────────────┘
The general lesson — specialized vs. generalized impulse-routing
The reason this matters beyond the specific libraries
is that the impulse to staple set-theoretic
operations onto an interval type is one instance of
a much larger family of impulses, each of which has
broken type-domains throughout the long-running
history of our field. A specialized type attracts
operations that sound like they belong but
actually generalize to a broader type, and packing
the generalized behavior into the specialized one is
how you grow
god
objects. The canonical JDK case is
java.util.Date, which absorbed timezone
knowledge, parsing, formatting, calendar arithmetic,
and mutation — five separate concerns the type was
never about — and remains a textbook example of the
failure in every modern Java curriculum. The interval
libraries surveyed above mostly avoided that trap on
the set-operations axis; java.util.Date
is what it looks like when nobody catches the
impulse.
Recognizing the impulse — "this method I'm about
to add has the same name as an operation from a
different branch of mathematics" — and routing
it to the right abstraction is the work. The names
are doing the teaching. union,
intersection, complement,
difference are the dictionary of
set theory; precedes,
meets, overlaps,
during are the dictionary of
Allen's interval algebra; min,
max, supremum,
infimum are the dictionary of
order theory. When the method you want to
write belongs to a different dictionary than the
type it's about to live on, you're conflating
domains, and the right move is to add the missing
type, not the missing method.
Coordinates & getting started
Both libraries are on Maven Central. Java 17+, Gradle
8+ (Kotlin DSL) or Maven 3.9+. They cleanly compose:
depending on progressions transitively
pulls intervals, which transitively
pulls comparisons and the
traversal primitives.
# gradle/libs.versions.toml
[libraries]
intervals = { module = "software.visionary:intervals", version = "2026.05.10" }
progressions = { module = "software.visionary:progressions", version = "2026.05.10" }
// build.gradle.kts — just the discrete sequence half (transitively brings intervals)
dependencies {
api(libs.progressions)
}
// module-info.java
requires transitive software.visionary.progressions;
If you only need the continuous shapes — no stepping,
no traverse:
dependencies {
api(libs.intervals)
}
Either way, the Compare ordering
protocol comes along transitively — you don't need to
list it.
Design principles
-
Name the boundary semantics at the type
level. Picking
ClosedIntervaloverLeftClosedRightOpenIntervalmakes the intent explicit in the signature — never buried inside a hand-written comparison expression where a future reader has to guess whether you meant<or<=. -
Containment is a default. Each
public variant supplies its own
contains(value)default. Implementors declarestart()andend(); the membership logic is never their concern. - The umbrella is package-private on purpose. Callers always pick a specific variant; the base is the home for the Allen algebra defaults, but it is not part of the public API. Consumers cannot write code that is ambiguous about which interval shape it is holding.
-
Allen's algebra by intersection
type. Each progression variant extends
the matching
*Intervalshape, so progressions inherit the full 13-relation algebra without re-deriving it — and consumers can compare progressions via Allen relations without ever realizing them. -
Lazy specification. A
Progressionis a value describing a discrete sequence without allocating it. Realization is deferred totraverse()/ascending()/descending(). -
State in the base; arithmetic in the
leaves. Every progression needs the same
three fields and the same null checks;
arithmetic depends on the value type.
AbstractProgressionhoists state up; the four shape-specific bases stay empty except for the constructor. -
No default arithmetic.
advanceandretreatstay abstract. There is no honest default for an arbitraryCompare-comparable type, and a wrong default is worse than none. -
Bitoverboolean. Every containment and algebra method returnsBitso results compose into the framework's boolean-pipeline machinery without unboxing. -
Compile-time non-null at every
seam. Every parameter that mustn't be
null carries
@PreCondition(IsNotNull.class); every return that mustn't be null carries@PostCondition(IsNotNull.class). contrax fails the build on literal-nullcallers; the algebra body never has to defend itself.
Why this matters
"Is this number in this range?" is one of the oldest questions in software. We have written it longhand in every language ever invented, in dialects that don't compose, with off-by-one bugs that ship to production every week. James F. Allen wrote down the complete answer for two intervals on a linear order in 1983. Kenneth Iverson wrote down the complete answer for an integer sequence as a first-class value in 1962. The industry has been re-implementing their work, partially and incompatibly, ever since.
intervals and progressions
are 509 and 401 lines of Java respectively, every
line under
GPL-3.0-or-later,
on Maven Central today. They name the four boundary
semantics at the type level. They ship Allen's
thirteen relations as default methods on every
variant. They open to any totally-ordered value type
— boxed numerics out of the box, your own domain
types in three lines. They make the
for loop a value you can hand around,
compare with Allen, and walk in either direction
without allocating the sequence. They lean on
contrax so the
algebra body stays defensively clean while the
compiler refuses to call it with a null.
Neither library is finished. The
Compared<N> adapter today covers
only the six boxed numeric primitives; a
Lexicographic<S> adapter for
String, CharSequence, and
the JDK Character wrapper is the
obvious next built-in. AbstractProgression
encodes an arithmetic step; a geometric flavor for
powers-of-two and other multiplicative sequences is
the obvious one after that. The SPI is open; the
built-ins grow as real consumers ask for them, not
before.
But what is there today already kills the most-shipped
validation boilerplate in every Java codebase I have
ever read. 0 <= x && x < 100
becomes opacity.contains(x), written
once on a type that names the boundary semantics in
its name. "Do these two meetings conflict?"
becomes morning.overlaps(afternoon),
written once and forever. "Is the dumbbell I
want on the rack?" becomes
rack.contains(weight). The
for loop's three fields stop being
local variables in a syntax form and become a value
you can pass to a function.
Both libraries are small, fast, free, and usable today against any Java module you point them at. If you want tools built this way for your stack, you can nico@visionary.software me with all your cheers, boos, and beers. I'd be happy to build the next one with you, or discuss licensing for your commercial work.
The boundary was always part of the type. The sequence was always a value. We just kept writing them as syntax and finding out at runtime. I will keep building things that find out now.