Skip to content

Fix NaN handling in LinearLocation/EdgeIntersection compareTo - #1229

Open
bjornharrtell wants to merge 1 commit into
locationtech:masterfrom
bjornharrtell:fix/1228-nan-compareto
Open

Fix NaN handling in LinearLocation/EdgeIntersection compareTo#1229
bjornharrtell wants to merge 1 commit into
locationtech:masterfrom
bjornharrtell:fix/1228-nan-compareto

Conversation

@bjornharrtell

Copy link
Copy Markdown
Contributor

Fixes#1228

LinearLocation.compareTo() and EdgeIntersection.compare() compared the floating-point fields (segmentFraction/dist) with raw </>, which are always false for NaN. This made compareTo fall through to return 0 ("equal") whenever a NaN was compared against any other value on the same segment/index, not just against another NaN.

Since equals() for both classes (added in #1201) is defined as compareTo(o) == 0, this made equals() non-transitive and inconsistent with hashCode() whenever NaN was involved (e.g. segmentFraction is NaN for zero-length line segments).

Fix

Replace the manual </> comparisons with Double.compare(), which gives NaN a well-defined, transitive ordering consistent with Double.hashCode()/Objects.hash() (already used by both classes' hashCode()). No behavior change for non-NaN values.

Tests

Added regression tests reproducing the transitivity/hashCode issue from the report:

  • LinearLocationTest.testEqualsHashCodeConsistentWithNaN
  • EdgeIntersectionTest.testEqualsHashCodeConsistentWithNaN

@bjornharrtell
bjornharrtellforce-pushed the fix/1228-nan-compareto branch 5 times, most recently from 8c31be5 to d328bf5CompareAugust 31, 2026 17:45
compareTo() used raw </> comparisons on segmentFraction/dist, which are
always false for NaN and made compareTo (and thus equals(), added in locationtech#1201)
fall through to 0 whenever NaN was compared to any other value on the same
segment/index. This broke transitivity of equals() and its consistency with
hashCode(). Use Double.compare() instead, matching Double's NaN ordering
semantics already used by hashCode().

@grootstebozewolfgrootstebozewolf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM. Double.compare is the right fix for the #1201equals / compareTo / hashCode contract on NaN. Nits below are optional follow-ups, not blockers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same < / > fall-through still lives in compareLocationValues (instance and static). LocationIndexOfPoint.indexOfFromStart uses that for “strictly after minIndex”, so a NaN fraction still compares equal to every fraction on that segment. Same one-liner: return Double.compare(...).

// same location
return 0;
// Double.compare is used to ensure consistency with equals/hashCode for NaN
return Double.compare(segmentFraction, other.segmentFraction);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Finite-vs-finite order is unchanged except signed zero: Double.compare(-0.0, +0.0) == -1, while the old path returned 0. -0.0 survives normalize() (-0.0 < 0.0 is false), and Objects.hash already hashed them differently, so this also closes an equals/hashCode hole. Worth a sentence in the PR; not a reason to revert.

if (this.dist > dist) return 1;
return 0;
// Double.compare is used to ensure consistency with equals/hashCode for NaN
return Double.compare(this.dist, dist);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same signed-zero note as on LinearLocation: Double.compare distinguishes -0.0 and +0.0. EdgeIntersectionList is a TreeMap keyed on this order, so the NaN collapse was not only a HashSet issue.

Comment on lines +64 to +77
public void testEqualsHashCodeConsistentWithNaN() throws Exception
{
LinearLocation zero = new LinearLocation(0, 0, 0.0);
LinearLocation nan = new LinearLocation(0, 0, Double.NaN);
LinearLocation half = new LinearLocation(0, 0, 0.5);

// NaN must only be equal to itself, not to arbitrary other values (transitivity)
assertFalse(zero.equals(nan));
assertFalse(nan.equals(half));
assertFalse(zero.equals(half));
assertEquals(nan, new LinearLocation(0, 0, Double.NaN));

// equals/hashCode contract must hold even when NaN is involved
assertEquals(nan.hashCode(), new LinearLocation(0, 0, Double.NaN).hashCode());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

assertFalse(zero.equals(half)) is not a NaN case. Stronger: put zero and nan in the same HashSet / TreeSet (that was the collection failure), and assertTrue(nan.compareTo(zero) > 0) for the Double.compare total order.

Also, LineSegment.segmentFraction already maps NaN to 1.0, then normalize() walks that to the next vertex — that is why testZeroLengthLineString still compares against (1, 0.0). The public constructors still accept NaN, so the contract fix stands; indexOf on a collapsed segment does not store NaN.

Comment on lines +51 to +64
public void testEqualsHashCodeConsistentWithNaN() {
EdgeIntersection zero = new EdgeIntersection(new Coordinate(1, 2), 0, 0.0);
EdgeIntersection nan = new EdgeIntersection(new Coordinate(1, 2), 0, Double.NaN);
EdgeIntersection half = new EdgeIntersection(new Coordinate(1, 2), 0, 0.5);

// NaN must only be equal to itself, not to arbitrary other values (transitivity)
assertFalse(zero.equals(nan));
assertFalse(nan.equals(half));
assertFalse(zero.equals(half));
assertEquals(nan, new EdgeIntersection(new Coordinate(1, 2), 0, Double.NaN));

// equals/hashCode contract must hold even when NaN is involved
assertEquals(nan.hashCode(), new EdgeIntersection(new Coordinate(1, 2), 0, Double.NaN).hashCode());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as the LinearLocation twin: assertFalse(zero.equals(half)) is not a NaN case. Worth putting zero and nan in one HashSet / TreeSet, and asserting nan.compareTo(zero) > 0.

Sign up for freeto 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.

LinearLocation/EdgeIntersection equals() (added in #1201) is inconsistent with hashCode() and non-transitive when segmentFraction/dist is NaN

2 participants

@bjornharrtell@grootstebozewolf