All findings · Lucene finding · LFV-777 · pointvector-distance-missing-value-ignores-nongeo-context

PointVectorStrategy sorts a document without a point as if it were 180 units away in a non-geo context

With PointVectorStrategy in a flat (non-geo) coordinate system, a document without a point gets distance 180 instead of "farthest", so a distance sort lists it before every real document more than 180 units away. Apparently a one-line slip in the 7.0.0 port; every release since has it.

Summary

Priority
Low, rank 351Needs the experimental PointVectorStrategy in a non-geo context with point-less documents, and only ordering and scores change.
Impact
silent wrong results
Since
7.0.0 (LUCENE-7737, 2017); 6.x correctintroduced 2017-06-13 · about 9 years
Affected
7.0.0 to 10.5.2 and main
Status
documented
Found by
code reading while writing a reference, then a cross-strategy probe

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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-06.

The finding in 30 seconds

PointVectorStrategy stores a point as two numeric doc-values fields, x and y, and its distance source must give a document without them some value. It gives 180 * multiplier, a constant that means "farthest" only on a globe measured in degrees.

Summary card. Left: a flat world -1000..1000 with the query point (0, 0) as a star, near at 100, far at 500, and a dashed circle of radius 180 where Lucene puts the document none. Expected ascending order near, far, none; actual near, none, far (none before far). Code: DistanceValueSource.java line 47 this.nullValue = 180 * multiplier; lines 65-83 return DoubleValues.withDefault(..., nullValue), whose advanceExact is true for every document. SpatialContext.GEO: 180 degrees is the farthest distance, so none stays last. Silent: no exception or warning; circle queries never match none. Right: results executed on main with -ea and -da, expected versus actual: distance of none Double.MAX_VALUE versus 180.0; ascending distance sort near, far, none versus near, none, far; the same with missingValue = MAX_VALUE; recip score best first, the same; control BBoxStrategy and control SpatialContext.GEO both near, far, none. Fix (one line, executed): nullValue = strategy.getSpatialContext().isGeo() ? 180 * multiplier : Double.MAX_VALUE; every row then matches. Needs PointVectorStrategy with a non-geo context, hits without a point in a distance-sorted or recip-scored result, and real distances above 180. Since 7.0.0: LUCENE-7737 (2f2e00ffe23, 2017) dropped the isGeo() branch of the LUCENE-4208 rule; 6.6.0 sorts none last.
The three-document example in a flat world -1000..1000: the document without a point lands on a circle of radius 180 around the query point, so it sorts between near (100) and far (500); controls and the one-line fix on the right. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Use PointVectorStrategy with a non-geo SpatialContext (world -1000..1000). Index near at (100, 0), far at (500, 0), and a document none that has no point.
  2. Sort all documents by makeDistanceValueSource from (0, 0), ascending. You expect near (100), far (500), then none last at Double.MAX_VALUE: what BBoxStrategy, SerializedDVStrategy and the prefix-tree strategies give in the same context.
  3. Instead you get near=100, none=180, far=500, with no error. getSortField(false, Double.MAX_VALUE) changes nothing, because the source always reports a value.
  4. The recip-distance score over the box -1000..1000 is wrong the same way: near 0.586, none 0.440, far 0.220. With the fix, none scores about 0.
  5. Why: the source gives a missing point 180 * multiplier in every context: the largest distance on a globe in degrees, but an ordinary distance in a flat world. The 7.0.0 port (LUCENE-7737) dropped the "Double.MAX_VALUE if not geo" branch the other sources keep. The strategy's own queries still never match the point-less document, but a filter built on the distance source (for example distance < 200) does let it through.

Who is affected

Applications using PointVectorStrategy (opt-in spatial-extras module, @lucene.experimental) with a non-geo SpatialContext that sort or boost by distance over a query that also matches documents without a point. No index-size threshold, assertions on or off. Geo contexts and the strategy's own queries are unaffected; caller-built distance filters are not. Every release since 7.0.0.

Mechanism, terms and numbers659 words

PointVectorStrategy (spatial-extras, @lucene.experimental) stores a point as two numeric doc-values fields. Its makeDistanceValueSource returns vector/DistanceValueSource, which gives a document without a point the fixed value 180 * multiplier in every SpatialContext (DistanceValueSource.java:47) and wraps the distance in DoubleValues.withDefault, so the value always counts as present. In a geo context 180 degrees is the largest possible distance and the document sorts last. In a non-geo (Cartesian) context it is an ordinary distance: an ascending distance sort lists the point-less document before every real document more than 180 units away, and makeRecipDistanceValueSource scores it like a document 180 units away. In the repro (world -1000..1000, query point (0, 0)) the order is near=100, none=180, far=500 instead of near, far, none, and the recip scores are near 0.586, none 0.440, far 0.220. The other distance sources use ctx.isGeo() ? 180 * multiplier : Double.MAX_VALUE, the rule LUCENE-4208 set for all strategies in 2012, and so did this class until LUCENE-7737 ported it to DoubleValuesSource for 7.0.0; the same commit kept the rule in Solr's copy of the class. The missingValue of getSortField(reverse, missingValue) cannot override the value. The strategy's own queries never match a point-less document, but a predicate the caller builds on the distance source (in the run, FunctionMatchQuery with distance < 200) lets it through. Restoring the one line fixes it.

