All findings · Lucene finding · LFV-426 · xy-distance-sort-accepts-non-finite-origin

XYDocValuesField.newDistanceSort accepts a NaN or infinite origin instead of throwing

XYDocValuesField.newDistanceSort promises an IllegalArgumentException for invalid coordinates but accepts a NaN or infinite origin, because its sort field never checks x and y. The sort then runs in a meaningless order: with a NaN origin, documents without a location come first instead of last (every release since 8.5.0 and main).

Summary

Priority
Low, rank 886XYDocValuesField.newDistanceSort accepts a NaN or infinite origin instead of rejecting it, with a minor missing-last sort side effect, the same accepts-NaN-parameter profile as LFV-304.
Impact
wrong error
Since
8.5.0 (2020)introduced 2020-02-21 · about 6 years
Affected
8.5.0 to 10.6.0 and main
Status
documented
Found by
static diff of the lat/lon and x/y twin classes + JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-03.

The finding in 30 seconds

newDistanceSort passes x and y to a sort field whose constructor checks only the field name. Every computed distance then contains the bad coordinate: NaN for a NaN origin, Infinity for an infinite one, while documents without a location keep their fixed distance, Infinity.

Background: sorting by x/y distance

  1. XYDocValuesField stores a point on a flat x/y plane per document. XYDocValuesField.newDistanceSort(field, x, y) returns a SortField that orders hits by straight-line distance from the origin (x, y), nearest first.
  2. A document without the field gets distance Infinity, so it sorts after every document that has a location ("missing values sort last").
  3. For XY, only finite floats are valid coordinates: a NaN or Infinity in an indexed point, an XYPoint, an XYCircle or a distance query throws IllegalArgumentException.
Diagram of the bug on main 645d4fe14a20. XYDocValuesField.newDistanceSort documents an IllegalArgumentException for invalid coordinates, and new XYDocValuesField, XYPointField.newDistanceQuery and LatLonDocValuesField.newDistanceSort all reject a NaN coordinate, but newDistanceSort("p", NaN, 0) is accepted because the XYPointSortField constructor only checks the field name. Three documents, one without a location, sorted by distance: with origin (0, 0) the document without a location (distance Infinity) comes last; with origin (NaN, 0) every located document's distance is NaN, Double.compare puts NaN after Infinity, and the document without a location comes first; with an infinite origin all distances are Infinity and the hits come in doc-id order. Fix: validate x and y with XYEncodingUtils.encode in the constructor, after which the minimal repro passes.
Three documents, one without a location, sorted by distance: a finite origin puts it last; a NaN origin makes the two located documents' distances NaN, which sorts after Infinity, so it comes first. The fix rejects the origin. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index three documents: A at (1, 1), B with no location, C at (2, 2). Sorting with XYDocValuesField.newDistanceSort("p", 0, 0) gives A (1.41), C (2.83), then B (Infinity): missing values sort last, as documented.
  2. Now the origin is (NaN, 0), for example a centroid computed as 0/0 or a request value "NaN" read with Float.parseFloat. You expect IllegalArgumentException: the javadoc promises it for invalid coordinates, and new XYDocValuesField("p", NaN, 0), XYPointField.newDistanceQuery and the lat/lon twin all throw it.
  3. You get no error: the sort field's constructor only checks that the field name is not null. Every located document's distance becomes NaN, and Double.compare puts NaN after Infinity.
  4. So the order is B, A, C: the document with no location comes first, and A and C report distance NaN. An infinite origin makes every distance Infinity and returns A, B, C in doc-id order.
  5. Only requests that use this sort are affected: in the probe the matching documents, hit count and paging were unchanged, and the bad sort does not change the index.

Who is affected

Applications that sort by x/y distance and let a NaN or infinite origin through, such as a computed 0/0 or "NaN" or "Infinity" parsed with Float.parseFloat. They get no error and a meaningless order for those requests. Rare: a GitHub code search found no caller in Elasticsearch, OpenSearch or Solr, and no public report since 2020.

Mechanism, terms and numbers596 words

XYDocValuesField.newDistanceSort(field, x, y) documents "@throws IllegalArgumentException if {@code field} is null or location has invalid coordinates", word for word like its lat/lon twin. It returns new XYPointSortField(field, x, y), whose constructor checks only field != null, so a NaN or infinite x or y is accepted. The XY APIs around it that take coordinates (the XYDocValuesField constructor, XYPointField.newDistanceQuery, and the XYPoint, XYCircle, XYRectangle, XYLine and XYPolygon constructors) reject exactly these values through XYEncodingUtils.checkVal, and the lat/lon sort field validates its origin.

The search then runs. A document without the field has distance +Infinity; a located one has sqrt((x - docX)^2 + (y - docY)^2), which is NaN for a NaN origin and +Infinity for an infinite one. Double.compare orders NaN after +Infinity, so with a NaN origin documents without a location come first, against the documented "missing values sort last"; with an infinite origin everything ties and comes back in doc-id order. Only requests that use this sort are affected. It came with the XY sort in 8.5.0 (LUCENE-9238, a port of the lat/lon code that kept the @throws line but not the check) and is in every release through 10.6.0 and on main. Two XYEncodingUtils.encode calls in the constructor fix it; the fix passes the repro, the closest XY suites and check.

