Skip to content

fix: make IsAdjacentTo symmetric, and normalization with it - #17

Merged
CaffeinatedCoder merged 1 commit into
mainfrom
fix/adjacency-symmetry
Aug 16, 2026
Merged

CaffeinatedCoder merged 1 commit into
mainfrom
fix/adjacency-symmetry

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Owner

The defect

RangeExtensions.IsAdjacentTo switched on the receiver's shape and handled only IFiniteRange<T>; every other shape fell through to false. Its inner switch did handle unbounded operands, so the relation was asymmetric:

(,0].IsAdjacentTo([1,3])  → false     [1,3].IsAdjacentTo((,0])  → true
[4,).IsAdjacentTo([1,3])  → false     [1,3].IsAdjacentTo([4,))  → true
(,0].IsAdjacentTo([1,))   → false

PostgreSQL's -|- is symmetric and answers true for all five — queried directly against the server to confirm, not inferred. The XML doc asserted the broken behaviour as if it were intended, which is why reading the code confirmed the comment rather than catching the bug.

Why it mattered beyond the predicate

RangeSet.From and RangeSet.Union merge neighbours with current.IsAdjacentTo(next) after sorting by lower bound, so an unbounded-start element is always the receiver and always took the broken direction. Sets were built violating the pairwise-non-adjacent invariant the type documents:

RangeSet.From([(,0], [1,)])          was {(,0],[1,)}   now {(,)}
blocks.Union(blocks.Complement())    was {(,0],[1,)}   now the Infinite set

Two sets that should be equal compared unequal depending on how they were built, and a set covering the whole domain did not equal RangeSet.Infinite.

The fix

Rewritten to switch on the pair (range, other), deciding each unordered shape pair once so both receiver orders route to the same test — symmetry is structural rather than something each arm has to remember. Empty, Infinity, and two ranges open at the same end still answer false.

RangeSet.IsAdjacentTo was never affected: it has its own bound-based implementation that tracks infinity flags explicitly.

Verification

Tests were written first and confirmed red before the fix, then green, then red again on reverting only the source:

  • symmetry sweep across all 8×8 shape pairs
  • the five pairs PostgreSQL was queried about, both directions
  • continuous XOR-inclusiveness with unbounded receivers, plus the negative cases
  • four normalization consequences, including Union with Complement
  • live PostgreSQL: RangeAdjacency_UnboundedShapes_MatchPostgres compares model against server; it fails naming the disagreeing pair '(,0]' -|- '[1,3]' when the fix is reverted

No pre-existing test broke — the fix only adds true where PostgreSQL says true. The existing unbounded tests kept passing because they only ever called the working direction, with comments explaining the receiver "must be finite".

Compatibility

⚠️ Results change for any range or set with an unbounded element adjacent to its neighbour — in every case from a wrong answer to the one the database gives. No API change; PackageValidation passes against the 6.2.0 baseline. Applies to the NodaTime range types, which share the predicate.

Docs: XML doc corrected, README adjacency section gains a symmetric-and-unbounded example with a "Changed in 6.2.1" callout, changelogs for all four packages.

🤖 Generated with Claude Code

IsAdjacentTo switched on the receiver's shape and handled only IFiniteRange<T>;
every other shape fell through to false. Its inner switch did handle unbounded
operands, so the relation was asymmetric: [1,3].IsAdjacentTo((,0]) was true
while (,0].IsAdjacentTo([1,3]) was false. PostgreSQL's -|- is symmetric and
answers true for both — queried directly to confirm. The XML doc asserted the
broken behaviour as if intended, which is why reading the code confirmed it.

The damage was in normalization rather than the predicate. RangeSet.From and
RangeSet.Union merge neighbours with current.IsAdjacentTo(next) after sorting by
lower bound, so an unbounded-start element is always the receiver and always took
the broken direction:

  RangeSet.From([(,0], [1,)])        was {(,0],[1,)}   now {(,)}
  blocks.Union(blocks.Complement())  was {(,0],[1,)}   now the Infinite set

Sets violated the pairwise-non-adjacent invariant they document, two sets that
should be equal compared unequal depending on construction path, and a set
covering the whole domain did not equal RangeSet.Infinite.

Rewritten to switch on the pair (range, other), deciding each unordered shape
pair once so both receiver orders route to the same test — symmetry is now
structural rather than something each arm has to remember. Empty, Infinity and
two ranges open at the same end still answer false.

Verified by reverting: the symmetry sweep, the four normalization tests and the
new live-PostgreSQL parity test all fail without the fix, the last one naming
the disagreeing pair.

Co-Authored-By: Claude Opus 5 <[email protected]>
@CaffeinatedCoder
CaffeinatedCoder merged commit 8515e37 into main Aug 16, 2026
8 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the fix/adjacency-symmetry branch August 16, 2026 14:09
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.

1 participant