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:

Six libraries, four boundary semantics, six different opinions.
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
precedespend < other.start
precededByPother.end < start
meetsmend == other.start
metByMother.end == start
overlapsostart < other.start < end < other.end
overlappedByOother.start < start < other.end < end
startssstart == other.start AND end < other.end
startedBySstart == other.start AND other.end < end
duringdother.start < start AND end < other.end
contains(Bounded)distart < other.start AND other.end < end
finishesfother.start < start AND end == other.end
finishedByFstart < other.start AND end == other.end
equals(Bounded)estart == 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++) is LeftClosedRightOpenProgression(0, n, 1). for (int i = n - 1; i >= 0; i--) is the same value, walked via descending().
  • 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 Allen meets relation answers in a single call.
  • Speedometers and gauges. Your car's speedometer is ClosedProgression(0 mph, 120 mph, 1 mph) for the minor ticks and ClosedProgression(0 mph, 120 mph, 10 mph) for the major ones — a closed progression nested inside another, sharing the start and end. 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?" is contains.
  • 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/retreat on the same AbstractProgression, 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> where Size orders by ordinal, and "do we carry the next size up from this one?" is advance.
  • 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 (setsordered-setsintervals) 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

  1. Name the boundary semantics at the type level. Picking ClosedInterval over LeftClosedRightOpenInterval makes 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 <=.
  2. Containment is a default. Each public variant supplies its own contains(value) default. Implementors declare start() and end(); the membership logic is never their concern.
  3. 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.
  4. Allen's algebra by intersection type. Each progression variant extends the matching *Interval shape, so progressions inherit the full 13-relation algebra without re-deriving it — and consumers can compare progressions via Allen relations without ever realizing them.
  5. Lazy specification. A Progression is a value describing a discrete sequence without allocating it. Realization is deferred to traverse() / ascending() / descending().
  6. 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. AbstractProgression hoists state up; the four shape-specific bases stay empty except for the constructor.
  7. No default arithmetic. advance and retreat stay abstract. There is no honest default for an arbitrary Compare-comparable type, and a wrong default is worse than none.
  8. Bit over boolean. Every containment and algebra method returns Bit so results compose into the framework's boolean-pipeline machinery without unboxing.
  9. 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-null callers; 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.