Terms

  • PointVectorStrategy: a spatial-extras strategy that indexes one point per document as two DoublePoint fields with numeric doc values (x and y). Its class javadoc calls distance sort "the Strategy's best feature".
  • SpatialContext: the spatial4j context that defines the world and the distance calculator. A geo context measures great-circle distances in degrees (at most 180); a non-geo (Cartesian) context, SpatialContextFactory.geo = false, measures straight-line distances in the world's own units, with no upper bound but the world size.
  • Distance value source: SpatialStrategy.makeDistanceValueSource(point, multiplier) returns a DoubleValuesSource whose value per document is the distance from point times multiplier; it is used for sorting (getSortField) and for the recip score (makeRecipDistanceValueSource, c / (d + c)).
  • Null value: the value the source reports for a document that has no indexed shape.

Mechanism (code at a9779ef3c193)

  1. PointVectorStrategy.makeDistanceValueSource returns new DistanceValueSource(this, queryPoint, multiplier) (PointVectorStrategy.java:182-185).
  2. The constructor sets this.nullValue = 180 * multiplier; whatever the context (DistanceValueSource.java:47).
  3. getValues returns DoubleValues.withDefault(<distance * multiplier>, nullValue); the inner advanceExact is ptX.advanceExact(doc) && ptY.advanceExact(doc), false for a document without a point (DistanceValueSource.java:58-84).
  4. DoubleValues.withDefault always returns true from advanceExact and then returns the default (DoubleValues.java:71-86), so the sort comparator never sees a missing value and the missingValue of getSortField(reverse, missingValue) is never used (DoubleValuesSource.java:667-716).
  5. Geo: 180 times the multiplier is the largest possible distance (for a positive multiplier, as in the one-argument overload and every repro), so the document sorts last. Non-geo: spatial4j's default calculator is Cartesian unless the caller configures another, real distances can be any size, and 180 lands between them.
  6. The recip score wraps the same source (SpatialStrategy.java:133-145, ReciprocalDoubleValuesSource.java:61-63), so the document scores c / (180 + c): 0.440 in the repro, against 0.220 for a real document 500 units away.
  7. The sibling sources branch on the context: DistanceToShapeValueSource.java:52 (BBoxStrategy, SerializedDVStrategy) and ShapeFieldCacheDistanceValueSource.java:60 (prefix-tree strategies) use ctx.isGeo() ? 180 * multiplier : Double.MAX_VALUE.
  8. The strategy's own queries are not affected: the circle query's approximation is the x/y range query (PointVectorStrategy.java:195-198, :209-222), which never matches a document without a point, so the distance check (:283) never sees one; the rectangle query is the same range query (:193-194).
  9. A predicate the caller builds on the distance source is affected: FunctionMatchQuery (queries module) matches when advanceExact(doc) && filter.test(doubleValue()) (FunctionMatchQuery.java:98-99), and withDefault answers true with 180. FunctionMatchQuery(dist, d -> d < 200) on the three documents matches near and none on main (assertions on and off), near only with the fix.