The bug, step by step (main 645d4fe14a20, under lucene/core/src/java/org/apache/lucene/)

  1. No check. XYDocValuesField.newDistanceSort is return new XYPointSortField(field, x, y); (document/XYDocValuesField.java:151-152). The constructor calls super(field, Type.CUSTOM, false, Double.POSITIVE_INFINITY) (the missing value), throws only when field == null, and stores x and y as given (document/XYPointSortField.java:28-35). The twin LatLonPointSortField calls GeoUtils.checkLatitude(latitude); GeoUtils.checkLongitude(longitude); at the same place (document/LatLonPointSortField.java:34-35), and checkLatitude rejects NaN explicitly (geo/GeoUtils.java:85-86).
  2. The sort key. XYPointDistanceComparator.sortKey starts from Double.POSITIVE_INFINITY, which a document without the field keeps, and takes Math.min over Math.sqrt(diffX * diffX + diffY * diffY) with diffX = x - docX (document/XYPointDistanceComparator.java:190-209). For a NaN origin the distance is NaN and Math.min(Infinity, NaN) is NaN. For an infinite origin every distance is +Infinity, equal to the missing value.
  3. The order. Slots are compared with Double.compare (:77-79), which orders NaN after +Infinity; a missing document is compared as +Infinity in compareBottom (:126-128). Ascending order therefore puts missing documents first under a NaN origin and ties everything under an infinite origin (ties go by doc id).
  4. No crash, no hang. setBottom builds its pruning box only when bottom < Float.MAX_VALUE (:87-97), which is false for NaN and +Infinity, so nothing throws at search time and nothing loops. The box keeps its initial full range (:55-58), so compareBottom never skips a value by the box and computes the distance of every value it sees (:122-160): possibly more work than with a finite origin. The probe saw no timing blow-up; memory was not measured.

Executed on main (order probe, assertions on and off; the same on the 10.6.0 jars)

Documents: A at (1, 1), B without a location, C at (2, 2).

originaccepted?order (distance)
(0, 0)yesA 1.4142, C 2.8284, B Infinity
(NaN, 0), (0, NaN)yesB Infinity, A NaN, C NaN
(Infinity, 0), (0, -Infinity)yesA, B, C, all Infinity (doc-id order)
(-Float.MAX_VALUE, 0)yesA, C (3.4028E38 each), B Infinity

Controls in the same run: new XYDocValuesField("p", NaN, 0) and XYPointField.newDistanceQuery("p", NaN, 0, 1) throw "invalid value NaN; must be between -3.4028234663852886E38 and 3.4028234663852886E38"; LatLonDocValuesField.newDistanceSort("p", NaN, 0) throws "invalid latitude NaN; must be between -90.0 and 90.0".

Exposure and likelihood278 words
  • Who is hit. Applications that sort with XYDocValuesField.newDistanceSort and let a non-finite origin through: a reference point computed as 0/0 (for example the centroid of an empty set), or a request parameter "NaN" or "Infinity" parsed with Float.parseFloat and passed on unchecked. Default IndexWriterConfig, codec and searcher; the missing check is in the sort field's constructor, so it does not depend on the index; query-time only.
  • What goes wrong. No exception, and the results come back in a meaningless order: documents without a location first (NaN origin) or plain doc-id order (infinite origin), with NaN or Infinity as the reported distances. An application that relies on the documented IllegalArgumentException to reject bad input never sees it.
  • Limits. The sort field belongs to the request that builds it, so only requests that use it are affected. In the study's probe (20,000 documents, 8 segments, 7 origins; main with assertions on and off, 10.5.2 with assertions on) the matched set and hit count equalled a filter's count, searchAfter paging returned every match exactly once, a sliced concurrent search returned the same top 50 hits as the sequential one, and search time did not blow up. These are the tested workloads, not a proof for every index. The bad sort runs only at query time and does not change the index; index-time XY fields still reject non-finite values.
  • Reach. A GitHub code search found no caller in Elasticsearch, OpenSearch or Solr (default branches, 2026-10-26), only copies of Lucene's own test elsewhere. That bounds the search; it does not prove there are no callers.
  • Likelihood: rare. Normal code passes finite coordinates; the recorded searches found no public report since 8.5.0 (2020).

Minimal repro

One JUnit test class in lucene/core, one line: newDistanceSort("p", Float.NaN, 0f) must throw IllegalArgumentException. Public API, no index, deterministic; fails with assertions on and off, on main and 10.6.0.

