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.
- Use
PointVectorStrategywith a non-geoSpatialContext(world -1000..1000). Index near at (100, 0), far at (500, 0), and a document none that has no point. - Sort all documents by
makeDistanceValueSourcefrom (0, 0), ascending. You expect near (100), far (500), then none last atDouble.MAX_VALUE: what BBoxStrategy, SerializedDVStrategy and the prefix-tree strategies give in the same context. - 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. - 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.
- Why: the source gives a missing point
180 * multiplierin 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_VALUEif 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.
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.
- PointVectorStrategy: a spatial-extras strategy that indexes one point per document as two
DoublePointfields 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 aDoubleValuesSourcewhose value per document is the distance frompointtimesmultiplier; 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.
PointVectorStrategy.makeDistanceValueSourcereturnsnew DistanceValueSource(this, queryPoint, multiplier)(PointVectorStrategy.java:182-185).- The constructor sets
this.nullValue = 180 * multiplier;whatever the context (DistanceValueSource.java:47). getValuesreturnsDoubleValues.withDefault(<distance * multiplier>, nullValue); the inneradvanceExactisptX.advanceExact(doc) && ptY.advanceExact(doc), false for a document without a point (DistanceValueSource.java:58-84).DoubleValues.withDefaultalways returns true fromadvanceExactand then returns the default (DoubleValues.java:71-86), so the sort comparator never sees a missing value and themissingValueofgetSortField(reverse, missingValue)is never used (DoubleValuesSource.java:667-716).- 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.
- The recip score wraps the same source (
SpatialStrategy.java:133-145,ReciprocalDoubleValuesSource.java:61-63), so the document scoresc / (180 + c): 0.440 in the repro, against 0.220 for a real document 500 units away. - The sibling sources branch on the context:
DistanceToShapeValueSource.java:52(BBoxStrategy, SerializedDVStrategy) andShapeFieldCacheDistanceValueSource.java:60(prefix-tree strategies) usectx.isGeo() ? 180 * multiplier : Double.MAX_VALUE. - 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). - A predicate the caller builds on the distance source is affected:
FunctionMatchQuery(queries module) matches whenadvanceExact(doc) && filter.test(doubleValue())(FunctionMatchQuery.java:98-99), andwithDefaultanswers 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
PointVectorStrategywith a non-geoSpatialContext, have documents without a point, and sort or score bymakeDistanceValueSource/makeRecipDistanceValueSourceover 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) andnew 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
SpatialContextFactorydefaults 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.
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.
/*
* 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);
}
}
}
}
# 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-eaand-da; passes with the isGeo() ? 180 : Double.MAX_VALUE rule restored.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- 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 twin11e5aca556b, 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) andShapeFieldCacheDistanceValueSource.java:60(prefix-tree strategies). On the same data onlyPointVectorStrategyin a non-geo context gives 180. (CompositeSpatialStrategyandNumberRangePrefixTreeStrategydo not implement distances; they throwUnsupportedOperationException.) - 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 (trunk47bf70812ba, 4.x7677c9e98b7, 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") movednullValueinto the constructor as180 * 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 fromValueSourcetoDoubleValuesSourceextensions, 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 aboutPointVectorStrategyin particular). - The tests assume uniform behaviour.
TestDistanceStrategyruns all six strategies,PointVectorStrategyincluded, 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 usesSpatialContext.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.withDefaulthides the missing value fromgetSortField(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.
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).
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
TestDistanceStrategyasserts 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
a9779ef3c193with the fix, the javadoc sentence below and the new test, gradle: the new test passes 2 of 2; the whole:lucene:spatial-extras:testpasses with assertions on (seed 777B0004) and off (seed 777B0005): 27 suites, 119 tests, 0 failures, 5 skipped by their own@Ignore/@Nightlyannotations, includingTestDistanceStrategy(12 tests) andTestPointVectorStrategy(4). :lucene:spatial-extras:check -x testpasses (checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMain,forbiddenApisTest), aftertidyrewrapped 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.
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.
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.
The direction LUCENE-5804 (#6866, open since 2014) proposed for all strategies. Not run.
Trade-offs
A larger refactor; not needed for this bug.
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.
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.
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 thatvector/DistanceValueSourcealone has noisGeo()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_VALUEin 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).
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/solr, two downstream search engines, Lucene.NET) | 2026-10-27 | two 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 advocate | no report; 6 medium-overlap hits read, none the same bug |
| Open PRs | 2026-10-27 | the changed files of all 408 open apache/lucene PRs | 10 touch spatial-extras; none touches DistanceValueSource.java (#12295 touches PointVectorStrategy.java, casts only) |
| JIRA archive and mailing lists | 2026-10-27 | 6 JQL queries (LUCENE, SOLR); 20 list queries per tool run over dev@, java-user@, issues@ and commits@; 4 threads read in full | usage questions and Solr's own PointVector field type deprecation; nothing about missing values |
| Web and local history | 2026-10-27 | 9 web queries; git log -G nullValue and -S on the rule in the full history | nothing relevant; 2f2e00ffe23 is the only commit that removed the rule; no fix commit |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this 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 | affected | this 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 sweep | missing=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 release | fails; passes with only the rule restored in DistanceValueSource.java |
| 6.6.0, 6.6.6, 6.5.1 | not affected | Double.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 |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Cross-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 | real | Own 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 | real | Five 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 validated | New 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. |
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.
- PNG 459 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,395 words
- 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 mainea57360912ed, whose spatial-extras,DoubleValuesandDoubleValuesSourcesources equala9779ef3c193'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.
- 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, :283lucene/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-63lucene/spatial-extras/src/java/org/apache/lucene/spatial/util/DistanceToShapeValueSource.java:52; lucene/spatial-extras/src/java/org/apache/lucene/spatial/util/ShapeFieldCacheDistanceValueSource.java:60lucene/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-104lucene/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-140github.com/apache/lucene/issues/6866 (LUCENE-5804)
- TestPointVectorMissingDistanceNonGeo.java (JUnit test):
repro/LFV-777/src/org/apache/lucene/spatial/vector/TestPointVectorMissingDistanceNonGeo.java(61 lines)
A summary still (the PNG), made in parallel with this doc from an executed probe of the main jars, with the fix as control.
assets/LFV-777/bug.png(459 KB)