Exposure and likelihood362 words
  • Who is hit. Applications that use PointVectorStrategy with a non-geo SpatialContext, have documents without a point, and sort or score by makeDistanceValueSource / makeRecipDistanceValueSource over a query that also matches those documents (a text query, MatchAllDocsQuery, a filter on other fields), or that filter on the distance source with a predicate of their own. An open upstream issue recommends this strategy "for general 2D" (LUCENE-9689, #10728).
  • When it shows. Whenever such a result set also holds real documents more than 180 units from the query point: ordinary in a Cartesian world in metres or other fine units, and possible even in a non-geo world sized like the globe (a point at (170, 80) is 187.88 from the origin and sorts after the point-less document). Deterministic; no index-size threshold (three documents reproduce it); assertions on or off. The JUnit tests use LuceneTestCase's randomized directory, writer config, codec and searcher; the finder's cross-strategy probe, the version-portable probe and the diagram probe use a plain new IndexWriterConfig() (default codec) and new IndexSearcher(reader) and show the same 180 and the same wrong order.
  • Opt-in on three counts: the spatial-extras module, the experimental strategy, and a non-geo context (spatial4j's SpatialContextFactory defaults to geo). Nothing else needs configuring.
  • Silent. No exception and no log line: the nearest-first page simply has point-less documents ahead of real ones, and a distance boost ranks them like documents 180 units away.
  • Not affected. Which documents the strategy's own rectangle and circle queries match; geo contexts; indexes where every document has a point; result sets in which every real document is within 180 units; circle-filtered queries (they exclude point-less documents).
  • Caller-built distance filters. A predicate on the distance source, such as FunctionMatchQuery(dist, d -> d < 200), lets the point-less document through today (it sees 180); the fix changes that hit set (executed on main and with the fix).
  • Why rare. All of the above must hold, and Solr, the main historical downstream of this strategy style, used its own copy of the class that kept the rule (at the introducing commit; Solr's later history was not traced). No report since 7.0.0 (2017) was found in the recorded searches.

Minimal repro

The test shows that in a non-geo SpatialContext, PointVectorStrategy's distance sort gives a document without a point the distance 180, so it sorts before a real document 500 units away.

TestPointVectorMissingDistanceNonGeo.java (JUnit test)61 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.spatial.vector;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.context.SpatialContextFactory;

public class TestPointVectorMissingDistanceNonGeo extends LuceneTestCase {

  public void testDocWithoutPointSortsLastInNonGeoContext() throws Exception {
    SpatialContextFactory factory = new SpatialContextFactory();
    factory.geo = false;
    SpatialContext ctx = factory.newSpatialContext();
    PointVectorStrategy strategy = PointVectorStrategy.newInstance(ctx, "pv");
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document withPoint = new Document();
      for (Field f : strategy.createIndexableFields(ctx.getShapeFactory().pointXY(500, 0))) {
        withPoint.add(f);
      }
      w.addDocument(withPoint); // doc 0
      w.addDocument(new Document()); // doc 1, no point
      try (DirectoryReader r = DirectoryReader.open(w)) {
        Sort byDistance =
            new Sort(
                strategy
                    .makeDistanceValueSource(ctx.getShapeFactory().pointXY(0, 0))
                    .getSortField(false));
        TopDocs top = new IndexSearcher(r).search(new MatchAllDocsQuery(), 2, byDistance);
        // expected: doc 0 (distance 500) first, as in the other strategies; actual: doc 1 (180)
        assertEquals(0, top.scoreDocs[0].doc);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-777/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/spatial-extras/build/libs/lucene-spatial-extras-$V.jar
CP=$CP:$LUCENE/lucene/spatial3d/build/libs/lucene-spatial3d-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.spatial.vector.TestPointVectorMissingDistanceNonGeo
# the module(s) spatial-extras also need their third-party runtime jars
Expected vs actual
Expected doc 0 (point at (500, 0)) first in an ascending distance sort from (0, 0); actual doc 1, the one without a point (expected:<0> but was:<1>), with -ea and -da; passes with the isGeo() ? 180 : Double.MAX_VALUE rule restored.

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

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<0> but was:<1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.spatial.vector.TestPointVectorMissingDistanceNonGeo.testDocWithoutPointSortsLastInNonGeoContext(TestPointVectorMissingDistanceNonGeo.java:57)

Why it looks unintended

  • LUCENE-4208 set one rule for all strategies, "180 if geo, Double.MAX_VALUE otherwise", and every other distance source still follows it.
  • This class followed it until the 7.0 port, whose same commit kept the rule in Solr's copy of the class and announced no behaviour change.
  • Cartesian contexts are documented as supported; a sort field's missing value cannot override it.
The full argument368 words
  • The rule was stated by the module's author. LUCENE-4208 (#5280; trunk commit 577e25c7ffc, 2012-09-15, and its 4.x-branch twin 11e5aca556b, the one in the 4.0.0 tag), patch comment: "Standardized behavior of makeDistanceValueSource across the 3 strategies, including null value behavior (it's '180' if geo, Double.MAX_VALUE otherwise)."
  • Every other implementation still follows it: DistanceToShapeValueSource.java:52 (BBoxStrategy, SerializedDVStrategy) and ShapeFieldCacheDistanceValueSource.java:60 (prefix-tree strategies). On the same data only PointVectorStrategy in a non-geo context gives 180. (CompositeSpatialStrategy and NumberRangePrefixTreeStrategy do not implement distances; they throw UnsupportedOperationException.)
  • This class had the rule from 2012 to 7.0. 4.0.0 to 4.4.0 have isGeo() ? 180 : Double.MAX_VALUE; LUCENE-5118 (trunk 47bf70812ba, 4.x 7677c9e98b7, in 4.5.0) added the multiplier and kept the rule; 6.5.1, 6.6.0 and 6.6.6 pass the repro scenario.
  • The drop looks like a slip in a mechanical port (an inference: no commit message, comment or CHANGES line states the intent). LUCENE-7737 (2f2e00ffe23, "Remove spatial-extras dependency on queries module") moved nullValue into the constructor as 180 * multiplier. In the same commit Solr's copy of the class got (strategy.getSpatialContext().isGeo() ? 180 * multiplier : Double.MAX_VALUE). Its CHANGES entry (7.0.0, API Changes) announces the dependency change and the move from ValueSource to DoubleValuesSource extensions, but no behaviour change, and the 21-comment issue thread never mentions missing values.
  • Cartesian contexts are documented: the spatial-extras overview lists, among the capabilities of Spatial4j, which the module uses heavily, "Both geospatial contexts and plain 2D Euclidean/Cartesian contexts are supported" (overview.html:40-45; a statement about Spatial4j, not about PointVectorStrategy in particular).
  • The tests assume uniform behaviour. TestDistanceStrategy runs all six strategies, PointVectorStrategy included, through one test that adds a document without a shape ("test score for nothing") and expects 180 and a recip score of about 0 (TestDistanceStrategy.java:38-104). It only uses SpatialContext.GEO, which is why the change went unnoticed.
  • Core states the same convention for Cartesian points: XYDocValuesField.newDistanceSort, "If a document is missing the field, then by default it is treated as having Double#POSITIVE_INFINITY distance (missing values sort last)" (XYDocValuesField.java:139-140).
  • The sort field's missing value cannot opt out: DoubleValues.withDefault hides the missing value from getSortField(reverse, missingValue) (other workarounds exist; see the fix section).
The devil's-advocate check162 words

Strongest case for "works as designed" (rated moderate by reviewers): no javadoc gives the missing-document value (SpatialStrategy.java:108-113 is silent); the API speaks of degrees (makeDistanceValueSource(Point) is "a multiplier of 1.0 (i.e. units of degrees)", :101-102); PointVectorStrategy is @lucene.experimental and DistanceValueSource @lucene.internal; the only upstream test of the value uses GEO and expects 180; and no complaint was found in nine years.

Why it fails: the value was a deliberate, stated rule shared by all strategies; the commit that changed it kept the rule in its own Solr copy and announced no behaviour change, so the change looks accidental; Cartesian contexts are documented; the "degrees" wording describes the default geo case; the experimental tags mark API instability rather than license a silent behaviour change; and the sort field's missing value cannot override the value. A maintainer could still prefer to document the current value instead (the documentation-only option). The contract is a design rule and sibling code, not javadoc.

Suggested fix

Recommended Restore the isGeo() rule in DistanceValueSource (one line)

Restore the rule in DistanceValueSource's constructor: nullValue = isGeo() ? 180 * multiplier : Double.MAX_VALUE, as the sibling sources and this class before 7.0 do (one line). Geo behaviour is unchanged. The new test, the whole spatial-extras suite (119 tests, assertions on and off) and the module's format and lint checks pass.

-    this.nullValue = 180 * multiplier;
+    this.nullValue = strategy.getSpatialContext().isGeo() ? 180 * multiplier : Double.MAX_VALUE;
All options and trade-offs6 options · 627 words

Option A is the one validated; the javadoc sentence is optional and was validated together with it. Workarounds until a fix ships: if point-less documents need not be returned, add new FieldExistsQuery(strategy.getFieldName() + PointVectorStrategy.SUFFIX_X) as a FILTER clause (both public; getFieldNameX() is package-private), which returned near, far when run from a package outside Lucene; otherwise sort first on an application field that marks documents with a location, then by distance, or wrap the distance source in an application DoubleValuesSource that checks the x doc values itself (read, not run).

Restore the isGeo() rule in DistanceValueSource (one line) (validated by the checks) (recommended)

In the constructor (DistanceValueSource.java:47), use the same expression as DistanceToShapeValueSource and ShapeFieldCacheDistanceValueSource, and as this class before 7.0.

-    this.nullValue = 180 * multiplier;
+    this.nullValue = strategy.getSpatialContext().isGeo() ? 180 * multiplier : Double.MAX_VALUE;

Trade-offs

  • Geo contexts keep 180 * multiplier, which TestDistanceStrategy asserts for all six strategies; for any multiplier it restores exactly the pre-7.0 expression.
  • In a non-geo context a point-less document sorts last in ascending order and first in descending order, as with the other strategies; its recip score becomes about 0 (7.87E-307), the same as BBoxStrategy's.
  • It also changes the hit set of predicates the caller builds on the distance source: FunctionMatchQuery(dist, d -> d < 200) stops matching the point-less document (executed). The strategy's own queries match the same documents as before.
  • The same diff applies to the 10.5.2 file, which is byte-identical to main's.

What the checks validated

  • Scratch worktree of main a9779ef3c193 with the fix, the javadoc sentence below and the new test, gradle: the new test passes 2 of 2; the whole :lucene:spatial-extras:test passes with assertions on (seed 777B0004) and off (seed 777B0005): 27 suites, 119 tests, 0 failures, 5 skipped by their own @Ignore/@Nightly annotations, including TestDistanceStrategy (12 tests) and TestPointVectorStrategy (4).
  • :lucene:spatial-extras:check -x test passes (checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest), after tidy rewrapped the new test's class javadoc; the tidied test passes 2 of 2 again.
  • Standalone runner with the patched class shadowed: the new test (assertions on and off), the finder's test (2 of 2) and the independent reproducer's (5 of 5) pass.

Also state the missing-document value in SpatialStrategy.makeDistanceValueSource (optional) (validated by the checks)

Add to the javadoc of makeDistanceValueSource(Point, double) (SpatialStrategy.java:108-112): "A document without an indexed shape gets {@code 180 * multiplier} (the largest geo distance) if the context is geo, and {@link Double#MAX_VALUE} otherwise."

Trade-offs

Turns the issue-level rule into a documented contract that a test can pin. True of every implementation once the fix is in (Composite and NumberRangePrefixTree throw).

What the checks validated

Applied together with the fix in the runs above.

Use Double.POSITIVE_INFINITY for non-geo

Matches core's XYDocValuesField.newDistanceSort convention; the recip score becomes exactly 0. Not run.

Trade-offs

Differs from the two sibling sources (Double.MAX_VALUE), so one consistent rule would mean changing all three; no benefit for sorting.

Report "no value" and let the caller's missingValue apply (drop withDefault)

Not run; a coherent alternative contract (an explicit missingValue would then work), but not recommended here.

Trade-offs

The one-argument getSortField(reverse) uses a missingValue of 0 (DoubleValuesSource.java:111-112), so by default point-less documents would sort first; it also changes the geo value TestDistanceStrategy asserts and makes this strategy differ from the others again.

Build PointVectorStrategy's distance on DistanceToShapeValueSource

The direction LUCENE-5804 (#6866, open since 2014) proposed for all strategies. Not run.

Trade-offs

A larger refactor; not needed for this bug.

Documentation only: keep 180 and document it

State the current value on SpatialStrategy.makeDistanceValueSource and PointVectorStrategy. Not run.

Trade-offs

Defensible if maintainers do not treat the LUCENE-4208 rule as a contract, but this strategy then keeps differing from the others in non-geo contexts, and the wrong order stays.

Downstream note

Where an application ranks listings by distance in a Cartesian context, a document that omits its location can appear ahead of real ones more than 180 units away; the strategy's own queries never match it, but a distance filter the application builds on this source (for example distance < 200) lets it through.

How it was found

While writing a reference for the value a distance source gives a document without a shape, the finder noticed in the code that PointVectorStrategy's source alone lacks a geo/non-geo branch. A probe over four strategies, geo and Cartesian, confirmed that only this one gives 180 in a Cartesian context; independent AI tools then reproduced, reviewed and judged it.

The method in full144 words

Method: semi-formal (the hunt's tier for tool-guided finds that are not a formal tool's own output): a hunt unit (reference semantics for the spatial-extras recip-distance and shape value sources), 2026-10-27, stock main jars.

  • While writing the reference for the missing-document value of makeDistanceValueSource, the finder read the three distance sources and saw that vector/DistanceValueSource alone has no isGeo() branch.
  • A bounded cross-strategy differential probe ran PointVectorStrategy, BBoxStrategy, SerializedDVStrategy and RecursivePrefixTreeStrategy, each in a geo and a Cartesian context, on the same two documents: seven of the eight combinations agree (180 in geo, Double.MAX_VALUE in Cartesian, far before none); only PointVectorStrategy in the Cartesian context gives 180.0 and sorts none first.
  • It was then reduced to a public-API JUnit and checked by an independent reproducer, reviews by another model family, a devil's advocate, an adjudication, the version-history runs and an audit (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (best-effort search, 2026-10-27): 138 recorded queries over GitHub, the mailing lists, JIRA and the web, plus the changed files of all 408 open PRs and a 35-query run by the devil's advocate. LUCENE-4208 (#5280) states the rule the code now breaks; LUCENE-7737 (#8788) is the refactor that dropped it.

Searches4 searches

No issue, PR, commit, CHANGES entry, JIRA ticket, list thread, downstream issue or web page reports that PointVectorStrategy's distance source gives a point-less document 180 in a non-geo context, or that LUCENE-7737 lost its isGeo() branch. Not fixed: upstream main 20ced71c1e6 (2026-10-27) still has the line, and no open PR touches DistanceValueSource.java.

Closest items, none a report of this bug: #5280 (LUCENE-4208, 2012) states the rule; #8788 (LUCENE-7737, 2017) dropped it, and its 21 comments never mention missing values; #6182 (LUCENE-5118) added the multiplier and kept the rule; #10728 (LUCENE-9689, open) recommends PointVectorStrategy "for general 2D"; #6866 (LUCENE-5804, open since 2014) proposes sharing DistanceToShapeValueSource; open PR #12295 touches PointVectorStrategy.java (casts only). Gaps: GitHub multi-word search acts like a phrase search (several phrasings used); one web query was rate-limited and ran in a variant phrasing; PR review comments, attachments and private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/solr, two downstream search engines, Lucene.NET)2026-10-27two scripted prior-report search runs (51 and 57 queries; the 57-query run returned 456 distinct hits) and 15 direct searches (PointVectorStrategy, DistanceValueSource, makeRecipDistanceValueSource, DistanceToShapeValueSource, spatial labels), plus a third 35-query run by the devil's advocateno report; 6 medium-overlap hits read, none the same bug
Open PRs2026-10-27the changed files of all 408 open apache/lucene PRs10 touch spatial-extras; none touches DistanceValueSource.java (#12295 touches PointVectorStrategy.java, casts only)
JIRA archive and mailing lists2026-10-276 JQL queries (LUCENE, SOLR); 20 list queries per tool run over dev@, java-user@, issues@ and commits@; 4 threads read in fullusage questions and Solr's own PointVector field type deprecation; nothing about missing values
Web and local history2026-10-279 web queries; git log -G nullValue and -S on the rule in the full historynothing relevant; 2f2e00ffe23 is the only commit that removed the rule; no fix commit

History

Introduced in 7.0.0 by LUCENE-7737 (2017), which ported the class to DoubleValuesSource and wrote 180 * multiplier where the rule had been; the same commit kept the rule in Solr's copy. Executed: 6.5.1, 6.6.0 and 6.6.6 pass; 16 releases from 7.0.0 to 10.5.2 and main fail.

Versions tested5 rows

Introduced by LUCENE-7737 "Remove spatial-extras dependency on queries module" (#8788; a patch attached to the JIRA issue, no pull request; author @romseygeek, reviewed by @dsmiley and @jpountz): commit 2f2e00ffe23, authored 2017-06-13, committed 2017-06-27, first released in 7.0.0 (tag commit 2017-09-08). It removed nullValue = (strategy.getSpatialContext().isGeo() ? 180 * multiplier : Double.MAX_VALUE) and added this.nullValue = 180 * multiplier; in the constructor; Solr's legacy copy of the class kept the rule in the same commit.

Rule origin: LUCENE-4208 (@dsmiley, 2012-09-15): trunk commit 577e25c7ffc and its 4.x-branch twin 11e5aca556b, the one in the 4.0.0 tag; 4.0.0 shipped isGeo() ? 180 : Double.MAX_VALUE. Kept by LUCENE-5118 (trunk 47bf70812ba, 4.x 7677c9e98b7, in 4.5.0), which added the multiplier. No later commit touched the null value except the 2021 reformat (2cbf261032d, LUCENE-9570); the other later changes, LUCENE-8042 (276e317e942, isCacheable) and LUCENE-7736 (2a4dd499bbd, rewrite), leave it alone.

Commit isolation: swapping only DistanceValueSource.java flips the result both ways: 7.0.0 passes with the rule restored, 6.6.6 fails with the 7.0 value. No Lucene build at the parent commit was made. Every release from 7.0.0 to 10.5.2 contains the commit: the 65 release tags of the history clone in that range, plus the nine releases 8.8.2 to 8.11.4, which were cut from branch_8x, are not tagged in the clone and were all executed.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test: 2 of 2 fail (gradle and the standalone runner, assertions on and off); upstream main 20ced71c1e6 still has the line
10.5.2, 9.12.3 affectedthis doc's test: 2 of 2 fail, the same messages (10.5.2 assertions on, 9.12.3 off); the history probe fails with both
7.0.0 - 10.0.0 (14 releases run) release sweepmissing=180.0, order none,far: 7.0.0 (assertions on and off), 7.7.3, 8.0.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0, 10.0.0
7.0.0 first releasefails; passes with only the rule restored in DistanceValueSource.java
6.6.0, 6.6.6, 6.5.1 not affectedDouble.MAX_VALUE, order far,none (6.6.0 with assertions on and off); 6.6.6 fails with only the 7.0 value swapped in

Independent checks

An independent reproducer, a devil's advocate and an audit found it real with their own runs, reviews by another model family agreed on the root cause, and an adjudication confirmed it by re-running a test with a fresh seed. This doc's own test reproduced it through gradle and validated the fix.

Checks and verdicts433 words

Every check on main ran on the stock jars with assertions on and off (fix checks with a shadowed class), except a separate re-run of the finder's test, which ran with assertions on only. The reviews by the other model family (OpenAI GPT-5.6) read main ea57360912ed, whose spatial-extras, DoubleValues and DoubleValuesSource sources equal a9779ef3c193's; they could not execute Java themselves, and separate runs re-ran the finder's test on main.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateCross-strategy probe (4 strategies x geo and Cartesian); public-API JUnit fails 1 of 2 on main (-ea, -da), 10.5.2 and 9.12.3, its sibling-strategy control passes; passes with the one-line fix.
cross-model review, refute lens procedural not real (low)Another model family (OpenAI GPT-5.6), reading only: it votes not real when nothing was executed. It agreed with the root cause, found the inputs in contract and rated the intended-behaviour case moderate and "weakened substantially"; a separate run re-ran the finder's test (1 of 2 fail).
Independent reproducer realOwn 5-test JUnit: 3 fail on main (-ea, -da), 10.5.2 and 9.12.3 (value 180.0, order [near, none, far], recip 0.440 above 0.220); BBoxStrategy and geo controls pass; its fix passes 5 of 5; upstream TestDistanceStrategy and TestPointVectorStrategy pass 16 of 16 with and without the fix.
Cross-model review, intended lens real (medium)OpenAI GPT-5.6, reading only: intended-behaviour argument moderate, rejected on the sibling sources, the LUCENE-4208 rule, the unannounced change and the withDefault mechanics. A separate run re-ran the finder's test (1 of 2 fail, -ea, -da; 2 of 2 pass with the fix).
Devil's advocate realFive intended-behaviour arguments, none survives; reach probe: same wrong order with spatial4j's default non-geo world, a globe-sized non-geo world and getSortField(false, Double.MAX_VALUE); reproduced on main and 10.5.2.
adjudication confirmed (high)Re-ran the independent reproduction's JUnit on a clean main with seed 1BADC0DE: 3 of 5 fail with -ea and -da; 5 of 5 pass with the fix. Security none, not a duplicate.
credibility audit downgrade (likelihood rare)Re-ran the finder's test (fails 1 of 2, -ea, -da), re-read the code and history; lowered the likelihood to rare because Solr's own copy kept the rule and nobody has reported it since 2017.
this doc reproduced, fix validatedNew 2-test JUnit: 2 of 2 fail on main through gradle and the standalone runner (assertions on and off), on 10.5.2 and 9.12.3; with the fix it and the whole spatial-extras suite (119 tests, assertions on and off) pass.

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,395 words

Finding record

LFV-777, snapshot of 2026-11-06

Registry title
PointVectorStrategy's distance value source gives a document without a point the distance 180 * multiplier even in a non-geo (Cartesian) SpatialContext, so an ascending distance sort lists it before real documents farther than 180 units and makeRecipDistanceValueSource scores it as if it were 180 units away (the other strategies that implement makeDistanceValueSource use Double.MAX_VALUE there; regression in 7.0.0 from LUCENE-7737)
Area
spatial-extras / PointVectorStrategy distance value source (vector/DistanceValueSource missing-document value; makeDistanceValueSource, makeRecipDistanceValueSource)
Priority
Low, rank 351: Needs the experimental PointVectorStrategy in a non-geo context with point-less documents, and only ordering and scores change.
Impact class
silent wrong results
Impact note
Applications that use the @lucene.experimental PointVectorStrategy of the opt-in spatial-extras module with a non-geo (Cartesian) SpatialContext, and sort or score by makeDistanceValueSource or makeRecipDistanceValueSource over a query that also matches documents without a point, get those documents ordered and scored as if they were 180 * multiplier units away, with the default IndexWriterConfig, codec and searcher, at any index size and with -ea or -da: an ascending distance sort lists them before every real document farther than 180 units, and the recip score ranks them above such documents. Which documents match does not change (the circle query's bounding-box clause never matches a point-less document), geo contexts are unaffected (180 is the largest geo distance), and callers cannot work around it with getSortField(reverse, missingValue), because DoubleValues.withDefault makes advanceExact always return true; released since 7.0.0.
Summary
You index points with PointVectorStrategy in a non-geo SpatialContext (for example world bounds -1000..1000), add a document without a point, and sort a query by makeDistanceValueSource(point).getSortField(false), expecting the point-less document last, as BBoxStrategy, SerializedDVStrategy and the prefix-tree strategies do: in a non-geo context they give it Double.MAX_VALUE, the rule LUCENE-4208 set for all strategies ('180 if geo, Double.MAX_VALUE otherwise'). Instead it gets distance 180.0, so with a document at (500, 0) and the query point (0, 0) the ascending order is none,far instead of far,none, and makeRecipDistanceValueSource over the query box -1000..1000 scores it 0.440 against 0.220 for a real document 500 units away. The cause: vector/DistanceValueSource sets nullValue = 180 * multiplier with no isGeo() branch; the branch was dropped in the 7.0 port from ValueSource to DoubleValuesSource (LUCENE-7737).
Introduced
2f2e00ffe235ba590f43bf07dabd4201e6da53c6, 'LUCENE-7737: Remove spatial-extras dependency on queries module' (JIRA LUCENE-7737, migrated as apache/lucene#8788; a patch attached to the issue, no pull request; author romseygeek, reviewed by dsmiley and jpountz; lucene-solr master). While porting vector/DistanceValueSource from ValueSource to DoubleValuesSource it removed 'nullValue = (strategy.getSpatialContext().isGeo() ? 180 * multiplier : Double.MAX_VALUE)' and added 'this.nullValue = 180 * multiplier;' in the constructor. The same commit kept the rule in the Solr legacy copy of the class (solr/core/.../legacy/DistanceValueSource.java) and in util/DistanceToShapeValueSource and util/ShapeFieldCacheDistanceValueSource. The rule came from 577e25c7ffc (LUCENE-4208, 2012, in 4.0.0) and was kept by 47bf70812ba (LUCENE-5118, the multiplier). [read: git blame at the study base, git log -G nullValue and git log -S on the rule in the history clone; exec: release-boundary runs 6.6.0 pass / 7.0.0 fail, and a single-file swap of DistanceValueSource.java that makes 7.0.0 pass with the rule restored and 6.6.6 fail with the 7.0 value; no Lucene build at the parent commit] (2017-06-13 (authored), 2017-06-27 (committed; LUCENE-7737 resolved the same day))
First affected version
7.0.0 [exec] (fails with -ea and -da; tag commit 2017-09-08). 6.6.0, the last release before the commit (tag commit 2017-05-29), and 6.6.6, the latest 6.x, pass [exec], as does 6.5.1; 4.0.0 to 6.6.6 have the isGeo() rule [read: tag sources of 4.0.0, 5.5.5, 6.5.1, 6.6.0, 6.6.6]
Affected versions
every release from 7.0.0 through 10.5.2 (all of 7.x, 8.x, 9.x and 10.x: 65 release tags contain 2f2e00ffe23, and 8.8.2 to 8.11.4 were cut from branch_8x) and main a9779ef3c193. Executed (JDK 25, the follow-up's version-portable main(), public API only): fails on 16 releases, 7.0.0, 7.7.3, 8.0.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0 to 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (7.0.0, 9.12.3 and 10.5.2 also with -da), and on main (-ea and -da); passes on 6.5.1, 6.6.0 (-ea and -da) and 6.6.6. The finder's JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check reproduced on main (-ea, -da) and 10.5.2 (-da). The remaining 7.x to 10.x releases contain the commit [read: tags]; the file is byte-identical in 9.0.0, 9.12.3, 10.5.2 and main. Not fixed upstream: upstream main f7bcc0abd81 (2026-10-27) still has 'this.nullValue = 180 * multiplier;' and no open PR touches DistanceValueSource.java [read].
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
semi-formal: code reading; also reached by fuzzing/PBT
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record, verdict new): about 145 queries: two scripted prior-report searches (the finder's 51 queries; the follow-up's 57 queries, 456 distinct hits, 0 high and 6 medium overlap) over apache/lucene issues, PRs and commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 15 manual gh searches over apache/lucene, apache/solr, Elasticsearch, OpenSearch and Lucene.NET; 6 JQL queries over LUCENE and SOLR; 9 web searches; 14 issue threads and 4 list threads read in full; the changed files of all 408 open apache/lucene PRs; plus the intended-behaviour check's own 35-query scripted prior-report search. Related, not reports: #5280 (LUCENE-4208, closed 2012; states the intended rule: 'Standardized behavior of makeDistanceValueSource across the 3 strategies, including null value behavior (it's 180 if geo, Double.MAX_VALUE otherwise)'), #8788 (LUCENE-7737, closed 2017; the refactor that dropped the branch; its 21 comments never mention missing values), #10728 (LUCENE-9689, open; recommends PointVectorStrategy 'for general 2D'), #6182 (LUCENE-5118, added the multiplier and kept the rule), #6866 (LUCENE-5804, open since 2014, an API proposal around DistanceToShapeValueSource; predates the regression). Not fixed upstream: upstream main f7bcc0abd81 (2026-10-27) still has the line, and of the 10 open PRs that touch spatial-extras none touches DistanceValueSource.java (#12295 touches PointVectorStrategy.java, casts only). Gaps: GitHub multi-word search acts like a phrase search (several phrasings used); one web query was rate-limited and ran in a variant phrasing; one 2015 Q&A page could not be read (it predates the regression).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 (assertions on) and 9.12.3 (assertions off) release jars through the standalone runner. A version-portable probe ran on 19 releases from 6.5.1 to 10.5.2 and on main. The cross-model reviews read main ea57360912ed, whose spatial-extras, DoubleValues and DoubleValuesSource sources equal a9779ef3c193's. The fix was validated in the scratch worktree: the new test, the whole :lucene:spatial-extras:test (assertions on and off) and :lucene:spatial-extras:check -x test.

Lucene and upstream sources

Finding record
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/vector/DistanceValueSource.java:30-48 (at a9779ef3c193) (@lucene.internal; constructor :43-48, :47 'this.nullValue = 180 * multiplier;') and :58-83 (getValues returns DoubleValues.withDefault(..., nullValue); advanceExact is ptX.advanceExact(doc) && ptY.advanceExact(doc))
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/util/DistanceToShapeValueSource.java:52 (at a9779ef3c193) and util/ShapeFieldCacheDistanceValueSource.java:60 ('ctx.isGeo() ? 180 * multiplier : Double.MAX_VALUE')
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/vector/PointVectorStrategy.java:80 (at a9779ef3c193) (@lucene.experimental), :183-184 (makeDistanceValueSource returns new DistanceValueSource), :195-198 (circle query: DistanceRangeQuery over makeWithin(bbox), so point-less documents never match)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/SpatialStrategy.java:100-113 (at a9779ef3c193) (makeDistanceValueSource javadoc: distance to the indexed shape's center times the multiplier; nothing about documents without a shape) and :126-145 (makeRecipDistanceValueSource: zScaling/(d + zScaling) over makeDistanceValueSource(center, 1.0))
  • lucene/core/src/java/org/apache/lucene/search/DoubleValues.java:71-86 (at a9779ef3c193) (withDefault: advanceExact always returns true, so a sort field's missingValue is never used)
  • lucene/spatial-extras/src/java/overview.html:44 (at a9779ef3c193) ('Both geospatial contexts and plain 2D Euclidean/Cartesian contexts' are supported)
The finding in 30 seconds
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/vector/DistanceValueSource.java:30-48, :58-84 (at a9779ef3c193)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/vector/PointVectorStrategy.java:54-82, :182-185, :193-198, :209-222, :283
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/SpatialStrategy.java:100-113, :126-145; lucene/spatial-extras/src/java/org/apache/lucene/spatial/util/ReciprocalDoubleValuesSource.java:61-63
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/util/DistanceToShapeValueSource.java:52; lucene/spatial-extras/src/java/org/apache/lucene/spatial/util/ShapeFieldCacheDistanceValueSource.java:60
  • lucene/core/src/java/org/apache/lucene/search/DoubleValues.java:71-86; DoubleValuesSource.java:105-122, :667-716
Who is affected
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/vector/PointVectorStrategy.java:54-56 ("best feature is decent distance sort"), :80 (@lucene.experimental)
  • github.com/apache/lucene/issues/10728 (LUCENE-9689)
Why it looks unintended
  • github.com/apache/lucene/issues/5280 (LUCENE-4208; patch comment quoted, read 2026-10-27)
  • lucene/spatial-extras/src/java/overview.html:40-45; lucene/spatial-extras/src/test/org/apache/lucene/spatial/TestDistanceStrategy.java:38-104
  • lucene/core/src/java/org/apache/lucene/document/XYDocValuesField.java:139-140
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:105-122; lucene/core/src/java/org/apache/lucene/document/XYDocValuesField.java:139-140
  • github.com/apache/lucene/issues/6866 (LUCENE-5804)

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

Repro files

About the diagrams

A summary still (the PNG), made in parallel with this doc from an executed probe of the main jars, with the fix as control.

Built 2026-11-06 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.