calcFacets sizes the heatmap by dividing the distance from its first cell to the box's end by the cell width. With Web Mercator bounds that can land a hair above a whole number, and Math.ceil then adds a column past the world's edge.
- A heatmap is a grid of counts.
calcFacets(context, acceptDocs, box, level, maxCells)picks the cell at that level holding the box's lower-left corner, counts enough columns and rows from there to cover the box, and returns the counts and the rectangle they span (region). - A non-geo context (
geo=false) has flat world bounds, for example Web Mercator in metres. The quad tree halves the world at every level, so level 5 has 32 x 32 cells. - With bounds like +/-20,037,508.34 the cell edges are not exact binary numbers, so a width computed from them can be one double (one ulp, unit in the last place) off.
- Use a non-geo context (
geo=false) with Web Mercator bounds in metres, x and y from -20,037,508.34 to 20,037,508.34, and aRecursivePrefixTreeStrategyover the defaultQuadPrefixTree. At level 5 the grid is 32 x 32 cells, each 1,252,344.27 wide. - Ask
calcFacetsfor the level-5 heatmap of the box x 3,000,000 to the east edge, y 0 to 1,000,000, as a map client does when it clamps its viewport to the world. - You expect 14 x 1 cells: the box's first cell starts at x = 2,504,688.54, exactly 14 cells from the edge. A box ending 1 m inside the edge gets exactly that.
- Instead
range / cellWidthgives 14.000000000000002 andMath.ceilmakes 15 columns. The 15th lies past the edge; the code wraps it like a dateline crossing (commented "won't happen if !geo"), so the region's maxX becomes -18,785,164.07. - Building that region throws
InvalidShapeException: maxX must be >= minX: 2504688.5425 to -1.878516406875E7; at the north edge it isY values [...] not in boundary. About 20-40% of boxes ending exactly at the edge fail, in every release since 5.1.0.
Applications computing spatial-extras heatmaps in a non-geo context with Web Mercator-style bounds, for a box ending exactly at the world's east or north edge, as a viewport clamped to the world does. About 20-40% of such boxes fail; that request gets no heatmap. Default geo contexts and integer or power-of-two bounds were not affected in any scan.
Mechanism, terms and numbers520 words
PrefixTreeStrategy.calcFacets (spatial-extras) returns a heatmap: a grid of counts that covers a box at a given prefix-tree level. In a non-geo SpatialContext whose world bounds make the quad tree's computed cell edges inexact doubles (so a cell's width can come out one ulp off), such as Web Mercator in metres (+/-20037508.34 or +/-pi*6378137), a box whose max X or max Y equals the world's max edge can make it throw InvalidShapeException instead. calcRowsOrCols divides the distance from the corner cell to the box's end by the corner cell's width, which is one double below its nominal value, gets a result a hair above a whole number (14.000000000000002 for the example) and Math.ceil adds a column. That column lies past the world's edge; the half-cell snap cannot absorb it, the branch commented "wraps dateline (won't happen if !geo)" makes maxX negative, and the shape factory rejects the region. About 20-40% of edge boxes throw; boxes inside the world, the default geo context and integer or power-of-two bounds never did in any scan. The call fails before the index is read, so it returns nothing and leaves the index alone. Present since heatmaps were added in 5.1.0, through 10.6.0 and main; a small cap on the column and row counts fixes it.
- Cell size from the corner cell.
calcFacetsfinds the level-5 cell holding the box's corner (3,000,000, 0) (:113-121): x [2,504,688.5425, 3,757,032.8137499997].cellWidth = cornerRect.getWidth()(:125) is1252344.2712499998, one double below 40,075,016.68 / 32 = 1,252,344.27125. - One column too many.
calcRowsOrCols(:341-361) computesrange = requestRange + (requestMin - cellMin)= 17,037,508.34 + 495,311.4575 = 17,532,819.7975 (:346), exactly 14 cells to the east edge, thenMath.ceil(range / cellRange)(:350):14.000000000000002, so 15. The only cap is the whole-world count,Math.round(worldRange / cellRange)= 32 (:354-360, "possibly due to rounding/edge issue"), not the 14 cells left before the edge. In other cases the computed range is one ulp high instead; the effect is the same. - The snap misses a whole cell.
heatMaxX = heatMinX + columns * cellWidth= 21,289,852.61 (:158), a full cell past the edge; the "numeric conditioning issue" snap only fires within half a cell (:159-160). - The dateline branch runs in a non-geo context.
else if (heatMaxX > worldRect.getMaxX()) { // wraps dateline (won't happen if !geo)(:161-162) subtracts the world width:heatMaxX= -18,785,164.06875. For Y there is no wrap branch (:165-168), soheatMaxYstays at 21,289,852.61, outside the world. - The region is rejected.
ctx.getShapeFactory().rect(heatMinX, heatMaxX, heatMinY, heatMaxY)(:170-172) throwsInvalidShapeException("maxX must be >= minX", or "Y values [...] not in boundary"), before any term is read, so even an empty reader reproduces it.
LFV-316 is a related rounding bug in QuadPrefixTree / PackedQuadPrefixTree cell edges for non-power-of-two bounds (same family as #9800). With its fix applied the minimal test still fails, and across 3,792 round-number calls the exceptions go from 698 (stock) to 943 (LFV-316's fix) to 0 (this fix).
Exposure and likelihood263 words
- Who.
PrefixTreeStrategy.calcFacets/HeatmapFacetCounter.calcFacetsusers with a non-geo context whose world bounds make the quad tree's cell edges inexact doubles: both Web Mercator extents (+/-pi*6378137 and the rounded +/-20037508.34), withQuadPrefixTreeandPackedQuadPrefixTree. Not affected in any scan: the default geo context and bounds such as +/-180, +/-1000, +/-1024, +/-2^25, +/-2e7 or the zero-basedENVELOPE(0,700000,1300000,0)of #9800. - An ordinary trigger. spatial4j rejects non-geo boxes outside the world, so a client whose map viewport reaches past the east or north edge has to shrink the box; a client that clamps it to the world sends a box ending exactly at the edge. Passing
null(the whole world) works. - How often. 777 of 1,989 random east-edge boxes throw for +/-pi*6378137 and 412 of 1,993 for +/-20037508.34 (levels 1-10); 144 of 416 round-number boxes at the east or north edge (levels 1-8); 1,396 of 5,056 edge boxes in a second scan, where all 5,056 boxes ending 1 m inside returned.
- What goes wrong. An undocumented unchecked exception instead of a heatmap, for that request only. In a 10,112-call probe every call that returned gave a region covering the box and inside the world (no silent wrong result), the index is not read or changed, later calls are unaffected, and assertions make no difference.
- Workaround. Inset the box's max X/Y slightly from the world's max edge (1 m worked in every scan that tried it).
- Likelihood. Plausible: a map client in such a setup hits it without doing anything unusual, but such setups seem rare and the searches found no earlier report.
HeatmapFacetCounteris@lucene.experimental.
One JUnit class in lucene/spatial-extras, public API, one assertion, no documents (an empty reader is enough, the exception comes before the index is read). Deterministic; it fails the same way on main, 10.6.0, 10.5.2 and 9.12.3, with 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.spatial.prefix;
import java.util.Map;
import org.apache.lucene.index.MultiReader;
import org.apache.lucene.spatial.prefix.tree.QuadPrefixTree;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.context.SpatialContextFactory;
import org.locationtech.spatial4j.shape.Rectangle;
import org.locationtech.spatial4j.shape.SpatialRelation;
public class TestHeatmapBoxAtWorldEastEdgeNonGeo extends LuceneTestCase {
public void testBoxEndingAtWorldEastEdge() throws Exception {
SpatialContext ctx =
SpatialContextFactory.makeSpatialContext(
Map.of(
"geo", "false",
"worldBounds", "ENVELOPE(-20037508.34, 20037508.34, 20037508.34, -20037508.34)"),
null);
PrefixTreeStrategy strategy = new RecursivePrefixTreeStrategy(new QuadPrefixTree(ctx), "f");
Rectangle box = ctx.getShapeFactory().rect(3_000_000, 20037508.34, 0, 1_000_000);
// expected: a heatmap covering the box; actual: InvalidShapeException "maxX must be >= minX"
HeatmapFacetCounter.Heatmap heatmap =
strategy.calcFacets(new MultiReader().getContext(), null, box, 5, 1000);
assertEquals(SpatialRelation.CONTAINS, heatmap.region.relate(box));
}
}
cp $REPRO/org/apache/lucene/spatial/prefix/TestHeatmapBoxAtWorldEastEdgeNonGeo.java \
$LUCENE/lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/
cd $LUCENE && ./gradlew :lucene:spatial-extras:test \
--tests org.apache.lucene.spatial.prefix.TestHeatmapBoxAtWorldEastEdgeNonGeo \
-Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false (same failure); the test uses no randomness
- Expected
- A 14 x 1 heatmap whose region covers the box and ends at the east edge.
- Observed
InvalidShapeException: maxX must be >= minX: 2504688.5425 to -1.878516406875E7fromHeatmapFacetCounter.calcFacets(line 172), assertions on and off.
More files, environment, full output242 words
Same package as Lucene's TestHeatmapFacetCounter (the file carries the Apache license header). The seed only picks the test framework's settings, which the test does not use; 20 of 20 random-seed iterations fail the same way. The same test is the suggested regression test.
The follow-up's version-portable main() (four edge boxes and the 1-m-inside control) is the evidence for the older releases (section History); it is not part of the repro.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT), spatial4j 0.8; 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 (the only platform run; Java floating point is strict IEEE-754) |
cp $REPRO/org/apache/lucene/spatial/prefix/TestHeatmapBoxAtWorldEastEdgeNonGeo.java \
$LUCENE/lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/
cd $LUCENE && ./gradlew :lucene:spatial-extras:test \
--tests org.apache.lucene.spatial.prefix.TestHeatmapBoxAtWorldEastEdgeNonGeo \
-Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false (same failure); the test uses no randomness
# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF, -Ptests.asserts=true (identical with =false)
TestHeatmapBoxAtWorldEastEdgeNonGeo > testBoxEndingAtWorldEastEdge FAILED
org.locationtech.spatial4j.exception.InvalidShapeException: maxX must be >= minX: 2504688.5425 to -1.878516406875E7
at __randomizedtesting.SeedInfo.seed([DEADBEEF:4427E2D0B808582C]:0)
at app/spatial4j@0.8/org.locationtech.spatial4j.shape.impl.ShapeFactoryImpl.rect(ShapeFactoryImpl.java:121)
at app//org.apache.lucene.spatial.prefix.HeatmapFacetCounter.calcFacets(HeatmapFacetCounter.java:172)
at app//org.apache.lucene.spatial.prefix.PrefixTreeStrategy.calcFacets(PrefixTreeStrategy.java:227)
at app//org.apache.lucene.spatial.prefix.TestHeatmapBoxAtWorldEastEdgeNonGeo.testBoxEndingAtWorldEastEdge(TestHeatmapBoxAtWorldEastEdgeNonGeo.java:41)
# standalone runner: the same failure on main (-ea, -da), 10.6.0 (-ea, -da), 10.5.2 (-ea) and 9.12.3 (-ea)
# side probe, same world and level: north-edge box -> InvalidShapeException: Y values [2504688.5425 to 2.128985261125E7] not in boundary ...
# box ending 1 m inside the east edge -> 14 x 1, region covers the box
# with the fix: the test passes (assertions on and off); the edge box gets the same 14 x 1 heatmap- The javadoc promises a region covering the box ("If null is given, the entire world is assumed") and names no exception; the code deliberately throws only
IllegalArgumentException. - The branch that breaks is commented "wraps dateline (won't happen if !geo)", and this context is
geo=false. - The same box 1 m inside the edge gets the expected 14 x 1 heatmap.
The full argument260 words
- The contract.
HeatmapFacetCounter.java:86-90: "The actual heatmap area will usually be larger since the cells on the edge that overlap are returned. ... If null is given, the entire world is assumed." So the contract expects inputs that reach the world edge (null, the whole world, ends at both max edges), though it makes no explicit promise for every custom non-geo world. The javadoc names no exception: its only failure note, in@param maxCells, has been cut off at "If the cells exceed this count, an" (:92) since LUCENE-6650 (2015; it read "... an IllegalArgumentException is thrown."). The code throws its only deliberate exception,IllegalArgumentException, for too many cells (that note,:140-150) and for a too-largemaxCells(:102-105);InvalidShapeExceptionis aRuntimeException, not anIllegalArgumentException. - The code states the assumption that breaks.
:161"wraps dateline (won't happen if !geo)"; the snaps at:159and:166("numeric conditioning issue") and the cap at:354("possibly due to rounding/edge issue") show rounding at the edge was meant to be absorbed, but they cover only half a cell or the whole world. - Tests.
TestHeatmapFacetCounter.testQueryCircle(:94-99) usesgeo=falsewith a [-90, 90] world, whose cell widths are exact;testRandomusually queries the world bounds (:192), but in a geo context. No test has a non-geo world with inexact cell widths. - History. The code is unchanged since it was added (LUCENE-6191, 2015) apart from formatting and a rename; nothing restricts heatmaps to geo contexts or to boxes inside the edge.
The devil's-advocate check144 words
The strongest case: the class is @lucene.experimental; heatmaps were designed and tested mostly for geo; in the LUCENE-6191 discussion (#7253) the author benchmarked a geo=false 360x360 world with the input "slightly inset so heatmap doesn't include a row just > 90 and just < 90"; and quad trees over non-power-of-two bounds are known to be numerically fragile (#9800).
Why it fails: the inset is an issue-comment workaround for an extra row inside the world, not a documented precondition, and gives no reason for an exception when the extra cell would lie outside the world; null means the whole world; the code itself says the wrap "won't happen if !geo"; the exception is not the IllegalArgumentException the code throws on purpose for bad requests; and #9800 was resolved as Fixed. The verifiers and the cross-model lenses rated the intended-behaviour case weak. Verdict: a defect.
Recommended Cap columns and rows at the cells left before the world's max edge (validated)
For a non-geo context, cap the column count at the cells left between the first cell and the world's max X edge, round((worldMaxX - heatMinX) / cellWidth), and cap the rows the same way at max Y. The edge box then gets its 14 x 1 heatmap; the whole spatial-extras module passes.
- final int columns =
+ int columns =
calcRowsOrCols(
cellWidth, heatMinX, inputRect.getWidth(), inputRect.getMinX(), worldRect.getWidth());
+ if (!ctx.isGeo()) {
+ // FP rounding in calcRowsOrCols can add a column; there is none beyond the world's max edge
+ columns =
+ Math.max(
+ 1, Math.min(columns, (int) Math.round((worldRect.getMaxX() - heatMinX) / cellWidth)));
+ }
// (rows: the same cap, Math.round((worldRect.getMaxY() - heatMinY) / cellHeight), in geo too)All options and trade-offs3 options ยท 629 words
One fix was validated in a scratch worktree of 645d4fe14a20 with gradle: the hunt's fix, with one line reflowed by google-java-format (whitespace only; the hunt's layout failed the format check). A tolerance before Math.ceil was considered but not run.
After calcRowsOrCols, for a non-geo context cap the column count at round((worldMaxX - heatMinX) / cellWidth), and cap the row count at round((worldMaxY - heatMinY) / cellHeight) (rows never wrap). Math.round absorbs the one-ulp error; Math.max(1, ...) keeps at least one cell. 1 file, +18/-7.
- final int columns =
+ int columns =
calcRowsOrCols(
cellWidth, heatMinX, inputRect.getWidth(), inputRect.getMinX(), worldRect.getWidth());
+ if (!ctx.isGeo()) {
+ // FP rounding in calcRowsOrCols can add a column; there is none beyond the world's max edge
+ columns =
+ Math.max(
+ 1, Math.min(columns, (int) Math.round((worldRect.getMaxX() - heatMinX) / cellWidth)));
+ }
// (rows: the same cap, Math.round((worldRect.getMaxY() - heatMinY) / cellHeight), in geo too)Trade-offs
- Scope. It only lowers a column or row count that would reach past the world's max edge. By code reading every such count ends in this exception today except one, which returns a valid result that the fix changes: when the extra column makes the count equal to the whole world's (a box starting inside column 1 and ending at the east edge),
:155-157moves the region to the world's min edge, so stock returns 2^level columns, the first of which the box does not reach. With the fix the region starts at the corner cell and has 2^level - 1 columns, as stock already returns at levels where rounding adds no column. Both contain the box, with no exception (executed by the fact check at levels 5 and 7, and 5, 7, 8 and 10, for the two Mercator extents). The PR should add a regression test for this case and state which result it intends. - Geo X is left alone (crossing the dateline is legitimate there); rows have no wrap branch, so the row cap applies in geo too, where a row past the max edge would put
maxYoutside the world (code reading). - Cost. One division and one compare per axis per call.
- A smaller formulation of the same cap (not run) passes
worldMax - heatMin(the world width for geo X) ascalcRowsOrCols's last argument, so its existing cap (:355) does the work.
What the checks validated
- Stock + test: fails, assertions on and off (gradle).
- + fix: the whole
lucene/spatial-extrasmodule passes with assertions on: 27 classes, 118 tests, 0 failures, 5 skipped (the new test 1/0,TestHeatmapFacetCounter4/0). - With assertions off: the same 27 classes, 118 tests, 0 failures.
- With the one line reflowed by google-java-format (the hunt's layout failed the format check),
:lucene:spatial-extras:check -x test(google-java-format, ecjLint, forbiddenApis) passes, and the new test andTestHeatmapFacetCounterpass again. - Earlier, on
a9779ef3c193: every verifier's tests pass with it (finder 1/1, the independent reproduction 4/4, the intended-behaviour check 7/7, adjudication 4/4), the follow-up's historymain()passes, and the edge-box scans drop to 0 exceptions.
Subtract a small relative epsilon from range / cellRange in calcRowsOrCols (:350).
Trade-offs
Needs an epsilon choice; a box that really extends a hair past a grid line would lose its last partial column (documents in that sliver not counted); leaves the non-geo wrap branch reachable for any other overshoot. Not recommended over the cap.
Callers inset the box's max X/Y slightly from the world's max edge (1 m worked for every control box in the scans), or pass null for the whole world.
Trade-offs
Useful until a fix ships; it does not fix the contract.
Downstream, by code reading only: Solr's JSON heatmap facet passes the request's box to calcFacets and turns only IllegalArgumentException into a 400 (apache/solr main e59ce3c97dd, FacetHeatmap.java:255-267), so there this would probably surface as a server error for that one request. Other Solr versions were not checked.
A bounded-exhaustive comparison of every heatmap cell with a reference rebuilt from the indexed terms, extended to non-geo worlds with Web Mercator bounds, turned up an exception class that only those bounds produced. Direct probes over ten world bounds isolated the trigger, and the arithmetic was traced by hand.
The method in full124 words
- Translation validation. A harness compared every cell of every
calcFacetsresult on the real jar with a reference recomputed from the indexed terms, plus checks of the region's geometry, over small bounded input spaces. Extended to non-power-of-two (Web Mercator) non-geo worlds, it showedInvalidShapeExceptionatHeatmapFacetCounter.calcFacets:172, which persisted with LFV-316's tree fix applied. - Probes. Edge-touching and world-edge probes over ten world bounds isolated the trigger (a box ending at the world's max X or max Y edge) and measured the reach; 0 exceptions with the fix.
- Then a public-API JUnit, an independent reproducer, cross-model reviews (an independent reviewer from another model family, OpenAI GPT-5.6, executed mode), a devil's-advocate check and an adjudication. The method is semi-formal: targeted, bounded searches rather than a proof.
No prior report found (2026-10-27: 109 executed tool queries, manual JIRA, list and GitHub searches, all 409 open PRs' files), refreshed 2026-10-30. Closest items are different defects: #7931 (a test-helper bug), #9800 (index-time QuadPrefixTree rounding, the LFV-316 family), #8346 (dateline ancestor counts).
Searches4 searches
#7931 (LUCENE-6873, "heatmap bug causing InvalidShapeException") shares the exception name only: the bad rectangle came from the test helper SpatialTestCase.randomRectangle in a geo context; the fix corrected a precision issue in randomGaussianMeanMax, test code only, in 5.5. #9800 (LUCENE-8755, fixed in 8.3.0) is the same numerical family at index time; this bug fails both before (8.2.0) and after (8.3.0) that fix. #7253 (LUCENE-6191) is the feature itself.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs, commits, downstream repos, lists, JIRA, web (scripted prior-report search) | 2026-10-27 | two runs, 45 and 64 executed queries on exception texts, class and method names, heatmap with worldBounds, mercator, edge, rounding (the web only in the second run, 7 searches) | no hit about this defect; the related items below |
| JIRA (LUCENE, SOLR) and the Lucene and Solr lists | 2026-10-27 | heatmap with InvalidShapeException, worldBounds, mercator, edge, rounding; "maxX must be"; calcRowsOrCols; every HeatmapFacetCounterTest Jenkins-failure thread on dev@ | LUCENE-6873 (#7931), LUCENE-7291 (#8346), LUCENE-8755 (#9800), SOLR-7591, SOLR-7005, SOLR-13263: different defects |
| Open apache/lucene PRs and the file's history | 2026-10-27 | changed files of all 409 open PRs; git log -S / -L on the code | no open PR touches HeatmapFacetCounter.java; no logic change since 2015 |
| GitHub refresh | 2026-10-30 | issue and PR search in apache/lucene for five phrasings (positive control "heatmap": 14 issues) | nothing new; the file is unchanged on upstream main 716d6f216f5 |
Introduced with the feature by 248121bb3f74 / b1c673bcca4d (LUCENE-6191, 2015-02-09), first released in 5.1.0; only reformatted since. Executed and affected from 5.1.0 to 10.6.0 and on main; not fixed upstream on 2026-10-30.
Versions tested7 rows
248121bb3f74 (trunk, svn r1658298) and the branch_5x backport b1c673bcca4d (svn r1658302), 2015-02-09, LUCENE-6191 "Spatial 2D heatmap for PrefixTreeStrategy" (now #7253), by dsmiley, committed directly to svn (no PR). CHANGES 5.1.0, New Features. git blame -w, ignoring the formatting commit 2cbf261032d (LUCENE-9570) and the rename 26075fc1dc0 (LUCENE-9394), attributes all 70 lines of calcRowsOrCols and the snap/wrap block to it. The three code regions are identical at all 97 release tags from 5.1.0 to 10.5.2 in the local clones; the clones have no tags for 8.8.2 to 8.11.4, and those releases were executed instead. Not a regression.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails, gradle and standalone, assertions on and off |
| 10.6.0 | affected | the minimal test fails (release jars, -ea, -da) |
| 10.5.2, 9.12.3 | affected | the minimal test fails (release jars) |
5.1.0 to 10.5.2 (13 releases) and main a9779ef3c193 | affected | the follow-up's version-portable main() fails 4 of 4 edge boxes, control OK, byte-identical messages (5.1.0, 10.5.2 and main also with -da; 8.2.0 and 8.3.0 bracket LUCENE-8755) |
| 8.8.2 to 8.11.3 (8 releases) | affected | the same main() fails 4 of 4 edge boxes, control OK, same messages as 8.11.4 (release jars, -ea); these releases have no tags in the local clones |
| 5.0.0 | no API | no heatmap API (the repro does not compile) |
fix (scratch worktree of 645d4fe14a20) | not affected | the test and the spatial-extras module pass |
An independent reproducer, cross-model reviews (both real, high), a devil's-advocate check and an adjudication: confirmed, high confidence. This doc re-ran the minimal test on main and three releases and validated the fix with the whole spatial-extras module.
Checks and verdicts273 words
The cross-model reviews used an independent reviewer from another model family (OpenAI GPT-5.6) that compiled and ran its own tests. The hunt's checks ran on main a9779ef3c193, where lucene/spatial-extras is identical to 645d4fe14a20.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | JUnit fails on main (-ea, -da), and it or a main() variant on 7.7.3, 8.2.0, 8.11.2, 9.12.3 and 10.5.2; probes measured the reach; passes with the fix. |
| Independent reproducer | real | Own JUnit fails 3 of 4 (east edge, north edge, exact extent; the 1-m-inside control passes) on main, 10.5.2 and 9.12.3; traced 14.000000000000002; showed the finder's box depends on LFV-316's rounding and picked boxes that do not. |
| Cross-model review, refute lens | real, high | Own test fails at the exact east edge, the 1-m-inside control passes; own arithmetic trace; the fix passes its test and TestHeatmapFacetCounter. |
| Cross-model review, intended lens | real, high; intended case weak | Own test fails at the east and north edges and with PackedQuadPrefixTree; exact-binary world passes. |
| Devil's advocate | real, high | Built the strongest intended case and rebutted it; own JUnit (clamped viewports) fails 4 of 7, 7 of 7 with the fix; scans of unaffected bounds. |
| adjudication | confirmed, high | Re-ran the independent reproduction's tests with a fresh seed (3 of 4 fail, -ea, -da; still 3 of 4 with LFV-316's fix; 4 of 4 pass with this fix). |
| this doc | as above | Minimal test on main (gradle and standalone, -ea, -da), 10.6.0, 10.5.2 and 9.12.3; the fix with the whole spatial-extras module and its static checks. |
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 511 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,359 words
- Registry title
- HeatmapFacetCounter.calcFacets throws InvalidShapeException for a box that ends at the world's east or north edge in a non-geo context with projected (Web Mercator) world bounds, because floating-point rounding adds one cell past that edge
- Area
- spatial-extras / prefix-tree heatmap faceting (HeatmapFacetCounter.calcFacets, reached through PrefixTreeStrategy.calcFacets)
- Priority
- Low, rank 621: Heatmap faceting throws a clean InvalidShapeException before it reads the index, with no wrong counts or damage, and only in the opt-in non-geo spatial-extras setup.
- Impact class
- crash
- Impact note
- Applications that compute heatmaps with PrefixTreeStrategy.calcFacets over QuadPrefixTree or PackedQuadPrefixTree in a non-geo SpatialContext whose world bounds give inexact grid lines (both Web Mercator extents, +/-pi*6378137 and the rounded +/-20037508.34, are affected) get an undocumented unchecked InvalidShapeException instead of a heatmap for roughly 20-40% of input boxes that end exactly at the world's max X or max Y edge, which is what a map viewport clamped to the world sends; the default geo context and integer, zero-based or power-of-two world bounds were not affected in any scan, boxes inside the world never throw, the call fails cleanly before the index is read (no wrong counts, no index damage), ordinary scale, every release since 5.1.0.
- Summary
- You configure a non-geo SpatialContext (geo=false) with Web Mercator world bounds such as ENVELOPE(-20037508.34, 20037508.34, 20037508.34, -20037508.34), index points with RecursivePrefixTreeStrategy over the default 12-level QuadPrefixTree, and ask PrefixTreeStrategy.calcFacets for the heatmap of a box inside the world that ends at its east edge, for example x in [3,000 km, east edge], y in [0, 1,000 km] at facet level 5. You expect a heatmap whose region covers the box (14 columns by 1 row), as the calcFacets javadoc describes, or at worst the documented too-many-cells IllegalArgumentException. Instead calcRowsOrCols computes range / cellWidth = 14.000000000000002 in floating point, Math.ceil makes that 15 columns, the region would end a whole cell past the east edge, the non-geo code takes the branch commented 'wraps dateline (won't happen if !geo)' and makes maxX negative, and building the region throws InvalidShapeException 'maxX must be >= minX'; the same box at the north edge throws 'Y values [...] not in boundary'.
- Introduced
- 248121bb3f74a8af032ccd66f903904eaa3e1c59 (trunk, svn r1658298) and its branch_5x backport b1c673bcca4d1d8493812121f8bb32acef3040dc (svn r1658302, the commit that shipped in 5.1.0): LUCENE-6191 'Spatial 2D heatmap for PrefixTreeStrategy' (migrated as apache/lucene#7253), by GitHub dsmiley, committed directly to svn with no PR [read: git blame -w in the full-history clone, ignoring the formatting-only commits 2cbf261032d (LUCENE-9570) and 26075fc1dc0 (LUCENE-9394 shape-factory rename), attributes all 70 lines of calcRowsOrCols and of the heatMaxX/heatMaxY snap and wrap block to it; git log -L and git log -S show no logic change since]. Not a regression: the defect is as old as the API. (2015-02-09)
- First affected version
- 5.1.0 [exec: the follow-up's version-portable repro throws InvalidShapeException in 4 of 4 edge cases with -ea and with -da, control OK]; 5.0.0 has no HeatmapFacetCounter [exec: the repro does not compile; lucene-spatial-5.0.0.jar has no such class]
- Affected versions
- Every release from 5.1.0 through 10.6.0 (10.6.0 [exec] on its release jars by the bug doc's test, -ea and -da) and main a9779ef3c193; not fixed. [exec] the follow-up's version-portable main() (four edge cases and a 1-m-inside control) fails 4 of 4 with the control OK on 5.1.0 (-ea, -da), 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.2.0 and 8.3.0 (before and after the LUCENE-8755 getCell change), 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea, -da) and main (-ea, -da), with byte-identical messages, and passes on main with the fix shadowed; the independent reproduction's JUnit fails 3 of 4 on main, 10.5.2 and 9.12.3; the intended-behaviour check's JUnit fails 4 of 7 on main, 10.5.2 and 9.12.3; the finder's JUnit or main() variant fails on 7.7.3, 8.2.0, 8.11.2, 9.12.3, 10.5.2 and main. [read] the three code regions (calcRowsOrCols, the column/row computation, the heatMax snap/wrap block), normalised for comments, whitespace and the spatial4j rename, are identical at all 97 release tags 5.1.0-10.5.2 in the local clones (the 8.8.2-8.11.4 tags are missing there; 8.11.4 was executed). No commit to HeatmapFacetCounter.java on main and no open PR touches it (read 2026-10-27).
- 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
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (searched 2026-10-27, read-only; the prior-report record, verdict new): two scripted prior-report searches (45 and 64 executed queries over GitHub issues and PRs, commits, downstream repositories, the dev, java-user, issues and commits lists, JIRA and the web; 64 and 122 distinct hits, none about this defect), manual JIRA searches (SOLR and LUCENE heatmap issues, 'maxX must be', calcRowsOrCols, worldBounds, mercator, rounding), the Lucene and Solr user and dev lists (25 targeted queries; every HeatmapFacetCounterTest Jenkins-failure thread read), GitHub-wide searches for the exception texts and class names, the issue trackers of four Solr heatmap client projects, Stack Overflow, the changed files of all 409 open apache/lucene PRs (none touches HeatmapFacetCounter.java) and the file's full git history. Nothing describes calcFacets producing one column or row too many at the world's max edge in a non-geo context, or an InvalidShapeException from its region rectangle. Related, different defects: LUCENE-6873 / #7931 ('heatmap bug causing InvalidShapeException', fixed in 5.5 in the test helper SpatialTestCase.randomRectangle, geo context); LUCENE-8755 / #9800 (index-time QuadPrefixTree exception with non-geo bounds, fixed in 8.3.0; this bug reproduces before and after it; the LFV-316 family); LUCENE-7291 / #8346 (dateline ancestor counts); LUCENE-6191 / #7253 (the feature issue, where a benchmark comment insets the input from a 360x360 world's edge to avoid an extra row, no exception). Gaps: GitHub search is token-based and rate-limited; private downstream trackers cannot be searched.
- Lucene commit tested
- main
645d4fe14a20(tested 2026-10-30): the minimal test through gradle in a clean scratch worktree (assertions on and off, stock and with the fix, plus the whole spatial-extras module and its static checks with the fix) and through the standalone runner (-ea,-da); the 10.6.0 (-ea,-da), 10.5.2 and 9.12.3 release jars (Maven Central, SHA-1 checked). The hunt, the verifiers, the adjudication and the follow-up ran on maina9779ef3c193(the cross-model reviews onea57360912ed);lucene/spatial-extrasis identical ata9779ef3c193and645d4fe14a20. The follow-up's version sweep ran 13 releases from 5.1.0 to 10.5.2; after the fact checks the samemain()also ran on the eight releases from 8.8.2 to 8.11.3, which have no tags in the local clones.
- Finding record
lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/HeatmapFacetCounter.java:38 (at a9779ef3c193) (@lucene.experimental), :86-92 (calcFacets javadoc: 'The actual heatmap area will usually be larger since the cells on the edge that overlap are returned ... If null is given, the entire world is assumed'; the @param maxCells sentence is truncated), :102-108 (maxCells IllegalArgumentException; null input = world bounds), :121-138 (cellWidth/cellHeight from the corner cell; columns and rows from calcRowsOrCols), :140-150 (too-many-cells IllegalArgumentException), :153-168 (half-cell 'numeric conditioning issue' snaps; the branch commented 'wraps dateline (won't happen if !geo)'; no clamp for maxY), :169-172 (region rectangle built through the shape factory), :341-361 (calcRowsOrCols: Math.ceil(range / cellRange), capped only at Math.round(worldRange / cellRange))lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/PrefixTreeStrategy.java:220-229 (at a9779ef3c193) (calcFacets delegates to HeatmapFacetCounter.calcFacets)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/HeatmapFacetCounter.java:113-125, :127-138, :153-172, :341-361
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/HeatmapFacetCounter.java:38 (@lucene.experimental)
- Minimal repro
a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/PrefixTreeStrategy.java:220-229
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/HeatmapFacetCounter.java:86-90, :102-105, :140-150, :159-166, :354a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/TestHeatmapFacetCounter.java:94-99, :192
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/HeatmapFacetCounter.java:155-157, :341-361
- History
a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:10950
- TestHeatmapBoxAtWorldEastEdgeNonGeo.java (lucene/spatial-extras, 1 test):
repro/LFV-666/src/org/apache/lucene/spatial/prefix/TestHeatmapBoxAtWorldEastEdgeNonGeo.java(44 lines)
Made in parallel with this doc from the same executed example (main 645d4fe14a20); every value drawn is asserted against the Lucene sources at that commit or the diagram's own executed probe.
assets/LFV-666/bug.png(511 KB)
![Summary card: 'Heatmap calcFacets adds a column past the world's east edge, then throws'. The example (non-geo world ENVELOPE(-20037508.34, 20037508.34, 20037508.34, -20037508.34), QuadPrefixTree, facet level 5, box x [3,000,000, east edge], y [0, 1,000,000]) drawn on the row 0 <= y < 1,252,344.27 of the 32-column level-5 grid; line 172 builds rect(2504688.5425, -1.878516406875E7, 0.0, 1252344.27125), maxX < minX, and throws InvalidShapeException. A table of expected versus actual: the edge box 14 x 1 heatmap versus InvalidShapeException; columns counted 14 versus 15; the same box 1 m inside 14 x 1 both; the box at the north edge a heatmap versus InvalidShapeException. Why: the corner cell's width is one double below 40,075,016.68 / 32, so 14 cells divide to 14.000000000000002 and Math.ceil gives 15; column 15 ends a whole cell past the edge and the dateline branch wraps it; the fix caps the columns at 14.](../assets/LFV-666/bug.png)