TestXYDistanceSortNaNOrigin.java (1 one-line test)28 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.document;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestXYDistanceSortNaNOrigin extends LuceneTestCase {

  public void testNaNOriginRejected() {
    // javadoc: throws IllegalArgumentException for invalid coordinates; actual: no exception
    expectThrows(
        IllegalArgumentException.class, () -> XYDocValuesField.newDistanceSort("p", Float.NaN, 0f));
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or 10.6.0, branch_10_6, upstream main)
cp $REPRO/TestXYDistanceSortNaNOrigin.java lucene/core/src/test/org/apache/lucene/document/
./gradlew :lucene:core:test --tests org.apache.lucene.document.TestXYDistanceSortNaNOrigin -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
newDistanceSort("p", Float.NaN, 0f) throws IllegalArgumentException, as its javadoc promises.
Observed
No exception ("Expected exception IllegalArgumentException but no exception was thrown"), on main and 10.6.0, assertions on and off.

All files of this repro: repro/LFV-426/

More files, environment, full output233 words

The test has the ASF license header, so it is also the regression test unchanged; with it in place, :lucene:core:check -x test passes. It fails on stock and passes with the recommended fix.

The test covers only (NaN, 0); (0, NaN) and the infinite origins are covered by the order probe below. The recommended fix rejects all four non-finite cases.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): affected; also the 10.6.0 release jars with this test
jdkOpenJDK 25.0.3
osLinux, aarch64

Observed output

TestXYDistanceSortNaNOrigin > testNaNOriginRejected FAILED
    junit.framework.AssertionFailedError: Expected exception IllegalArgumentException but no exception was thrown
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:E2DEC189C27FB499]:0)
        at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
        at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
        at app//org.apache.lucene.document.TestXYDistanceSortNaNOrigin.testNaNOriginRejected(TestXYDistanceSortNaNOrigin.java:25)

:lucene:core:test (FAILURE): 1 test, 1  failure

# the same failure with -Ptests.asserts=false, and on the 10.6.0 release jars (assertions on and off)

# order probe on the same main, assertions on (identical with assertions off and on 10.6.0):
origin=(0.0, 0.0) accepted, order: A=1.4142135623730951 C=2.8284271247461903 B(no location)=Infinity
origin=(NaN, 0.0) accepted, order: B(no location)=Infinity A=NaN C=NaN
origin=(0.0, NaN) accepted, order: B(no location)=Infinity A=NaN C=NaN
origin=(Infinity, 0.0) accepted, order: A=Infinity B(no location)=Infinity C=Infinity
origin=(0.0, -Infinity) accepted, order: A=Infinity B(no location)=Infinity C=Infinity
origin=(-3.4028235E38, 0.0) accepted, order: A=3.4028234663852886E38 C=3.4028234663852886E38 B(no location)=Infinity
control new XYDocValuesField("p", NaN, 0): rejected: invalid value NaN; must be between -3.4028234663852886E38 and 3.4028234663852886E38
control XYPointField.newDistanceQuery("p", NaN, 0, 1): rejected: invalid value NaN; must be between -3.4028234663852886E38 and 3.4028234663852886E38
control LatLonDocValuesField.newDistanceSort("p", NaN, 0): rejected: invalid latitude NaN; must be between -90.0 and 90.0

Why it looks unintended

  • The javadoc promises IllegalArgumentException when the location has invalid coordinates, and says missing values sort last.
  • For XY, invalid means non-finite: the same class's constructor, XYPoint, XYCircle and XYPointField.newDistanceQuery all reject NaN and Infinity.
  • The lat/lon twin, from which the XY code was ported, validates its origin; the port kept the @throws line but not the check.
