A Geo3D circle is the cap that a flat plane through its rim cuts off the planet; SidedPlane records which side is inside from the sign of evaluate(center). For a circle a few centimetres wide the centre is about 1e-18 from that plane, far below rounding error, so that sign is noise and can come out wrong.
- Geo3D (module
lucene/spatial3d) stores points as x, y, z on a planet model: WGS84 (an ellipsoid) or a unit sphere. A distance query becomes a circle shape, and a document matches when the shape contains its point. - A circle is the cap that a flat plane cuts off the planet: the plane through the circle's rim. A
SidedPlanestores the planen . x + D = 0and which side counts as inside: the sign (+1 or -1) ofevaluate(x) = n . x + Dat the centre. - For circles too small to build, Geo3D has two answers: below about 2e-5 m on Earth the circle becomes a single point, and a circle whose plane cannot be built throws "Couldn't construct circle plane, probably too small?".
- Index one
Geo3DPoint(WGS84) at (6.106045660234088, 162.52345301762631) and countGeo3DPoint.newDistanceQuery("point", WGS84, -6.106045660234088, -17.47654698237369, 0.012391875163764922): a 1.24 cm circle around the exact opposite spot, about 20,000 km from the document. - You expect 0 hits, or the
IllegalArgumentException("Couldn't construct circle plane, probably too small?") that Geo3D throws for circles it cannot build. - You get 1 hit, with assertions on or off. The circle contains its own antipode and its bounds cover the whole planet, so it matches every indexed point.
- Why: Geo3D cuts the circle off the planet with a flat plane through its rim and keeps the side the centre is on. The centre is really 1.9e-18 on the cap's side, but doubles near the plane's offset (1.0011) are 2.2e-16 apart, so the computed value lands one step on the other side and the circle flips to the whole planet minus a speck.
- How often: of 20,000 random WGS84 circles with radii from 0.01 mm to 10 m, 2,587 contain their own antipode; below 10 cm up to a quarter flip and about half throw. Flips reach 19.6 cm (the largest found), none in the sampled 20 to 60 cm ranges. Every release since 5.2.0, and on SPHERE too.
Users of the opt-in spatial3d module with tiny circles: Geo3DPoint.newDistanceQuery on WGS84 with a radius of about 2e-5 m to 20 cm, GeoCircleFactory.makeGeoCircle with the same angles (also on SPHERE), and spatial-extras' default spherical Geo3D context. Default codec, any index. Rare: in our judgement real distance filters are usually larger, and about half of the tiny calls throw instead.
Mechanism, terms and numbers1,293 words
Geo3DPoint.newDistanceQuery returns a "query matching points within this distance". On WGS84, with a radius between about 2e-5 m (below that the circle becomes a single point) and about 20 cm, some calls instead build a circle that contains its own antipode: the whole planet minus a speck around the centre (below about 1 mm, minus a patch of up to a few hundred metres), and the query silently matches every indexed point outside it: a 1.24 cm query matches a document at the centre's antipode, about 20,000 km away. Below 10 cm this happens to up to about a quarter of the calls, about half throw one of two existing exceptions ("Couldn't construct circle plane, probably too small?" or "Couldn't construct intersection point, probably circle too small?"), and the rest are correct; above 12 cm it gets rare, and the largest failing radius found is 19.6 cm (an observed maximum, not a proven bound).
The cause: GeoStandardCircle cuts the planet with a plane through two rim points and lets SidedPlane take the inside from the sign of evaluate(center). The centre is only about r^2/2 from that plane (1.9e-18 here), about a hundred times below the spacing of doubles near the plane's offset (2.2e-16), and only an exact 0.0 is rejected. The same cutoff angles fail on PlanetModel.SPHERE, through GeoCircleFactory.makeGeoCircle, and through spatial-extras' default spherical Geo3D context. Present since geo3d's first release (5.2.0; newDistanceQuery since 6.1.0) up to 10.6.0 and main. A small fix takes the side from the sign of normal . center instead; it passes the repro and the spatial3d tests. It removes the far-side matches; it does not make centimetre circles centimetre-accurate.
Geo3DPoint.newDistanceQuery(Geo3DPoint.java:122-130) callsGeo3DUtil.fromDistance(Geo3DUtil.java:152-162), which turns the radius into an angle (radius / mean planet radius,:159) and callsGeoCircleFactory.makeGeoCircle(GeoCircleFactory.java:38-47): belowMINIMUM_ANGULAR_RESOLUTION(Vector.java:29, pi x 1e-12 rad, about 2e-5 m on Earth) aGeoDegeneratePoint, otherwise aGeoStandardCircle.GeoStandardCircletakes two rim points at latitude +/- the cutoff angle (GeoStandardCircle.java:69-90) and builds the plane through them (:100-102) withSidedPlane.constructNormalizedPerpendicularSidedPlane(center, normalPlane, upperPoint, lowerPoint)(SidedPlane.java:211-226), which ends innew SidedPlane(center, n, D); anullresult becomes "Couldn't construct circle plane, probably too small?" (GeoStandardCircle.java:103-111).SidedPlane(Vector p, Vector v, double D)(SidedPlane.java:183-190):
sigNum = Math.signum(evaluate(p)); // p is the circle's centre
if (sigNum == 0.0) { // only an exact zero is rejected
throw new IllegalArgumentException("Cannot determine sidedness because check point is on plane.");
}- The sign is noise. In exact arithmetic
evaluate(center)is aboutr^2/2in size for a cutoff angler, with the sign ofn . center. For the repro (1.24 cm,r= 1.945e-9 rad) that is 1.9e-18, while doubles near the plane offsetD= 1.0010804326724039 are 2.2e-16 apart. The computed value is+2.220446049250313E-16(one step), and an exact (BigDecimal) evaluation of the rounded plane at the rounded centre gives+2.18e-16: the rounded plane itself has the centre on the wrong side.sign(n . center)is -1, so the cap is on the negative side, butsigNumis +1. - Wrong sign, (almost) the whole planet.
isWithin(GeoStandardCircle.java:175-181,SidedPlane.java:300-309) counts anything within 1e-12 of the plane as inside and otherwise compares signs withsigNum. With the wrong sign it accepts every point off the cap and every cap point within the band. The repro's cap is only about 1.9e-18 deep, so the shape is the whole planet, and its bounds (GeoStandardCircle.java:218-226) cover the planet too, so the index's box test filters nothing. The same holds for every sampled wrong-sided circle from about 1 mm up. Below about 1 mm the rounding of the two rim points, only2rapart, also tilts the plane (SidedPlane.java:216-218builds the normal from their difference), the real cap can be deeper than the band, and many such circles leave out a patch near the centre (the numbers below). - Many tiny circles round
evaluate(center)to exactly 0.0 and get the clean exception (6,077 in the WGS84 sweep below); 510 more fail a second guard, "Couldn't construct intersection point, probably circle too small?" (GeoStandardCircle.java:112-118); the rest get a noisy sign, right or wrong.r^2/2is 0.8 of a rounding step at 12 cm and 2 steps at 19 cm, so failures thin out above 12 cm.
- The repro circle (WGS84, 1.24 cm): contains its centre, points 10 m, 1 km and 0.5 rad away, and its antipode; bounds x, y in [-1.0011, 1.0011], z in [-0.9978, 0.9978], the whole planet. A 0.1 m circle at the same centre also contains its antipode; 0.5 m, 1 m, 10 m and 1 km are correct. On SPHERE the same cutoff angle fails the same way; there
newDistanceQueryreads its radius argument as the angle in radians (SPHERE's mean radius is 1.0,PlanetModel.java:31,:142). - Sweep, 20,000 random WGS84 circles, radii log-uniform from 1e-5 m to 10 m: 2,587 contain their own antipode. Per decade (exception "circle plane" / exception "intersection point" / correct / contains its antipode): 1e-5 to 1e-4 m 1,112 / 2 / 641 / 585; 1e-4 to 1e-3 m 1,632 / 9 / 852 / 819; 1e-3 to 1e-2 m 1,635 / 95 / 829 / 753; 1e-2 to 1e-1 m 1,562 / 376 / 1,006 / 420; 0.1 to 1 m 136 / 28 / 3,205 / 10; 1 to 10 m 0 / 0 / 3,292 / 0. The other 1,001 radii, all below the 2e-5 m threshold, became a
GeoDegeneratePoint, a third outcome without an exception. The circles that contain their antipode are as many as those whosesigNumdiffers fromsign(n . center)(2,587 each); a fact check's own probe matched the two circle by circle (19,004 of 19,004 WGS84 and 21,899 of 21,899 SPHERE circles). SPHERE, same cutoff angles: 3,059. - The upper end: 711 of 200,000 random WGS84 circles from 0.10 to 0.30 m contain their antipode, 27 of them above 0.15 m, the largest 0.1889 m (2.96e-8 rad); 173 of 400,000 from 0.15 to 0.20 m; 0 of 400,000 each from 0.20 to 0.30 m and 0.30 to 0.60 m. Public-API WGS84 queries of 15.4, 16.2 and 17.9 cm each match a document at the centre's antipode. A second fact check sampled 4,000,000 WGS84 circles from 0.185 to 0.30 m: 4 contain their antipode, none at 0.20 m or more; the largest, a 19.63 cm (3.08e-8 rad)
newDistanceQuerycentred at (14.514881155433457, 48.76857350643001), matches a document at the antipode; with the fix, 0. The largest failing radius is an observed maximum on one platform, not a proven bound. - What a wrong-sided circle leaves out: the repro circle leaves out nothing (0 of 2,000,000 random planet points and 0 of 200,000 points from 1.2e-5 m to 12 km around the centre are outside). Over 200,000 random circles per model from 2.1e-5 to 0.2 m, every wrong-sided circle from 1 mm up has its whole cap within the 1e-12 band; below 1 mm many leave out their deepest cap point and a patch around it (WGS84: 7,442 of 8,708 wrong-sided circles below 0.1 mm, patches up to about 360 m; 3,664 of 12,700 from 0.1 to 1 mm, up to about 77 m). Through the public API a 2.53e-5 m query matches its centre and its antipode but not a document 566 m away.
- End to end: 200 fixed centres with a 1 cm
newDistanceQueryon a 162-document world grid: 36 queries match all 162 documents, 109 throw, 55 are correct; 1 km controls match nothing. On a 20,001-document index the repro circle and a 10 cm circle match all 20,000 far documents.
Exposure and likelihood369 words
- Who. Users of the opt-in spatial3d module (
Geo3DPointand the geom classes are@lucene.experimental) that build circles with a cutoff angle between about 3e-12 and 3.1e-8 rad:Geo3DPoint.newDistanceQueryon WGS84 with a radius of about 2e-5 m to 20 cm; the same call on SPHERE with an angle argument in that range (SPHERE's mean radius is 1.0, so the argument is read as radians);GeoCircleFactory.makeGeoCircledirectly; and spatial-extras with a spherical Geo3D context, which isGeo3dSpatialContextFactory's default (Geo3dShapeFactory.circleusesmakeGeoCirclefor a sphere and the unaffectedmakeExactGeoCircleotherwise). The follow-up's reach probe saw 565 of 2,400 tiny query circles in a spherical spatial-extras context match documents 1,000 km or more away, and 0 of 2,400 with a WGS84 context. - What goes wrong. A positive distance filter silently matches every document with a point in the field (below about 1 mm, every one outside a patch of up to a few hundred metres near the centre). No exception, default codec and
IndexWriterConfig, assertions on or off; every tested index (1, 3, 162 and 20,001 documents) had all its far documents matched. Other clauses of the same query still apply; as aMUST_NOTclause the circle hides everything. - What stays right. No circle failed in the sampled 20 to 60 cm ranges (400,000 per range and model), and the 0.5 m, 1 m, 10 m and 1 km controls are correct; the tiny radii that throw fail loudly. Correctly sided tiny circles still have a separate precision limit: everything within 1e-12 of the plane counts as inside, so below about 1.4e-6 rad (9 m) a circle behaves roughly like a 9 m circle, and below about 1 mm the tilted plane lets sampled circles reach points up to about 520 m away (about 570 m with the fix).
- How likely. Rare. It needs a radius below 20 cm; in our judgement real distance filters are usually far larger (no usage data was collected). Failures above 12 cm are rare, about half of the calls below 10 cm fail loudly, and no user report was found in the eleven years since 5.2.0. A plausible trigger is an "items at this exact spot" lookup with a millimetre radius.
One JUnit class in lucene/spatial3d, public API: index one point, then count a 1.24 cm newDistanceQuery centred on the opposite side of the planet. One assertion, deterministic; it fails the same way on main, 10.6.0, 10.5.2 and 9.12.3, assertions on and off.
/*
* 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.spatial3d;
import org.apache.lucene.document.Document;
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.Query;
import org.apache.lucene.spatial3d.geom.PlanetModel;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestGeo3DTinyRadiusMatchesWholeWorld extends LuceneTestCase {
public void testTinyRadiusDoesNotMatchAntipode() throws Exception {
double lat = -6.106045660234088, lon = -17.47654698237369;
Query q =
Geo3DPoint.newDistanceQuery("point", PlanetModel.WGS84, lat, lon, 0.012391875163764922);
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new Geo3DPoint("point", PlanetModel.WGS84, -lat, lon + 180)); // the antipode
w.addDocument(doc);
try (DirectoryReader reader = DirectoryReader.open(w)) {
// expected: a 1.24 cm circle matches 0 docs on the other side of the planet; actual: 1
assertEquals("hits for the antipode", 0, new IndexSearcher(reader).count(q));
}
}
}
}
cp $REPRO/org/apache/lucene/spatial3d/TestGeo3DTinyRadiusMatchesWholeWorld.java \
$LUCENE/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.TestGeo3DTinyRadiusMatchesWholeWorld \
-Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
- Expected
- 0 hits for the document on the other side of the planet (or an
IllegalArgumentExceptionfor a circle too small to build). - Observed
- 1 hit:
hits for the antipode expected:<0> but was:<1>, assertions on and off.
More files, environment, full output266 words
Same package as Lucene's spatial3d tests (the file carries the Apache license header). Keep the radius to the last digit: whether a given tiny circle comes out inverted depends on rounding. The test has no randomness of its own; the seed only picks the test framework's settings, and seeds DEADBEEF and 5EED1234 (5 iterations) give the same result. The same test is the suggested regression test (with a fail-hard fix it must also accept an IllegalArgumentException).
Only Linux aarch64 was run. Java's + - * / and Math.sqrt are exactly specified, but Math.sin and Math.cos may differ by an ulp on another platform, so the exact set of failing radii is not guaranteed to be the same everywhere. An earlier fact check found that the 8 sin/cos values this circle uses equal StrictMath's; the indexed antipode also goes through GeoPoint's Math.sin and Math.cos (GeoPoint.java:111), which that check did not cover.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0, 10.5.2 and 9.12.3 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp $REPRO/org/apache/lucene/spatial3d/TestGeo3DTinyRadiusMatchesWholeWorld.java \
$LUCENE/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.TestGeo3DTinyRadiusMatchesWholeWorld \
-Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
# lines 65-71 of (private working file), verbatim (main 645d4fe14a20, -Ptests.seed=DEADBEEF, -Ptests.asserts=true)
TestGeo3DTinyRadiusMatchesWholeWorld > testTinyRadiusDoesNotMatchAntipode FAILED
java.lang.AssertionError: hits for the antipode expected:<0> but was:<1>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:C7AA9D74D46BEEDB]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:647)
at org.apache.lucene.spatial3d.TestGeo3DTinyRadiusMatchesWholeWorld.testTinyRadiusDoesNotMatchAntipode(TestGeo3DTinyRadiusMatchesWholeWorld.java:43)
# standalone runner: the same assertion on main and on the 10.6.0, 10.5.2 and 9.12.3 jars, -ea and -da
# with the fix: 1 test, 0 failures (assertions on and off)newDistanceQuerypromises a "query matching points within this distance"; its only caveat is up to 0.5% error on WGS84, and the bug also hits a sphere.- Too-small circles are meant to become a single point or throw "probably too small"; geo3d's author ruled out a "whole-world circle".
GeoExactCirclealready rejects a plane with the antipode inside.
The full argument294 words
- The contract.
Geo3DPoint.java:110-121: "Create a query for matching points within the specified distance of the supplied location"; the radius is the "maximum distance from the center in meters: must be non-negative and finite"; "@return query matching points within this distance"; "@throws IllegalArgumentException if ... radius is invalid". The only accuracy caveat is "if PlanetModel#WGS84 is used, the query is approximate and may have up to 0.5% error" (:112-113); the bug also happens on SPHERE, and the error here is the whole planet. - The code's own answers for too-small circles are a point or an exception:
GeoCircleFactory.java:43-45returns aGeoDegeneratePoint(CHANGES, LUCENE-6798: "Geo3d now models degenerate (too tiny) circles as a single point"),GeoStandardCircle.java:103-111throws "probably too small", andTestGeo3DPoint.java:898-903catches that exception with "angle is too small; try again".GeoExactCircle.java:401rejects a plane with the antipode inside ("Plane is bogus, we cannot build the circle", added for #9128). - History. In #7655 (LUCENE-6597, 2015) geo3d's author, Karl Wright (DaddyWri), wrote that accepting a null circle plane "would represent a whole-world circle, not a zero-world circle". In #7817 (LUCENE-6759) they proposed to "throw an IllegalArgumentException whenever the math falls apart" for tiny circles, and Michael McCandless (mikemccand) agreed; that is the guard this bug slips past. The searches found no test, CHANGES entry or comment that accepts a tiny circle containing its antipode.
- Legal input. A finite, non-negative radius; upstream
TestGeoCircle.java:136-139builds a 1.7e-8 rad circle ("Eleventh BKD discovered failure") as ordinary input. - Why tests miss it. Upstream random circles draw the angle uniformly from [0, pi/2] (
TestGeo3DPoint.java:896), so the window below about 3.1e-8 rad is hit with probability about 2e-8 per circle, and the random shape tests useshape.isWithinas their own oracle, so a backwards shape agrees with itself.
The devil's-advocate check181 words
The strongest case (built by the intended-behaviour verifier; the adjudication and the reading-only cross-model review weighed the same points): geo3d is @lucene.experimental and approximate; the maintainers know that plane math loses accuracy for tiny shapes (#7690 / LUCENE-6632: circle planes are "less accurate the smaller the circle"); the 1e-12 band already makes caps below 9 m inexact; a centimetre circle is below the library's real resolution (geo3d's author in 2015: hardly anyone "except for missile designers" needs centimetre resolution); and open #9623 already proposes moving newDistanceQuery to GeoExactCircle.
Why it fails: a few metres of imprecision is a precision limit, containing the antipode is not. The maintainers fixed earlier tiny-circle failures instead of accepting them, chose to throw rather than return wrong shapes, and ruled out "whole world" for degenerate circles; @lucene.experimental permits API changes, not wrong answers; the 0.5% note is about ellipsoid distance and the bug reproduces on SPHERE; #9623 is an open 2018 accuracy proposal that never mentions small radii, and would still leave makeGeoCircle and spatial-extras' spherical context affected. Verdict: a defect (adjudication: confirmed, high confidence).
Recommended Take the side from the sign of normal . center (tested, not proved)
In GeoStandardCircle, take the inside from the sign of normal . center, which is the cap's side and about 1 in size, far above rounding noise, and reverse the plane when SidedPlane picked the other sign (+12/-1, one file). The test and the whole spatial3d test suite pass; no swept circle contains its antipode any more. Tested, not proved for every WGS84 circle.
final SidedPlane plane =
SidedPlane.constructNormalizedPerpendicularSidedPlane(
center, normalPlane, upperPoint, lowerPoint);
// SidedPlane takes the inside from the sign of evaluate(center). For a small cutoff angle
// the centre is only ~cutoff^2/2 from the plane, far below the rounding error of the plane,
// so that sign is noise. The cap is always on the side the plane normal points to relative
// to the centre, so take the side from the sign of normal . center instead.
if (plane != null
&& Math.signum(plane.x * center.x + plane.y * center.y + plane.z * center.z)
!= plane.sigNum) {
this.circlePlane = new SidedPlane(plane);
} else {
this.circlePlane = plane;
}All options and trade-offs4 options ยท 718 words
One fix was validated with gradle; two alternatives that follow the 2015 "fail hard" decision were not run.
Take the side from the sign of normal . center (tested, not proved) (validated by the checks) (recommended)
The hunt's fix, with its code comment re-wrapped for google-java-format: in the GeoStandardCircle constructor, compare SidedPlane's sigNum with sign(n . center) and use the reversing copy constructor SidedPlane(SidedPlane) (SidedPlane.java:34-37) when they differ. On a sphere the exact evaluate(center) = (n . center)(1 - cos r) has the sign of n . center for any cutoff in (0, pi); on WGS84 the normal was at most 0.19 degrees from the centre direction over the circles built from 300,000 random radii of 2.1e-5 to 0.6 m, and no sweep found a disagreement (sampled evidence, not a proof for every WGS84 circle). |n . center| is about 1, far above rounding error. Where the signs agree nothing changes; the exact-zero case still throws.
final SidedPlane plane =
SidedPlane.constructNormalizedPerpendicularSidedPlane(
center, normalPlane, upperPoint, lowerPoint);
// SidedPlane takes the inside from the sign of evaluate(center). For a small cutoff angle
// the centre is only ~cutoff^2/2 from the plane, far below the rounding error of the plane,
// so that sign is noise. The cap is always on the side the plane normal points to relative
// to the centre, so take the side from the sign of normal . center instead.
if (plane != null
&& Math.signum(plane.x * center.x + plane.y * center.y + plane.z * center.z)
!= plane.sigNum) {
this.circlePlane = new SidedPlane(plane);
} else {
this.circlePlane = plane;
}Trade-offs
- About half of the tiny radii still throw the existing exception, and correctly sided tiny circles keep the separate 9 m precision band; below about 1 mm the formerly inverted circles become circles that reach points up to about 570 m away (sampled).
- Single-point
GeoStandardPathend caps build the same kind of plane and are not covered; the finder's width sweep saw them contain their antipode and, for some widths, aNullPointerExceptionwhile building the path (GeoStandardPath.java:187-189usesonlyEndpoint.circlePlanewithout a null check). Neither has a minimal test of its own: a follow-up with its own test. - Cost: one dot product per circle construction, nothing per document. No benchmark run.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, the diff applied withgit apply: stock + test fails; with the fix the whole:lucene:spatial3d:testsuite, minimal test included, passes with assertions on (182 tests, 0 failures, 3 skipped as @Nightly), and the minimal test also passes with assertions off. - Standalone: the test passes with
-eaand-da. The 20,000-circle sweeps give 0 circles containing their antipode on WGS84 and SPHERE, with every exception count unchanged; the upper-end sweeps (0.10 to 0.60 m, both models) give 0. - Earlier runs:
:lucene:spatial3d:check -x test(google-java-format, ecjLint, forbiddenApis) passes with this file, and spatial-extras' four Geo3D test classes (21 tests, 4 skipped as @Nightly) pass with the same code in the hunt's comment wrapping; an independent verifier's 1,785-circle scan (0 fail open, exceptions unchanged) and the follow-up's reach probes pass with it. - What this shows: the far-side matches are gone in every run and sweep and the exceptions are unchanged. It does not show centimetre accuracy, and it is no proof for every WGS84 circle.
After building the plane, throw the existing exception when circlePlane.isWithin(-center.x, -center.y, -center.z), the check GeoExactCircle got for #9128.
Trade-offs
Matches the 2015 "throw whenever the math falls apart" decision, but turns the fail-open quarter into exceptions, so about three quarters of tiny radii would throw; the regression test must then accept an IllegalArgumentException. It may also reject near-whole-world circles whose antipode lies inside the 1e-12 band; untested.
Make GeoCircleFactory.makeGeoCircle (GeoCircleFactory.java:43) return a GeoDegeneratePoint up to the end of the window.
Trade-offs
The threshold must clear about 3.1e-8 rad (a 19.6 cm WGS84 circle still contains its antipode); every query between 2e-5 m and about 20 cm then becomes a point that matches only within 1e-12 per coordinate (a few micrometres), and upstream TestGeoCircle builds a 1.7e-8 rad circle.
The finder's first attempt.
Trade-offs
Breaks upstream TestGeoCircle.testCircleBounds (1 of 7 tests fails), which builds a 1.7e-8 rad circle.
Applications that pass a user-supplied radius to a Geo3D distance filter (including spatial-extras' default spherical Geo3D context) can be handed a radius under about 20 cm that makes the filter match every point (below about 1 mm, all but a patch near the centre); rejecting radii below 1 m avoids it.
A randomized harness compared Geo3D shape membership with an independent exact sphere computation (480,000 trials) and flagged very thin path shapes that accepted points about 1 rad away. The finder reduced that by hand to single circles, then to SidedPlane's side test, then to a public-API query.
The method in full105 words
- Harness. A randomized metamorphic harness built for the spatial3d hunt unit ran 480,000 trials comparing shape membership with an independent exact sphere reference. It flagged
GeoStandardPathshapes 1e-9 to 1e-8 rad wide whoseisWithinaccepted points about 1 rad away. - Reduction by hand. Single-point paths, then
GeoStandardCircle(a width sweep), thenSidedPlane's side test, then an end-to-endnewDistanceQueryover a world grid of documents, then the JUnit test. - Then an independent reproducer, a reading-only cross-model review (an independent reviewer from another model family, OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication. The first signal came from randomized testing, so the finding counts as non-formal.
No prior report found (GitHub issues and PRs, the JIRA archive, the Lucene lists, downstream projects and the web; 2026-10-26, refreshed 2026-10-30). Closest context: the 2015 decision to throw for tiny circles (#7817) and the antipode check added to GeoExactCircle (#9128).
Searches5 searches
Context, none of it this bug:
- #7817 / #7757 (LUCENE-6759 / LUCENE-6699, 2015): tiny circles are numerically fragile; the decision to throw an exception "whenever the math falls apart" created the guard this bug slips past. The two 2015 tiny circles, re-run on main, do not contain their antipode.
- #9128 (LUCENE-8080, 2017): a wrong-sided plane in
GeoExactCirclefrom planet flattening, fixed with the antipode checkGeoStandardCirclelacks. - #9283 (LUCENE-8236): a bogus plane in
GeoDegeneratePathfrom duplicate points matched far points; different shape and cause. - #7856 (LUCENE-6798, the degenerate point), #7655 (LUCENE-6597, "whole-world circle, not a zero-world circle"), open #9623 (LUCENE-8577, move
newDistanceQuerytoGeoExactCirclefor ellipsoid accuracy; no mention of small radii).
Limits: keyword search; list search sees subjects and body prefixes; private security reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, apache/lucene-solr, apache/solr and two downstream engines) | 2026-10-26 | two runs of the scripted prior-report search (24 issue/PR, 4 commit and 3 downstream queries; all high and medium hits read) and 41 manual searches: class names, the exception texts, tiny circle / antipode / whole world / sidedness wording | no report; the context items above |
| JIRA archive (LUCENE) | 2026-10-26 | 8 tool queries and 14 JQL queries; 39 entries downloaded with all comments, read or term-scanned | the 2015 tiny-circle threads and the later plane-precision work; no report |
| dev@, java-user@, issues@ lists; the web | 2026-10-26 | 59 list queries (40 by the scripted prior-report search, 19 by hand) and 15 web searches | notification mirrors of the context items; no report |
| Open PRs and upstream history | 2026-10-26 and 2026-10-30 | the list of the 413 open apache/lucene PRs (changed files read for the five with geo or spatial titles, not for every PR); open PRs mentioning spatial3d or geo3d; recent commits touching lucene/spatial3d; blob shas of the three files on upstream main | none of the inspected open PRs touches the geometry code, and a later fact check found that the open PRs mentioning spatial3d (#411, #365, #11955) change no spatial3d file; the last spatial3d commit on main is a 2026-06-14 build change; upstream main 208608f75307 and, per the later fact check, 2f424fd428da (both 2026-10-30) have the tested files unchanged |
| GitHub refresh | 2026-10-30 | issue and PR search in apache/lucene for GeoStandardCircle, "Couldn't construct circle plane", geo3d tiny circle, spatial3d circle whole world, geo3d antipode, newDistanceQuery small radius, SidedPlane sigNum | older context items only (12, 4, 9, 3, 0, 0 and 1 unrelated hit) |
Present since geo3d's first commit (bde056c76323, LUCENE-6196, 2015), first released in 5.2.0; newDistanceQuery since 6.1.0. Two 2015 rewrites kept the flaw. Fails on every tested release from 5.2.0 to 10.6.0 and on main; not fixed upstream.
Versions tested6 rows
Introduced with geo3d itself: bde056c763231cd4f35acbc708cfc07dc181b1fc, "LUCENE-6196: Geo3d initial checkin" (code by Karl Wright (DaddyWri), committed by David Smiley (dsmiley) on the svn branch lucene6196, 2015-04-13, issue #7258), merged to trunk as f760a51f80d and to branch_5x as 536ae683ff6 on 2015-05-06 (first release 5.2.0); svn era, no PR. The first circle plane, new SidedPlane(center, center, -cosAngle), was already sided by signum(evaluate(center)).
Rewrites that kept it (each committed to trunk, first shipped in 6.0.0, and to branch_5x, first shipped in the 5.x release named): LUCENE-6544 (trunk 03408382767, branch_5x 226f69dd68b, 2015-06-18, issue #7602, 5.3.0) built today's rim-point plane; LUCENE-6699 (trunk 03e5bcfa739, branch_5x 31b17203f2c, 2015-09-02, issue #7757, 5.4.0) added the exact-zero guard that turns many tiny circles into the exception; LUCENE-6798 (trunk 049fef3c9a9, branch_5x 3cfc3f153d1, 2015-09-11, 5.4.0) added the degenerate point below the threshold. Releases between the tested points are inferred from the continuous history of the lines.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails, gradle and standalone, assertions on and off |
| 10.6.0, 10.5.2, 9.12.3 | affected | the minimal test fails on the release jars, -ea and -da |
5.2.0 to 10.5.2 (19 releases) and main ea57360912ed | affected | Follow-up: a shape-level sweep from 5.2.0 (tiny circles that contain their antipode, 0 for the controls) and end-to-end query sweeps from 6.1.0 (36 of 200 1 cm queries match all 162 documents) |
bde056c76323 (introducing commit) over 5.2.0 | introducing commit | its 28 geo3d sources shadowed over 5.2.0: 1,446 of 6,000 tiny circles contain their antipode |
| 5.1.0 | no API | no geo3d package (the probe does not compile) |
fix (scratch worktree of 645d4fe14a20) | not affected | the test and the whole spatial3d test suite pass |
An independent reproducer, a reading-only cross-model review, a devil's-advocate verifier and an adjudication: confirmed, high confidence. A follow-up added 41 history runs and a reach probe; this doc re-ran the test on main and three releases and validated the fix with gradle; two rounds of fact checks corrected it.
Checks and verdicts498 words
The cross-model reviews used an independent reviewer from another model family (OpenAI GPT-5.6); the one that answered could only read code. The hunt's checks ran on main ea57360912ed, where lucene/spatial3d is identical to 645d4fe14a20.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | JUnit (a 1.24 cm query against 3 far documents) fails with -ea and -da, 20 of 20 iterations, on 10.5.2 and 9.12.3; passes with the fix, which keeps TestGeoCircle, TestGeoExactCircle, TestRandomGeoShapeRelationship and TestGeo3DPoint green. |
| review, refute lens | no verdict | Two attempts; neither returned a verdict. |
| Independent reproducer | real | Own public-API and mechanism tests fail on main (-ea, -da), 10.5.2 and 9.12.3: 1,785 circles give 1,002 exceptions, 365 that contain a far point, 418 correct; wrong sign and antipode containment coincide exactly; 0 fail open with the fix. Reported the 9 m precision band. |
| Cross-model review, works-as-designed lens | real, medium (reading only) | Agrees on the root cause, finds the inputs within the contract and the intended argument weak. A separate run re-ran the repro and added a 20,001-document index (all 20,000 far documents matched) and the exact-arithmetic probe. |
| Devil's advocate | real, high | Built and rejected the strongest intended case; in its own sweeps GeoExactCircle on the same inputs never contains the antipode. |
| adjudication | confirmed, high | Re-ran the finder's and the independent reproduction's tests (-ea, -da fail; pass with the fix, exception count unchanged). |
| Follow-up | new, affected since 5.2.0 | 41 history runs from 5.2.0 to 10.5.2 and the introducing commit; prior-art search (verdict new); a reach probe over Geo3DPoint and spatial-extras. |
| earlier fact checks of this doc | corrections applied | Independent fact checks of the first version (one by an independent reviewer from another model family) re-ran the repro and the fix and found that the window reaches 18.9 cm rather than 12 cm and that on SPHERE newDistanceQuery reads the radius as an angle, among smaller errors; all are corrected here. |
| second round of fact checks of this doc | corrections applied | A fact check, a cross-model fact check (an independent reviewer from another model family, OpenAI GPT-5.6) and a diagram check of the second version. They found that the largest failing radius is 19.6 cm rather than 18.9 cm, that the random-centre shares in the diagram needed their radius range, and that the 20,000-circle table left out the radii that became a point. The resolution's own probes added that below about 1 mm a wrong-sided circle can leave out a patch near the centre and a correctly sided one can reach a few hundred metres. All are corrected here; one cross-model claim was accepted only in part. |
| this doc | as above | The minimal test on main (gradle and standalone, -ea, -da) and on the 10.6.0, 10.5.2 and 9.12.3 jars (-ea, -da); the exact-arithmetic probe and the sweeps, stock and with the fix; the fix with gradle (whole spatial3d test suite). |
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 414 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,509 words
- Registry title
- Geo3D circles with a radius between about 2e-5 m and 20 cm take the inside of their cut plane from the sign of rounding noise, so Geo3DPoint.newDistanceQuery with such a radius can match every point document on the planet
- Area
- spatial3d / geom circles (GeoStandardCircle cut plane and SidedPlane sidedness, behind Geo3DPoint.newDistanceQuery and GeoCircleFactory.makeGeoCircle)
- Priority
- Low, rank 588: Geo3D circles with radii between about 2e-5 m and 20 cm take their inside from rounding noise and can match every point on the planet, but only in the opt-in spatial3d stack at sub-20 cm radii.
- Impact class
- silent wrong results
- Impact note
- Hits applications that use the opt-in spatial3d module with a circle cutoff angle between the degenerate-point threshold (MINIMUM_ANGULAR_RESOLUTION, about 2e-5 m on Earth) and about 3.1e-8 rad (about 20 cm on Earth): Geo3DPoint.newDistanceQuery on WGS84 (on SPHERE the radius argument is read as that angle, since SPHERE's mean radius is 1.0), GeoCircleFactory.makeGeoCircle, and (follow-up security probe, executed) spatial-extras with a spherical Geo3D spatial context, which is that factory's default; below 10 cm up to roughly a quarter of the calls silently build a circle that contains its own antipode, so the query matches every indexed point (about 20,000 km away included; below about 1 mm a patch of up to a few hundred metres near the centre can stay excluded), roughly half throw the existing 'probably too small' IllegalArgumentException (or a second 'Couldn't construct intersection point' one), and the rest are correct; failures get rare above about 12 cm, the largest failing radius found is 19.6 cm (3.08e-8 rad), and none of 800,000 random WGS84 circles from 20 to 60 cm failed (doc fact checks, executed); no scale or special index is needed, the default codec and IndexWriterConfig suffice, and it is released since 5.2.0 (shape API) and 6.1.0 (newDistanceQuery).
- Summary
- You build a Geo3D distance query with a very small radius, for example Geo3DPoint.newDistanceQuery with 1.24 cm, and expect it to match only points near the centre, or to be rejected as too small. The circle's cut plane passes within about r^2/2 of the circle's centre (here about 2e-18), far below double rounding, and SidedPlane takes the plane's inside from the sign of evaluate(center), which at that size is rounding noise. When the noise has the wrong sign the circle is the whole planet minus a speck (below about 1 mm, minus a patch of up to a few hundred metres near the centre), and the query silently returns every indexed point outside it: in the repro, all 3 documents thousands of kilometres away.
- Introduced
- bde056c763231cd4f35acbc708cfc07dc181b1fc (LUCENE-6196 'Geo3d initial checkin', issue #7258, contributed by DaddyWri and committed by dsmiley on the svn branch lucene6196; reached trunk through merge f760a51f80d3 and branch_5x through 536ae683ff6a; svn era, no PR) [read: blame shows GeoCircle's first plane 'new SidedPlane(center, center, -cosAngle)' and SidedPlane's 'sigNum = Math.signum(evaluate(p))' (unchanged on main, SidedPlane.java:185) both originate there; exec: that commit's 28 geo3d sources shadowed over 5.2.0 fail like 5.2.0 (1,446 of 6,000 tiny circles contain their antipode)]. The flaw survived two rewrites: LUCENE-6544 (trunk 03408382767, branch_5x 226f69dd68b6, 2015-06-18, issue #7602, first released in 5.3.0) built today's rim-point plane with SidedPlane.constructNormalizedPerpendicularSidedPlane, still sided by signum(evaluate(center)); LUCENE-6699 (trunk 03e5bcfa739c, branch_5x 31b17203f2c0, 2015-09-02, issue #7757, first released in 5.4.0) added the exact-zero guard that turns about half of the tiny circles into the 'probably too small' IllegalArgumentException (2015-04-13 (branch commit); 2015-05-06 (merged to trunk and branch_5x))
- First affected version
- 5.2.0 [exec] (first release with geo3d; 5.1.0 has no geo3d package, so the probe does not compile there); the public query API Geo3DPoint.newDistanceQuery first appears in 6.1.0, which fails [exec: 36 of 200 1 cm queries match 162 of 162 documents]
- Affected versions
- 5.2.0 to 10.6.0 and main ea57360912ed. [exec] fails on 5.2.0 (-ea and -da), 5.2.1, 5.3.0, 5.4.0, 5.5.5, 6.0.0, 6.1.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da) (41 follow-up history runs with three repros: the canonical JUnit, a 200-query end-to-end sweep and a shape-level sweep; every control run matched nothing); the finder's JUnit, the independent reproduction's repro and the intended-behaviour check's shape check also fail on 10.5.2 and 9.12.3. [read] releases between the tested points are inferred from the continuous history of the defective lines. Not fixed: upstream main is the tested commit and no commit since 2023 changed the logic of GeoStandardCircle, SidedPlane, GeoCircleFactory or GeoStandardPath (follow-up prior-report record). 10.6.0 (released 2026-10-29) [exec, bug-doc checks 2026-10-30]: the bug doc's minimal JUnit (derived from the finder's) fails 1 of 1 on the 10.6.0 release jars with -ea and -da.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new: two scripted prior-report searches (24 GitHub issue/PR, 4 commit, 3 downstream-repository, 40 mailing-list, 8 JIRA-archive and 6 web queries; 981 and 215 distinct hits, all high and medium hits read), 41 manual GitHub searches across apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch, 14 JQL queries with 39 JIRA entries downloaded with all comments, dev@, java-user@ and issues@ list searches, 9 web searches, the list of all 413 open apache/lucene PRs (changed files checked for the geo/spatial ones), and the code history); no issue, PR, JIRA entry, list thread or web page describes a GeoStandardCircle or single-point GeoStandardPath cap built with the wrong plane side, nor the NullPointerException in GeoStandardPath.done; not fixed upstream (no open PR touches spatial3d/geom); context, not this bug: #7817 and #7757 (LUCENE-6759, LUCENE-6699, 2015: tiny circles are numerically fragile, decision to throw IllegalArgumentException 'whenever the math falls apart', the origin of the guard this bug slips past; the two 2015 tiny circles re-run on main do not contain their antipode), #9128 (LUCENE-8080: a bogus-plane antipode check added to GeoExactCircle only), #9283 (LUCENE-8236: a bogus plane in GeoDegeneratePath matched far points, same symptom family, different shape and trigger), #7856 (LUCENE-6798, GeoDegeneratePoint for too-tiny circles), #7655 (LUCENE-6597: a null circlePlane 'would represent a whole-world circle, not a zero-world circle'), open #9623 (LUCENE-8577: move newDistanceQuery to GeoExactCircle for ellipsoid accuracy, no mention of small radii); gaps: GitHub's search rate limit forced a reduced plan for run 2, list search sees subjects and body prefixes only, the open-PR file scan covered only geo/spatial PRs, private security reports cannot be searched
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test with gradle in a clean scratch worktree (stock and with the fix; with the fix also the wholelucene/spatial3dtest suite) and with the standalone runner (-ea,-da), plus the 10.6.0, 10.5.2 and 9.12.3 release jars (-ea,-da); an exact-arithmetic probe and random-circle sweeps on WGS84 and SPHERE, stock and with the fix. The hunt, the verifiers, the adjudication and the follow-up ran on mainea57360912ed(the follow-up's history sweep: 19 releases from 5.2.0 to 10.5.2 plus the introducing commit);lucene/spatial3dandlucene/spatial-extrasare identical atea57360912edand645d4fe14a20.
- Finding record
a Lucene main checkout lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardCircle.java:100-112 (circle plane from constructNormalizedPerpendicularSidedPlane; null gives 'Couldn't construct circle plane, probably too small?'); geom/SidedPlane.java:183-190 (sigNum = signum(evaluate(p)), only an exact 0.0 is rejected), :211-226 (constructNormalizedPerpendicularSidedPlane maps that IAE to null), :300-309 (isWithin treats |evaluate| < MINIMUM_RESOLUTION as on the plane); geom/GeoCircleFactory.java:38-47 (GeoDegeneratePoint below MINIMUM_ANGULAR_RESOLUTION); Geo3DPoint.java:109-130 (newDistanceQuery javadoc: 'query matching points within this distance', radius 'non-negative and finite', IAE if the radius is invalid); Geo3DUtil.java:152-162 (fromDistance); geom/GeoStandardPath.java:184-189 (single-point end cap, onlyEndpoint.circlePlane used without a null check); geom/GeoExactCircle.java:401 (the bogus-plane antipode check that GeoStandardCircle lacks)a Lucene main checkout lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dShapeFactory.java:176-197 (sphere uses makeGeoCircle, other models makeExactGeoCircle) and Geo3dSpatialContextFactory.java:33,46-48 (default planet model SPHERE)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:122-130; Geo3DUtil.java:152-162, :159; geom/GeoCircleFactory.java:38-47; geom/Vector.java:26, :29; geom/PlanetModel.java:31, :142a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardCircle.java:69-90, :100-118, :175-181, :218-226a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/SidedPlane.java:183-190, :211-226, :300-309
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dSpatialContextFactory.java:33, :46-48; Geo3dShapeFactory.java:176-197a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:42; geom/GeoStandardCircle.java:28 (@lucene.experimental); geom/PlanetModel.java:31, :142; Geo3DUtil.java:159
- Minimal repro
a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPoint.java:111 (the indexed point's Math.sin and Math.cos)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:110-121; geom/GeoCircleFactory.java:43-45; geom/GeoStandardCircle.java:103-111; geom/GeoExactCircle.java:401a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:10161-10162 (LUCENE-6798)a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/test/org/apache/lucene/spatial3d/TestGeo3DPoint.java:896-903; geom/TestGeoCircle.java:136-139
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/SidedPlane.java:34-37; geom/GeoExactCircle.java:401; geom/GeoCircleFactory.java:43; geom/GeoStandardPath.java:184-190; geom/GeoPoint.java:308-311
- TestGeo3DTinyRadiusMatchesWholeWorld.java (lucene/spatial3d, 1 test):
repro/LFV-212/src/org/apache/lucene/spatial3d/TestGeo3DTinyRadiusMatchesWholeWorld.java(47 lines)
Made in parallel with this doc from the minimal repro and an executed probe on main 645d4fe14a20; every value drawn comes from the code or that probe (see the diagram notes).
assets/LFV-212/bug.png(414 KB)