The full argument306 words
  • The method's own contract. "@throws IllegalArgumentException if {@code field} is null or location has invalid coordinates." (XYDocValuesField.java:149) and "missing values sort last" (:139-140).
  • What "invalid" means for XY. The same class's constructor and setLocationValue: "@throws IllegalArgumentException if ... x or y are infinite or NaN" (:79, :91), enforced through XYEncodingUtils.encode (:94-95), which calls checkVal, "validates value is a number and finite" (XYEncodingUtils.java:34-41). XYPoint and XYCircle call checkVal too.
  • The sibling query enforces the identical wording, and a test checks it. XYPointField.newDistanceQuery: "@throws IllegalArgumentException if {@code field} is null, location has invalid coordinates, or radius is invalid" (XYPointField.java:174-175), enforced by new XYCircle (:178). Upstream BaseXYPointTestCase.testDistanceIllegal, "distance query should not accept invalid x/y as origin", passes a NaN origin. No upstream test passes a non-finite origin to the XY sort.
  • The twins validate. LatLonPointSortField has checked its origin since its first commit (LUCENE-7099, 2016), and the Geo3D distance sort goes through Geo3DUtil.fromDistance, which calls GeoUtils.checkLatitude/checkLongitude.
  • A port that lost the check. LUCENE-9238 (PR apache/lucene-solr#1272, whose description reads "Adaptation of LatLonPoint to generic cartesian space.") copied the @throws line word for word but not the check. That the check was lost in the port is an inference from the identical javadoc and the validating twin, not a stated intent; the PR has no discussion of validation.
  • The finite-only rule predates the sort. XYEncodingUtils.checkVal has rejected NaN and values beyond +/-Float.MAX_VALUE (so both infinities) since it was added with XYEncodingUtils in LUCENE-8632 (0c09481374c, 2019-07-08). One week before the sort, LUCENE-9218 (apache/lucene-solr#1252, merged 2020-02-14) rewrote it for floats as Float.isFinite(x) == false, "validates value is a number and finite"; in that PR the author had written Float.isNaN(x) || Float.isInfinite(x) and a reviewer suggested the single test.
The devil's-advocate check174 words

The strongest case: the XY javadoc is lat/lon boilerplate (it still says the sort value is a "distance in meters", XYDocValuesField.java:137, although x/y coordinates have no unit), XYPointSortField is package-private, NaN in means garbage out, and Double.compare is a total order, so the result is at least deterministic. Finiteness is an XY-family rule, not a Lucene-wide one: the generic FloatPoint deliberately accepts NaN and the infinities (TestPointQueries.testCrazyFloats), and a caller could be using a NaN origin as a sentinel today. The cross-model reviews rated this case weak.

Why it fails: the @throws clause is exactly what the siblings enforce (the lat/lon and Geo3D sorts, the XY field, point, circle and distance-query APIs, the same class's constructor). The fix rejects only non-finite origins; the finite origins probed are unchanged (an origin at -Float.MAX_VALUE still sorts with missing last, with and without the fix). A caller that relies on a non-finite origin would now get the exception, a compatibility change. It is a good reason for low priority, not for keeping the gap.

Suggested fix

Recommended Validate x and y in the XYPointSortField constructor (+4 lines)

Validate the origin in the XYPointSortField constructor with the XY family's finite check: XYEncodingUtils.encode(x); XYEncodingUtils.encode(y); plus an import. Validated on main: the repro passes with assertions on and off, the closest XY suites (90 tests) pass, and check passes.

All options and trade-offs4 options · 532 words

The recommended fix was run in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF), removed afterwards. "Closest XY suites" means TestXYPointDistanceSort, TestXYDocValuesQueries, TestXYPointQueries, TestXYPoint and TestXYCircle in lucene/core.

Validate x and y in the XYPointSortField constructor (+4 lines) (validated by the checks) (recommended)

XYEncodingUtils.checkVal is package-private in org.apache.lucene.geo; encode is public and calls it, exactly as XYDocValuesField.setLocationValue does. The error is the one new XYDocValuesField gives: "invalid value NaN; must be between -3.4028234663852886E38 and 3.4028234663852886E38". This is the finder's fix with a shorter comment. The diff shown is complete; its comment was reworded after the fact check, and it differs from the gradle-validated diff only in that comment line.

--- a/lucene/core/src/java/org/apache/lucene/document/XYPointSortField.java
+++ b/lucene/core/src/java/org/apache/lucene/document/XYPointSortField.java
@@ -16,6 +16,7 @@
  */
 package org.apache.lucene.document;
 
+import org.apache.lucene.geo.XYEncodingUtils;
 import org.apache.lucene.search.FieldComparator;
 import org.apache.lucene.search.Pruning;
 import org.apache.lucene.search.SortField;
@@ -30,6 +31,9 @@
     if (field == null) {
       throw new IllegalArgumentException("field must not be null");
     }
+    // reject NaN and infinite x/y, as XYDocValuesField does (XYEncodingUtils.checkVal)
+    XYEncodingUtils.encode(x);
+    XYEncodingUtils.encode(y);
     this.x = x;
     this.y = y;
   }

Trade-offs

  • A caller that passes a non-finite origin now gets the documented exception instead of a meaningless order: a visible behaviour change worth a CHANGES entry, and a break for any caller that used a NaN or infinite origin on purpose (say, as a sentinel). The finite origins probed give the same results: an origin at -Float.MAX_VALUE still sorts, missing last. Constructor-time cost only.
  • Calling encode for its side effect reads slightly odd; the alternatives below avoid that.
  • Open PR #14775 ("Add the ability to inverse a Sort") also edits XYPointSortField.java, outside the constructor, so a fix PR conflicts at most trivially.

What the checks validated

  • Stock 645d4fe14a20: the repro fails (1 of 1) with assertions on and off, through gradle and the standalone runner; also on the 10.6.0 jars.
  • With the fix: the repro passes with assertions on and off; the 5 closest XY suites pass (90 tests, 5 skipped, 0 failures); :lucene:core:check -x test (google-java-format, ecj lint main and test, forbidden APIs, dependency license check) passes with the repro test in place. These are targeted runs, not the full Lucene test suite.
  • Probe with the fixed file shadowed: (NaN, 0), (0, NaN), (Infinity, 0) and (0, -Infinity) are rejected; (0, 0) and (-Float.MAX_VALUE, 0) give the same order and distances as stock.
  • The diff shown (reworded comment) applies to 645d4fe14a20 with git apply --check, is google-java-format clean, and passes the minimal repro with assertions on and off (stock still fails both).

Build an XYPoint in the constructor (not run)

new XYPoint(x, y); validates both values with the same checkVal and the same message.

Trade-offs

Reads as "the origin must be a valid point", at the cost of one small allocation per sort field.

An explicit finite check with its own message (not run)

if (Float.isFinite(x) == false || Float.isFinite(y) == false) throw new IllegalArgumentException(...).

Trade-offs

Clearest intent, but a new message unlike the rest of the XY family.

Map NaN distances to Infinity in the comparator

Would keep missing documents last for a NaN origin.

Trade-offs

Not enough: it hides the bad input instead of rejecting it, and the javadoc promises an exception.

Downstream note

A GitHub code search found no caller of this API in Elasticsearch, OpenSearch or Solr; an application that forwards a user-supplied origin unchecked lets that user scramble only the order of their own results.

How it was found

A normalised static diff of sibling classes compared LatLonPointSortField with its x/y port XYPointSortField: the lat/lon origin checks have no counterpart in the XY class. A public-API JUnit with NaN and Infinity then showed that newDistanceSort accepts them.

The method in full128 words

Method: non-formal (the diff is an automated code-reading aid, not a model, a proof or a bounded-exhaustive check).

  • The unit's twin-diff tool normalised the lat/lon and x/y sibling classes of the geo family and listed every member that differs beyond the expected type and name changes. In LatLonPointSortField vs XYPointSortField, the GeoUtils.checkLatitude/checkLongitude lines exist only in the lat/lon twin.
  • The finder then ran a targeted public-API JUnit on the stock jar with the non-finite values (NaN, +Infinity, -Infinity): newDistanceSort accepted all of them, and a NaN origin put the document without a location first. An independent reproducer wrote its own test (all six non-finite origins accepted, controls passing), a devil's advocate and an adjudication confirmed it, and the repro was later cut to the one-line test above.

Prior reports, history and checks

Prior reports

No prior report found: 75 tool queries plus manual GitHub, JIRA, mailing-list and web searches and the changed files of all 411 open PRs (2026-10-26), refreshed on GitHub 2026-10-31. Not fixed upstream.

Searches3 searches

Verdict new. Related, not reports: #10278 / apache/lucene-solr#1272 (the introduction, no validation discussion); apache/lucene-solr#1252 (LUCENE-9218, the finite-only checkVal, intent evidence); #8157 / #8159 (LUCENE-7102/7104, LatLon missing values in 2016, a different defect, not precedent); #13301 (signed-zero equals in geo classes); open #14775 (edits the same file, constructor untouched). Limits: GitHub issue search does not index code, diffs or review comments (covered in part by the open-PR file scan); list and JIRA search are word-based; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
a scripted prior-report search: apache/lucene issues, PRs and commits, downstream repositories, mailing lists (dev@, java-user@, issues@, commits@), JIRA, web2026-10-26two runs, 75 distinct queries plus 8 web queries; 120 and 194 distinct hits, every medium hit read; the finder's earlier run (15 queries, 16 hits)no report; 0 high-overlap hits
Manual: GitHub (Elasticsearch, OpenSearch and its geospatial plugin, Solr, Lucene), JIRA (LUCENE, SOLR), mailing lists2026-10-269 GitHub searches incl. a code search, 14 JQL queries, 24 list queries; the changed files of all 411 open apache/lucene PRsno report; #14775 is the only open PR touching XYPointSortField (constructor untouched)
GitHub issues and PRs (apache/lucene), refresh2026-10-31"XYPointSortField", "newDistanceSort NaN", "XYDocValuesField newDistanceSort", "XY distance sort origin", "XYDocValuesField NaN", "cartesian distance sort", "invalid coordinates newDistanceSort"; PRs mentioning XY updated since 2026-10-26; upstream commits on the two files since 2026-10-20nothing new (a control search for newDistanceSort returns ten older items on LatLon, GeoPoint, Geo3D and spatial-extras distance features, such as #10728 on PrefixTreeStrategy, none about XY)

History

Born with the feature: LUCENE-9238 (88dd1c3f3d4, 2020-02-21) ported the lat/lon distance sort to x/y without its origin check; first released in 8.5.0. It fails the same way on all 52 releases 8.5.0 to 10.5.2, on 10.6.0 and on main; never fixed.

Versions tested8 rows

Introduced by 88dd1c3f3d4 (master, now main) and the branch_8x backport 53cb6acf912, both "LUCENE-9238: Add new XYPointField, queries and sorting capabilities (#1272)" (Ignacio Vera, 2020-02-21; PR apache/lucene-solr#1272, issue now #10278), which created XYDocValuesField, XYPointSortField and XYPointDistanceComparator. The constructor has checked only field != null ever since; later edits (LUCENE-9570, LUCENE-10377, #12405, #15483, #15486) never touched validation. Not a regression, so there is no commit-level A/B: the parents do not have the classes.

Before 8.5.0 the API does not exist (8.4.1: the study's JUnit does not compile, failing only at the uses of XYDocValuesField). The constructor is unchanged on 10.6.0, branch_10_6 and upstream main 2f424fd428d (2026-10-30).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle and the standalone runner, assertions on and off); NaN origin order B, A, C in the probe
10.6.0 affectedthe minimal test fails (assertions on and off); the same probe output
10.0.0 to 10.5.2 (12 releases) affectedthe study's JUnit fails the same way on each (10.5.2 also with assertions off)
9.0.0 to 9.12.3 (21 releases) affectedthe study's JUnit fails the same way on each
8.5.0 to 8.11.4 (19 releases) 8.5.0: first affectedthe study's JUnit fails the same way on each (8.5.0 also with assertions off)
8.4.1 and older API absentthe study's JUnit does not compile (no XY point classes in lucene-core)
branch_10_6, upstream main 2f424fd428d readthe same constructor; no upstream commit on the file since 2026-10-20
fix on 645d4fe14a20 not affectedall four non-finite origins rejected; the repro, the closest XY suites and check pass

Independent checks

An independent reproducer, a devil's advocate and an adjudication (all Claude) rated it real; cross-model reviews (OpenAI GPT-5.6, reading only) split, one procedural "not real" and one real, both agreeing on the root cause. It still reproduces on main and 10.6.0.

Checks and verdicts332 words

The OpenAI GPT-5.6 reviewers could only read code; separate runs re-ran the finder's repro for both reviews, and one of them also wrote and ran its own JUnit.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (twin diff, public-API JUnit) candidateJUnit fails on main (assertions on and off), 10.5.2 and 9.12.3; the fix passes and upstream TestXYPointDistanceSort still passes.
Cross-model review, refute lens (OpenAI GPT-5.6) not real (low), proceduralCould not run code; agreed on the root cause and that the inputs are in contract; rated the intended argument weak.
Independent reproducer realOwn JUnit: none of six non-finite origins throws on main, 10.5.2 and 9.12.3; controls pass; passes with the fix.
Cross-model adversary, intended lens (OpenAI GPT-5.6), and a separate run real (medium)The reviewer read code only and rated the best works-as-designed case weak; a separate run wrote and ran its own JUnit, which fails on main (4 tests, 2 failures: the NaN and the Infinity origin, assertions on and off).
Devil's advocate real (high), low impactThe lat/lon boilerplate argument fails; origins at +/-Float.MAX_VALUE still sort with missing last.
Adjudication confirmed, highRe-ran the independent reproduction's and the finder's tests with assertions on and off.
Credibility audit, current-main check and minimal repro keep; still reproducesAudit re-run on main; main a9779ef3c193 and 645d4fe14a20; the repro cut to one one-line test.
This doc still reproduces; fix validatedGradle on a scratch worktree of 645d4fe14a20; the 10.6.0 jars.
Independent fact and diagram checks real; corrections appliedA fact check re-ran the repro on main and 10.6.0, the documented gradle command, the fix and the XY suites; a cross-model fact check (OpenAI GPT-5.6, reading only) re-read every citation; a diagram check reviewed the frames. Their errors (the checkVal history, the printed diff, the list of XY APIs that validate, the probe's limits, the cross-model review attribution) were fixed; the diagram was re-rendered with the narrower wording.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,531 words

Finding record

LFV-426, snapshot of 2026-11-03

Registry title
XYDocValuesField.newDistanceSort accepts a NaN or infinite origin although its javadoc promises IllegalArgumentException for invalid coordinates (the LatLon twin and every other XY entry point reject such values); with a NaN origin, documents without a location sort first instead of last
Area
core / document: XY distance sort (XYDocValuesField.newDistanceSort, XYPointSortField, XYPointDistanceComparator)
Priority
Low, rank 886: XYDocValuesField.newDistanceSort accepts a NaN or infinite origin instead of rejecting it, with a minor missing-last sort side effect, the same accepts-NaN-parameter profile as LFV-304.
Impact class
wrong error
Impact note
Applications that sort by cartesian distance with XYDocValuesField.newDistanceSort and let a NaN or +/-Infinity origin coordinate through (for example a value computed as 0/0, or request text parsed with Float.parseFloat, which accepts 'NaN' and 'Infinity') get no IllegalArgumentException, and the search runs with a meaningless order: with a NaN origin every distance is NaN and documents without the field (distance +Infinity) come first, against the documented 'missing values sort last'; with an infinite origin every distance is +Infinity and the hits come back in doc-id order; default IndexWriterConfig, codec and searcher, any index size; query-time only, so the match set, the hit count, other requests and the index are unaffected; released since 8.5.0.
Summary
You build a distance sort with XYDocValuesField.newDistanceSort("p", Float.NaN, 0f), or with an infinite x or y, and expect the IllegalArgumentException that its javadoc promises when the 'location has invalid coordinates', as LatLonDocValuesField.newDistanceSort throws for NaN and as the XYDocValuesField constructor, XYPointField.newDistanceQuery and XYCircle throw for the same values. Instead the sort is built and the search runs, because the XYPointSortField constructor checks only that the field name is not null. With a NaN origin every distance is NaN, which Double.compare orders after +Infinity, so a document without a location (distance +Infinity, documented as 'missing values sort last') is returned before the documents that have one; with an infinite origin all distances tie at +Infinity.
Introduced
88dd1c3f3d4eb37c522b9b00a5ccce979e6efad6 (master, now main), backported to branch_8x as 53cb6acf912f54a07b870fe6726bfee329b62d06; both 'LUCENE-9238: Add new XYPointField, queries and sorting capabilities (#1272)' by iverase (PR apache/lucene-solr#1272, 'Adaptation of LatLonPoint to generic cartesian space', one approval and one comment asking for a CHANGES entry, no inline review comments; migrated issue apache/lucene#10278) [read: git log and blame in the full-history clone; the commit creates XYDocValuesField, XYPointSortField and XYPointDistanceComparator, the XYPointSortField constructor has checked only field != null ever since, and neither parent (8ea947eeb2e, cb68d7d2c57) has the classes]. Born with the feature, not a regression: the LatLon twin LatLonPointSortField validated its origin from its first commit (LUCENE-7099, b3ee746a8b1, 2016-03-11; GeoUtils.checkLatitude/checkLongitude since 6f0488f7302, 2016-03-27), and XYEncodingUtils.checkVal already required finite values one week before (LUCENE-9218, 4a54ffb553ad, 2020-02-14). No commit-level A/B: the introducing commit creates the affected classes, so its parent cannot compile the repro (2020-02-21 (author and commit date of 88dd1c3f3d4 and 53cb6acf912))
First affected version
8.5.0 [exec] (the first release with the XY point classes; 8.4.1 has no such API [exec: the repro fails to compile only at the four uses of XYDocValuesField; lucene-core-8.4.1 has no XY point classes])
Affected versions
every release from 8.5.0 to 10.6.0, 53 releases (19 on 8.x: 8.5.0-8.11.4; 21 on 9.x: 9.0.0-9.12.3; 13 on 10.x: 10.0.0-10.6.0), and main ea57360912ed and 645d4fe14a20 (unreleased 11.0) [exec 2026-10-31: the minimal test fails on the 10.6.0 release jars (SHA-1 checked) with -ea and -da, and the order probe gives the same order there; the minimal test fails on main 645d4fe14a20 with -ea and -da; exec: the finder's JUnit, in three version-adapted sources with identical assertions, fails 3 run / 2 failed on all 52 releases and on main, with the same two messages, also with -da on 8.5.0, 10.5.2 and main (follow-up, 57 failing runs); the independent reproduction's JUnit fails 5 run / 2 failed on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's fails 3 run / 1 failed on main (-ea and -da) and 10.5.2; 8.8.2 to 8.11.4 were covered by executing their SHA-1-checked release jars only, because the history clone's 8.x tags stop at 8.8.1]. Not affected: 8.4.1 and earlier (API absent) [exec]. Not fixed upstream: upstream main cf398bfb142 (2026-10-26) has a byte-identical XYPointSortField.java and branch_10x the same constructor; no open PR adds origin validation [read: GitHub contents and commits API, scan of the changed files of all 411 open apache/lucene PRs]. All runs on aarch64 Linux with JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record verdict new, high confidence): two scripted prior-report searches (75 distinct queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive, plus 8 web queries; 120 and 194 distinct hits, 0 high overlap, every medium hit read), the finder's run (15 queries, 16 hits, all LatLon, GeoPoint or Geo3D distance-sort history), 9 manual GitHub searches (Elasticsearch, OpenSearch and its geospatial plugin, Solr; a code search for 'XYDocValuesField.newDistanceSort' finds only copies of Lucene's own test), 14 JIRA text searches (LUCENE and SOLR), 24 list searches, the changed files of all 411 open apache/lucene PRs and 22 issue, PR and JIRA reads; the intended-behaviour check's own search (24 list and JIRA queries plus GitHub searches) found nothing either. Context, not this bug: LUCENE-9238 / #10278 and apache/lucene-solr#1272 (the introduction; nobody discussed validation), apache/lucene-solr#1252 (LUCENE-9218: a reviewer asked XYEncodingUtils.checkVal to reject non-finite values, one week before the XY sort was added without that check; intent evidence), LUCENE-7102 / #8157 and LUCENE-7104 / #8159 (infinite missing values in the LatLon comparator, 2016; a removed option, not precedent), #13301 (signed-zero equals in geo classes), open #14775 (adds inverseSort() to XYPointSortField, constructor untouched; a fix PR would conflict only trivially). Not fixed: upstream main cf398bfb142 (2026-10-26) has a byte-identical XYPointSortField.java. Gaps: GitHub issue search does not index code, diffs or review comments (covered in part by the open-PR file scan and the reads); list and JIRA search are word-based; private security reports are not visible
Lucene commit tested
main 645d4fe14a20 (gradle in a scratch worktree and the standalone runner, assertions on and off; an order probe on the jars); the 10.6.0 release jars (the minimal test and the probe, assertions on and off); the study's runs on main ea57360912ed and a9779ef3c193 (the files involved are identical) and on all 52 releases 8.5.0 to 10.5.2; the recommended fix in a scratch worktree of 645d4fe14a20. Upstream main 2f424fd428d (2026-10-30) has an identical XYPointSortField.java; 10.6.0 and branch_10_6 have the same constructor.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/XYPointSortField.java:28-35 (at ea57360912ed) (constructor: super(field, Type.CUSTOM, false, Double.POSITIVE_INFINITY), checks only field != null, stores x and y unchecked), :60-67 (equals compares x != other.x)
  • lucene/core/src/java/org/apache/lucene/document/XYDocValuesField.java:79 (at ea57360912ed) and :91 (constructor and setLocationValue: '@throws IllegalArgumentException if ... x or y are infinite or NaN'), :94-95 (XYEncodingUtils.encode on x and y), :133-153 (newDistanceSort javadoc: 'distance in meters', 'missing values sort last' at :140, '@throws IllegalArgumentException if {@code field} is null or location has invalid coordinates.' at :149; the factory at :151 returns new XYPointSortField(field, x, y))
  • lucene/core/src/java/org/apache/lucene/document/XYPointDistanceComparator.java:82-98 (at ea57360912ed) (setBottom builds the competitive box only when bottom < Float.MAX_VALUE), :122-160 (compareBottom: missing document compared as +Infinity, Math.sqrt distance otherwise), :190-208 (sortKey: +Infinity for a missing document, Math.min over sqrt((x-docX)^2+(y-docY)^2) otherwise, NaN for a NaN origin)
  • lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:34-41 (at ea57360912ed) (checkVal 'validates value is a number and finite': Float.isFinite(x) == false throws IllegalArgumentException)
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointSortField.java:29-37 (at ea57360912ed) (the twin constructor calls GeoUtils.checkLatitude and checkLongitude)
  • lucene/core/src/java/org/apache/lucene/document/XYPointField.java:170-179 (at ea57360912ed) (newDistanceQuery: same 'location has invalid coordinates' @throws, enforced through new XYCircle); lucene/core/src/java/org/apache/lucene/geo/XYPoint.java:44-45 and XYCircle.java:51-52 (checkVal on x and y); lucene/test-framework/src/java/org/apache/lucene/tests/geo/BaseXYPointTestCase.java:256-263 (testDistanceIllegal, 'distance query should not accept invalid x/y as origin', NaN origin)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/XYDocValuesField.java:133-153 (at 645d4fe14a20) (newDistanceSort javadoc and factory)
  • lucene/core/src/java/org/apache/lucene/document/XYPointSortField.java:28-35 (at 645d4fe14a20) (constructor)
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointSortField.java:29-38; (at 645d4fe14a20) geo/GeoUtils.java:85-86
  • lucene/core/src/java/org/apache/lucene/document/XYPointDistanceComparator.java:55-58, (at 645d4fe14a20) 77-79, 87-97, 122-160, 190-209
  • lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:34-41, (at 645d4fe14a20) 50-52, 60-89; geo/XYPoint.java:43-46; geo/XYCircle.java:44-54; geo/XYRectangle.java:45-48; geo/XYLine.java:67-68; geo/XYPolygon.java:93-103
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/document/XYDocValuesField.java:79, (at 645d4fe14a20) 91, 94-95, 137, 139-140, 149
  • lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:34-41, (at 645d4fe14a20) 50-52; geo/XYPoint.java:43-46; geo/XYCircle.java:51-52; geo/XYRectangle.java:45-48; geo/XYLine.java:67-68; geo/XYPolygon.java:93-103; geo/XYEncodingUtils.java:60-89 (decode, floatArrayToDoubleArray: no check)
  • lucene/core/src/java/org/apache/lucene/document/XYPointField.java:165-180 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/geo/BaseXYPointTestCase.java:256-263; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestXYPointDistanceSort.java:86
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointSortField.java:34-35; (at 645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:183-192; lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:152-158
  • gh api repos/apache/lucene-solr/pulls/1252/comments (2026-10-31, read-only: "This could be a single test." on XYEncodingUtils.checkVal)
  • lucene/core/src/test/org/apache/lucene/search/TestPointQueries.java:286-320 (at 645d4fe14a20) (testCrazyFloats: FloatPoint with NaN and both infinities)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/document/XYDocValuesField.java:93-97; (at 645d4fe14a20) geo/XYEncodingUtils.java:34-52; geo/XYPoint.java:43-46
  • gh pr view 14775 --repo apache/lucene (2026-10-31, read-only: open, files include XYPointSortField.java)
History
  • a Lucene clone releases/lucene/10.6.0, origin/branch_10_6, 2f424fd428d: XYPointSortField.java constructor (main identical, 10.6.0 and branch_10_6 differ only by the setMissingValue override)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made separately from this text, from the code at 645d4fe14a20 and runs executed for the diagrams; the PNG is the summary still.

Built 2026-11-03 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.