A heatmap splits the input's bounding box into grid cells and counts the documents in each. For an input that is not a rectangle, the javadoc promises 0 in every cell the shape does not touch.
- Index the point (88, 88) and the square [45, 90] x [45, 90] in a non-geo world [-90, 90]², with a 4-level
QuadPrefixTreeand the defaultRecursivePrefixTreeStrategy. The square is stored as one coarse (level-2) cell. - Ask
calcFacetsfor the level-4 heatmap ofcircle(0, 0, 89): a 16 x 16 grid of 11.25° cells over the circle's bounding box. The javadoc says the cells the circle does not touch are 0. - You expect the square to count only in the 8 of its 16 heatmap cells that touch the circle, and the point, which is outside the circle, not at all.
- Instead the square counts in all 16: the 8 corner cells outside the circle report 1. The point is correctly 0, so the same corner cell leaves out the point but counts the square.
- Why: Lucene walks only into grid cells that touch the circle, so the point's cell is never counted. The square's one coarse cell does touch the circle, and its count is then added to every heatmap cell under it, with no check against the circle.
Applications that compute heatmaps (PrefixTreeStrategy.calcFacets, an experimental API of the opt-in spatial-extras module) with a circle, polygon or shape-collection input over non-point shapes, which default settings index with coarse cells. Ordinary rectangle inputs, the usual map viewport, and point data are not affected. One document is enough; no error is raised.
Mechanism, terms and numbers832 words
HeatmapFacetCounter.calcFacets (also reached through PrefixTreeStrategy.calcFacets) takes any shape as its input and promises, for one that is not a rectangle, that "the non-intersecting cells will all be 0" (HeatmapFacetCounter.java:86-90). The term walk visits heatmap-level cells only when they intersect the input, so points come out right. But a non-point document that RecursivePrefixTreeStrategy indexed as a leaf cell coarser than the heatmap level reaches the visitor once, as an "ancestor", and calcFacets adds its count to every heatmap cell under that leaf, or to every cell when the leaf contains the whole heatmap, without checking those cells against the input (:216-235, :246-286). With a circle, polygon or shape-collection input, cells inside its bounding box but outside the shape report counts. In the repro (the setup of upstream testQueryCircle plus one square document) 8 cells that do not touch circle(0, 0, 89) report 1 instead of 0; one country-sized rectangle under a 3° circle lights up 164 cells outside it. Ordinary rectangle inputs and point data are not affected, and no error is raised. It has been there since the heatmap was added in 5.1.0 (LUCENE-6191, 2015). The recommended fix gives ancestor counts only to cells whose grid cell intersects a non-rectangular input.
- Heatmap:
calcFacets(context, topAcceptDocs, inputShape, facetLevel, maxCells)returns a grid ofcolumns x rowscounts. Each heatmap cell is one prefix-tree cell atfacetLevel, and the grid covers the input's bounding box. For the input the javadoc says: "We always return a rectangle of integers even if the inputShape isn't a rectangle -- the non-intersecting cells will all be 0." (HeatmapFacetCounter.java:86-90). Both classes involved are@lucene.experimental(HeatmapFacetCounter.java:38,PrefixTreeFacetCounter.java:45). - Prefix-tree indexing:
RecursivePrefixTreeStrategystores a shape as grid cells of several sizes (one term each). A point is a chain of cells down to the finest level. A larger shape gets big "leaf" cells where a cell lies inside it (tree/FilterCellIterator.java:51;pruneLeafyBranches, on by default, replaces four leaf siblings by their parent,RecursivePrefixTreeStrategy.java:56,:162-174) and small cells along its boundary. - Ancestor: an indexed leaf coarser than
facetLevel. The facet walk reports it once, and "the count can be considered to be added to all cells below" (PrefixTreeFacetCounter.java:54-59).
PrefixTreeFacetCounter.computewalks the terms with anAbstractVisitingPrefixTreeQueryover the input (PrefixTreeFacetCounter.java:131-136) and descends only into the children that intersect it,cell.getNextLevelCells(queryShape)(AbstractVisitingPrefixTreeQuery.java:244-245). A cell atfacetLevelis counted directly (HeatmapFacetCounter.java:198-214), so point documents outside the input give 0.- A coarser leaf that intersects the input is passed to the visitor once (
PrefixTreeFacetCounter.java:185-191). If it contains the whole heatmap region it goes toallCellsAncestorCount(HeatmapFacetCounter.java:216-217), otherwise to theancestorsmap (:218-224). calcFacetsaddsallCellsAncestorCountto every heatmap cell (:230-235) and each ancestor's count to every heatmap cell under its rectangle throughintersectInterval/incrementRange(:246-286,:291-338). Neither path tests a cell againstinputShape.- For an ordinary
Rectangleinput every heatmap cell intersects the input, so this puts nothing outside it. For a circle, polygon or shape collection, the bounding box's corners lie outside the shape, and the cells there get the count. What matters is the input shape, not the indexed data: a point input is not aRectangleeither (in the harness, point inputs at longitude +180 got such counts too).
Non-geo world [-90, 90]², QuadPrefixTree with 4 levels, default strategy settings (the setup of upstream testQueryCircle). Doc 0 is the point (88, 88); doc 1 is the square [45, 90]², indexed as one level-2 leaf plus level-4 leaves in the cells that only touch its left and bottom edges. calcFacets with circle(0, 0, 89) at level 4 returns a 16 x 16 heatmap of 11.25° cells. The square covers 4 x 4 of them. The 8 that do not touch the circle, the top row [45, 90] x [78.75, 90], the right column [78.75, 90] x [45, 78.75] and [67.5, 78.75]², each report 1. The point, in the corner cell [78.75, 90]², is correctly not counted: that cell reports 1 with the circle input and 2 with the circle's bounding box as input. The cells that intersect the circle (17 non-zero) are the same with and without the fix. With assertions on and off alike, and on every release run from 5.1.0 to 10.5.2 (as a main() port before 9.0).
Bigger shapes give bigger errors: one rectangle of about 40° x 25° (geo, GeohashPrefixTree with 11 levels) under circle(0, 40, 3) at level 4 gives a 24 x 35 heatmap in which 164 of the 184 cells outside the circle report 1: an almost filled rectangle instead of a disc. The same was seen with geohash circles, a shape-collection input (15 of 62 cells outside it), a leaf that covers the whole heatmap (4 of 4 corner cells) and a rectangle that is not grid-aligned (48 of 84 cells).
Exposure and likelihood244 words
- Who is hit. Applications that compute a spatial heatmap with
PrefixTreeStrategy.calcFacetsorHeatmapFacetCounter.calcFacets(opt-inlucene/spatial-extrasmodule,@lucene.experimental) with a non-rectangular input (circle, polygon, shape collection, buffered geometry) over a field that holds non-point shapes indexed with leaves coarser than the heatmap level, which defaultRecursivePrefixTreeStrategysettings commonly produce for large non-point shapes. Downstream, Solr's heatmap facet takes its input shape as WKT (facet.heatmap.geom), and its own circle test shows the wrong cells (see Why it is a bug). - Who is not hit. Ordinary rectangular inputs (the usual map viewport), a null input (the whole world) and point-only data.
- What goes wrong. Cells inside the input's bounding box but outside the shape report non-zero counts: each gets the count of every coarse document whose leaf covers it. No error. The document filter (
topAcceptDocs) and deletions are still honoured, and no document is counted only outside the shape: in an executed probe (3,715 random trials over three prefix-tree configurations, one indexed shape per document) the error only moves where an already-counted document's count lands. - Scale. One document is enough. The error grows with the size of the indexed shapes relative to the heatmap cells: 8 cells in the minimal repro, 164 for one country-sized shape under a small circle.
- How likely. Rare: it needs both a non-rectangular heatmap input and coarse non-point documents, and the searches found no prior report since 5.1.0, although a downstream golden test encodes the wrong values.
The test shows that PrefixTreeStrategy.calcFacets with a circle input gives a non-zero count to a heatmap cell outside the circle when the document (the square [45,90]x[45,90]) is indexed as a leaf cell coarser than facetLevel.
/*
* 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 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.spatial.prefix.tree.QuadPrefixTree;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.context.SpatialContextFactory;
import org.locationtech.spatial4j.shape.Circle;
import org.locationtech.spatial4j.shape.impl.RectangleImpl;
public class TestHeatmapAncestorCellsOutsideInputShape extends LuceneTestCase {
public void testCoarseLeafCountedOutsideCircleInput() throws Exception {
SpatialContextFactory factory = new SpatialContextFactory();
factory.geo = false;
factory.worldBounds = new RectangleImpl(-90, 90, -90, 90, null);
SpatialContext ctx = factory.newSpatialContext();
RecursivePrefixTreeStrategy strategy =
new RecursivePrefixTreeStrategy(new QuadPrefixTree(ctx, 4), "geo");
try (ByteBuffersDirectory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
for (Field f : strategy.createIndexableFields(ctx.getShapeFactory().rect(45, 90, 45, 90))) {
doc.add(f);
}
w.addDocument(doc);
try (DirectoryReader reader = DirectoryReader.open(w)) {
Circle circle = ctx.getShapeFactory().circle(0, 0, 89);
HeatmapFacetCounter.Heatmap heatmap =
strategy.calcFacets(reader.getContext(), null, circle, 4, 1000);
// top-right cell [78.75,90]x[78.75,90] is disjoint from the circle: expected 0, actual 1
assertEquals(0, heatmap.getCount(heatmap.columns - 1, heatmap.rows - 1));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-670/, 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.prefix.TestHeatmapAncestorCellsOutsideInputShape
# the module(s) spatial-extras also need their third-party runtime jars
- Expected vs actual
- Expected 0 for the top-right cell [78.75,90]x[78.75,90], which does not intersect circle(0,0,89) (javadoc: "the non-intersecting cells will all be 0"); actual
expected:<0> but was:<1>(-ea and -da).
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.prefix.TestHeatmapAncestorCellsOutsideInputShape.testCoarseLeafCountedOutsideCircleInput(TestHeatmapAncestorCellsOutsideInputShape.java:54)- The javadoc promises "the non-intersecting cells will all be 0" for a non-rectangular input (
HeatmapFacetCounter.java:86-90). - In the same corner cell a point outside the circle is left out, but the coarse square is counted.
- The wrong cells lie outside the circle even by the grid's own cell relation, so grid approximation does not explain them.
The full argument319 words
- The contract is specific and unqualified. "We always return a rectangle of integers even if the inputShape isn't a rectangle -- the non-intersecting cells will all be 0." (
HeatmapFacetCounter.java:86-90). It names the non-rectangular case. It came in the feature's first commit (248121bb3f7, LUCENE-6191) with theShapeparameter and the circle test; on LUCENE-6191 (#7253) the author posted this exact text when they switched the input fromRectangletoShape. - The upstream test only uses points.
testQueryCircle(TestHeatmapFacetCounter.java:94-132) asserts the promise with two point documents, which never produce coarse leaves;testRandomuses rectangle inputs only (:160-291). - Internally inconsistent. In the corner cell [78.75, 90]² the point (88, 88) is excluded and the square is included: the cell reports 1 with the circle input and 2 with the circle's bounding box as input.
- Not grid approximation. "The shape's boundary is approximated to a grid precision" (
PrefixTreeStrategy.java:50-51) does not explain it: in the downstream circle case the two wrong cells are DISJOINT from the circle by the tree's own cell relation at the heatmap level, and an independent reproducer's documents are exactly grid-aligned. - Nothing relaxes it. No CHANGES entry, MIGRATE note, test, commit or issue changes the promise; none of the later commits on
calcFacets(LUCENE-6650, LUCENE-7291, LUCENE-7787, LUCENE-9394, reformatting) adds a check of the ancestor cells against the input. - Precedent. LUCENE-7291 (#8346), a different wrong ancestor-count accumulation in the same method (dateline-crossing heatmaps), was called "a real bug" by the author and released in 6.1.0 and 5.5.2 (its JIRA fix versions also list 6.0.2 and 5.6, which were never released).
- The author called the sibling pattern a bug. On LUCENE-5735 (#6797), on 2015-02-05, four days before the heatmap commit: "There's a bug in NumberRangePrefixTreeStrategy.calcFacets in which all cells above the parent are counted as topLeaves, when really that can only be done if the leaf cell _contains_ the facet range."
The devil's-advocate check234 words
The strongest case for intended behaviour (the cross-model reviews rated it moderate; the independent reproducer rated it weak, and the devil's advocate found that none of the arguments holds): prefix-tree faceting counts grid-approximated indexed cells; the visitor javadoc says an ancestor's count "can be considered to be added to all cells below" (PrefixTreeFacetCounter.java:54-59); on LUCENE-6191 the author accepted "blocky effects (e.g. all cells on one side of the heatmap could be +1 due to matching an adjacent country)"; the API is experimental; and Solr's SpatialHeatmapFacetsTest circle case expects [0, 1, 1, 1, 1, 1, 1, 0] // curved; we have a 0 in its top row, where columns 1 and 6 do not touch the circle, so a golden output already encodes the behaviour.
Why it fails: the calcFacets promise is about the input shape and names the non-rectangular case; the visitor wording describes the visitor's stream, which calcFacets must map onto its own documented output; "blocky effects" are about indexed shapes inside the heatmap region, not about cells outside the input; experimental status lowers impact, not validity; and the downstream test's own comment is consistent with zeros where the circle curves away, so its two uncommented 1s look like an unnoticed artifact (a reading of the comment, not proof). With the fix its top row becomes [0, 0, 1, 1, 1, 1, 0, 0]. Verdict: not intended, a defect.
Recommended Give ancestor counts only to cells that intersect a non-rectangular input (recommended, validated)
Give an ancestor's count only to heatmap cells whose grid cell intersects a non-rectangular input, relating each such cell once and only when a coarse leaf was found (+78/-6 in HeatmapFacetCounter.java). The repro and the whole spatial-extras module pass with assertions on and off.
final InputShapeCells inputCells =
inputShape instanceof Rectangle || (allCellsAncestorCount[0] == 0 && ancestors.isEmpty())
? null
: new InputShapeCells(heatmap, inputShape, grid, facetLevel, cellWidth, cellHeight);
// in the allCellsAncestorCount loop and in incrementRange:
if (inputCells == null || inputCells.intersects(cBase + r)) {
heatmap.counts[cBase + r] += count;
}
// InputShapeCells.intersects(index), once per cell (condensed): the grid's cell at
// facetLevel under the heatmap cell's center, related to the input
Cell cell = null;
for (CellIterator it = grid.getTreeCellIterator(center, facetLevel); it.hasNext(); ) cell = it.next();
relation[index] = cell.getShape().relate(inputShape).intersects() ? (byte) 1 : (byte) 2;All options and trade-offs5 options · 1,050 words
Fix A was built and validated in a scratch worktree and with the finder's harness, together with a simpler variant of it. Fixes B and C were built and checked by the finder and an independent reproducer; D and E were not built. Any code fix changes two cells of Solr's circle-input heatmap test.
When the input is not a Rectangle and at least one coarse leaf was found, a small helper (InputShapeCells) decides, the first time an ancestor's count would be added to a heatmap cell, whether the cell intersects the input, and caches the answer in a byte[]. It looks up the grid's own cell at facetLevel under the heatmap cell's center and relates its shape to the input, the relation the term walk uses for the cells it visits (AbstractVisitingPrefixTreeQuery.java:244-245). Both ancestor paths (the allCellsAncestorCount loop and incrementRange) skip cells that do not intersect; counts made directly at the heatmap level are untouched. +78/-6 in HeatmapFacetCounter.java; with the new test method, +40 in TestHeatmapFacetCounter.java.
final InputShapeCells inputCells =
inputShape instanceof Rectangle || (allCellsAncestorCount[0] == 0 && ancestors.isEmpty())
? null
: new InputShapeCells(heatmap, inputShape, grid, facetLevel, cellWidth, cellHeight);
// in the allCellsAncestorCount loop and in incrementRange:
if (inputCells == null || inputCells.intersects(cBase + r)) {
heatmap.counts[cBase + r] += count;
}
// InputShapeCells.intersects(index), once per cell (condensed): the grid's cell at
// facetLevel under the heatmap cell's center, related to the input
Cell cell = null;
for (CellIterator it = grid.getTreeCellIterator(center, facetLevel); it.hasNext(); ) cell = it.next();
relation[index] = cell.getShape().relate(inputShape).intersects() ? (byte) 1 : (byte) 2;Trade-offs
- Cost: no extra per-cell lookup or relate and no mask for rectangle inputs, a null input, point data, or when no coarse leaf intersects the input (only a few conditionals). Otherwise at most one grid-cell lookup (
facetLevelsteps) and one relate per heatmap cell that an ancestor touches (bounded bymaxCells), and abyte[]the size of the heatmap. For a complex polygon the relate is the main cost; a refinement could skip the per-cell check for an ancestor that lies WITHIN the input. No benchmark was run. - A simpler variant relates a cell rectangle rebuilt from the heatmap region (dateline-wrapped, clamped to the world bounds) instead of the grid's cell (+66/-6). It passes every test too, but in the harness it drops 18 counts in cells that touch a circle at exactly one point in a Web Mercator world, where the rebuilt rectangle and the grid's cell differ in the last digits. Fix A has none.
- Downstream: the top row of Solr's circle-input heatmap test changes from
[0, 1, 1, 1, 1, 1, 1, 0]to[0, 0, 1, 1, 1, 1, 0, 0].
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, JDK 25: stock with the new test, the standalone repro and verifier suites: 16 tests, 9 fail, with assertions on and off. - With fix A the whole
lucene/spatial-extrasmodule passes: 30 suites, 129 tests (12 of them added: the repro, the new test method and the verifiers' 10), 5 skipped, 0 failed, with assertions on (together withcheckGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMain,forbiddenApisTest) and off. - Standalone runner with fix A shadowed: the repro 1 of 1 and the independent reproducer's suite 6 of 6, assertions on and off; the devil's advocate's probes 4 of 4.
- The finder's harness (3,125,522 heatmaps, 262,910,034 cells), stock against fix A: cells outside a circle or point input with a count go from 224,179 to 0 in the geo and the plain non-geo worlds and from 24,548 to 43 in a Web Mercator world (which stock already gets wrong for rectangle inputs too, from separate bugs); no cell loses a count it should keep; exceptions unchanged; every other category identical.
After all ancestor counts are applied, if the input is not a Rectangle and there were ancestors, rebuild the rectangle of every non-zero cell, relate it to the input and set the cell to 0 if it does not intersect (+25 lines).
Trade-offs
Smallest diff, but it relates every non-zero cell, not only those under coarse leaves, and can also zero a count made at the heatmap level. In the harness's Web Mercator world its rectangles, not clamped to the world bounds, throw 459 more InvalidShapeExceptions than stock for circle inputs, and it drops 50 counts it should keep. Not recommended as it is.
What the checks validated
The repro passes (the finder); upstream TestHeatmapFacetCounter passes 20 of 20 (assertions on, 5 iterations; the cross-model reviews) and 12 of 12 (off, 3 iterations; the review); in the harness's earlier revision, without the Web Mercator world, all 245,020 cells outside the input with a count go to 0.
Whenever the input is not a Rectangle, relate every heatmap cell up front and let both ancestor paths skip the masked cells (the independent reproducer's fix).
Trade-offs
Pays one relate per cell for every non-rectangular heatmap, even for point-only data, and its rebuilt rectangles are not clamped to the world bounds. Its 6 tests and the finder's test pass with it; not run through the harness.
For an ancestor that is not WITHIN the input, walk its sub-cells down to facetLevel with getNextLevelCells(inputShape) and count only the cells reached, as the term walk would for a finely indexed shape.
Trade-offs
Reuses the walk's pruning (sub-trees WITHIN the input need no per-cell relate), but it is more code than A and changes how ancestors flow through calcFacets. It may be the cleaner design, and it is the approach the author described for the sibling defect on LUCENE-5735 ("walk the sub-cells").
For example: cells that do not intersect a non-rectangular input are 0, except for documents indexed with cells coarser than facetLevel.
Trade-offs
No behaviour or downstream change, a credible compatibility choice for an experimental API, but it keeps the inconsistency: a point in a cell outside the shape is left out, a coarse shape in the same cell is counted.
Downstream, Solr's own heatmap test with a circle input (SpatialHeatmapFacetsTest) expects 1 in two cells that do not touch its circle, so a fix changes that expected matrix.
A harness compared every cell of 2.4 million stock heatmaps (229 million cells) with a reference built from the indexed cells read back from the index. Rectangle inputs with area never differed; circle inputs had non-zero cells outside the circle, all from coarsely indexed shapes, which led to the ancestor code and the javadoc promise.
The method in full196 words
Method: bounded-exhaustive translation validation (the hunt's semi-formal tier).
- The hunt unit checked
HeatmapFacetCounter.calcFacetsagainst a reference: a document is counted in a heatmap cell if it has a term at that cell or below it, or a leaf term above it whose cell contains it; only accepted documents; and 0 for cells that do not intersect the input shape, as the javadoc says. - On the stock main jars: 2,446,632 heatmaps, 228,939,474 cells, over geo and non-geo quad, packed-quad and geohash trees (2-5 levels), 30 index seeds of 1-10 point, rectangle and circle documents per configuration, deletions and several segments; rectangle, point and circle inputs. Rectangle inputs with positive area gave 0 mismatches; circle (and some point) inputs gave non-zero cells outside the input, all from documents indexed with coarse leaves. A separate effect for zero-area inputs at longitude +180 with the packed quad tree (2,523 cells) is not this bug. Four planted mutants were all detected.
- The finder then read the ancestor code and the javadoc line and reduced it to a public-API JUnit on the stock main jars. A review by another model family (OpenAI GPT-5.6) ran its own tests before the candidate was filed.
No prior report found (searched 2026-10-27: 224 recorded queries, 57 of them by hand (43 GitHub searches), over Lucene and Solr GitHub, JIRA, the mailing lists and the web). Closest: LUCENE-5735, an analogous, still-open remark about NumberRangePrefixTreeStrategy.calcFacets, and LUCENE-7291, a different, fixed ancestor bug in the same method.
Searches5 searches
No issue, PR, JIRA ticket, list thread, commit or web page reports heatmap cells outside a non-rectangular input that get counts from coarse leaves. HeatmapFacetCounter is functionally unchanged on upstream main, branch_10x and branch_10_6, and no open PR touches it in a related way.
Closest, not this bug: LUCENE-5735 (#6797, open): in 2015 the original author noted that NumberRangePrefixTreeStrategy.calcFacets counts coarser leaves as topLeaves without checking that they contain the facet range, an analogous defect in a different class with a different fix site, never fixed; if read as a partial report, the novelty would be "known". LUCENE-7291 (#8346, released in 6.1.0 and 5.5.2): a different ancestor-count bug in the same method, on the dateline path. LUCENE-6191 (#7253): the feature issue, which discusses "blocky effects" of coarse indexed shapes inside the region, not cells outside the input. Solr's SpatialHeatmapFacetsTest encodes the wrong values in two cells of its circle case without remarking on them.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits, downstream repositories, mailing lists, JIRA archive, web | 2026-10-27 | a scripted prior-report search, 3 runs plus the finder's, 167 queries: 56 GitHub issue/PR, 9 commit, 2 downstream, 75 mailing-list (dev@, java-user@, issues@, commits@, solr-user@), 14 JIRA and 11 web queries, 0 errors | only the feature issue #7253 rated high; no report of this defect |
| GitHub, by hand (apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project, all of GitHub) | 2026-10-27 | 43 read-only searches (part of the 57 queries recorded by hand): heatmap, non-rectangular, non-intersecting, topLeaves, facet.heatmap.geom with circle or polygon | no report; LUCENE-5735 (#6797) is the closest |
| Open PRs | 2026-10-27 | the changed files of all 409 open apache/lucene PRs | #16847 (long doc ids) and #11873 (logging) list the heatmap files; #16911 deletes branch_10x wholesale and lists only 3,000 of its 7,630 files, so it probably touches them too; all unrelated |
| JIRA (LUCENE, SOLR) and the Solr lists | 2026-10-27 | 45 SOLR heatmap issues, 8 read with all comments; 14 LUCENE heatmap issues; the Solr user and dev lists | usage questions and unrelated fixes only |
| History | 2026-10-27 | git log --follow of HeatmapFacetCounter, PrefixTreeFacetCounter and TestHeatmapFacetCounter; CHANGES.txt and MIGRATE.md; LUCENE-6191 with all 21 comments | no change or note relaxes the promise |
Introduced with the heatmap itself by LUCENE-6191 (248121bb3f7, 2015-02-09), together with the promise and the circle input; first released in 5.1.0. Executed: 5.0.0 has no heatmap; 11 releases from 5.1.0 to 10.5.2 (as main() ports before 9.0) and main fail with the same 8 cells. Never fixed.
Versions tested8 rows
Introduced by LUCENE-6191 "Spatial 2D heatmap for PrefixTreeStrategy" (@dsmiley, 248121bb3f7, 2015-02-09; svn r1658298, a JIRA patch, no PR; mirrored as #7253), with the same logic in the branch_5x backport b1c673bcca4. It created HeatmapFacetCounter with the Shape inputShape parameter, the "will all be 0" javadoc, testQueryCircle (points only) and the ancestor-spreading code. Later commits on calcFacets changed other things only: LUCENE-6650 (Filter to Bits), LUCENE-7291 (the dateline split), LUCENE-7787 (MatchNoBits short-circuit), LUCENE-9394 (shape-factory calls) and reformatting; on the ancestor block itself only LUCENE-7291 and the reformatting. The 6.0 module and package moves touched the file, not the logic. Releases 5.1.0-5.5.5, 6.0.0-8.8.1 and 9.0.0-10.5.2 contain it by tag containment (8.8.2-8.11.4 are not in the history clone; 8.11.2 and 8.11.4 were run). Not a regression.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the repro fails with the same 8 cells, assertions on and off (gradle and the standalone runner) |
| 10.5.2 | affected | the same 8 cells; the independent reproducer fails 4 of 6 |
| 9.0.0, 9.12.3, 10.0.0 | affected | the same 8 cells |
| 6.0.0, 6.6.6, 7.7.3, 8.11.2, 8.11.4 | affected | the same 8 cells (main() ports) |
| 5.1.0, 5.5.5 | affected | the same 8 cells (main() port); 5.1.0 is the first release with the heatmap |
| 5.1.0 and 6.0.0, points only | control | correct: no cell outside the circle is counted |
| 5.0.0 | no API | no heatmap API: the repro does not compile |
| main + recommended fix | not affected | the repro passes; the whole lucene/spatial-extras module (129 tests, 12 of them added) passes, assertions on and off |
An independent reproducer, reviews by another model family (OpenAI GPT-5.6, each running its own tests), an intended-behaviour devil's advocate and an adjudication call it real; a registry re-run and an audit reproduced it on main. The brief re-ran the repro on main and 10.5.2 and validated the fix.
Checks and verdicts477 words
The independent reproducer, the devil's advocate, the adjudication, the registry re-run and the audit ran tests on the stock main jars a9779ef3c193 with assertions on and off. The cross-model reviews ran their own tests on main ea57360912ed, whose lucene/spatial-extras is identical; the intended-lens review's tests were re-run on a9779ef3c193 at the review stage, and the review's test at the fact check (1 of 2 fails, its control passes). Not done: a polygon-input test (JTS is on the module's test classpath, but none was written), a run of a downstream server, a benchmark of the fix.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Harness over 2.4 million heatmaps (rectangle inputs with area exact; circle and some point inputs wrong outside the input), then a 1-test JUnit failing on main (-ea, -da), 10.5.2, 9.12.3, and as a main() port on 8.11.2 and 7.7.3; passes with fix B. |
| cross-model review | real (high); intended argument moderate | OpenAI GPT-5.6, executed: its own test finds count 1 in a cell disjoint from the circle (three seeds, -ea and -da); an outside-point control passes; a visitor trace shows the level-2 leaf reaching level-4 faceting; passes with fix B, and upstream TestHeatmapFacetCounter too. |
| Independent reproducer | real | Own 6-test JUnit written from the claim fails 4 of 6 on main (-ea, -da), 10.5.2 and 9.12.3; 2 controls pass; its own fix (C) passes 6 of 6. |
| Cross-model review, intended lens | real (high); intended argument moderate | OpenAI GPT-5.6, executed: own test fails with -ea and -da, point control passes, passes with fix B; a geo rectangle that is not grid-aligned gives 48 of 84 disjoint cells non-zero (review-stage re-run). |
| Intended-behaviour devil's advocate | real (medium-high) | Probes A-E on main (probe D also on 10.5.2), including a Lucene-level copy of Solr's circle-input heatmap test (reproduces its matrix exactly) and the tree-relation check that its two 1s are disjoint from the circle. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's tests with a fresh seed (4 of 6 fail, -ea and -da) and with fix C (6 of 6 pass); read the root cause. |
| Follow-up | history, prior art new, security none | Release sweep 5.0.0 to main, 224-query prior-report search, security probe (3,715 random trials). |
| registry re-run and audit | downgraded: still confirmed, likelihood rare | The finder's JUnit fails on main (-ea, -da) and passes with fix B; the independent reproduction's JUnit fails 4 of 6; impact 70 to 60. |
| this doc's brief | reproduced; fix A validated | Gradle in a scratch worktree (stock 9 of 16 focused tests fail; the whole module passes with fix A, assertions on and off, with format and lint checks), the standalone runner, a grid probe and the finder's harness against stock and fixes A, its simpler variant and B. |
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 415 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,453 words
- Registry title
- Spatial heatmap: HeatmapFacetCounter gives non-zero counts to cells outside a non-rectangular input shape when documents are indexed with coarse leaf cells, although the javadoc says those cells are always 0
- Area
- spatial-extras / prefix-tree heatmap faceting (HeatmapFacetCounter.calcFacets, also reached through PrefixTreeStrategy.calcFacets; coarse 'ancestor' leaf cells reported by PrefixTreeFacetCounter)
- Priority
- Low, rank 349: Needs the opt-in experimental heatmap API with a non-rectangular input shape over coarse non-point documents.
- Impact class
- silent wrong results
- Impact note
- Wrong heatmap cell counts with no error, for applications that compute a spatial heatmap (HeatmapFacetCounter.calcFacets or PrefixTreeStrategy.calcFacets, an @lucene.experimental API of the opt-in spatial-extras module; downstream, for example, a heatmap facet request with a WKT geometry) with a non-rectangular input shape (circle, polygon, shape collection, buffered geometry) over a field that holds non-point documents, which default RecursivePrefixTreeStrategy settings index with leaf cells coarser than facetLevel once a shape is larger than a few facet cells: every heatmap cell under such a leaf that lies inside the input's bounding box but outside the shape gets that document's count (a country-sized shape under a small circle input shows as a filled square instead of a disc), while point-only data and rectangular inputs (the usual map viewport) are never affected; no scale is needed, -ea and -da behave the same, the document filter and deletions are still honoured, and every release since 5.1.0 is affected.
- Summary
- You index non-point shapes (rectangles, polygons) with the default RecursivePrefixTreeStrategy and ask PrefixTreeStrategy.calcFacets (HeatmapFacetCounter) for a heatmap with a circle or another non-rectangular input shape; the javadoc says the grid covers the input's bounding rectangle and that 'the non-intersecting cells will all be 0'. Instead, a document whose shape was indexed as a leaf cell coarser than the heatmap level has its count added to every heatmap cell under that leaf, including cells that do not intersect the input shape at all, because the code that spreads these coarse ('ancestor') counts never checks the cells against the input shape; point documents and rectangular inputs come out right. In the minimal repro (the setup of Lucene's own testQueryCircle plus one square document), 8 cells that are disjoint from the circle report 1 instead of 0, with no error.
- Introduced
- 248121bb3f74a8af032ccd66f903904eaa3e1c59 (LUCENE-6191 'Spatial 2D heatmap for PrefixTreeStrategy', GitHub mirror apache/lucene#7253, by dsmiley; svn trunk r1658298, SVN era, no PR), with the branch_5x backport b1c673bcca4d1d8493812121f8bb32acef3040dc (r1658302, same ancestor logic) [read: git log --follow, git log -L on the ancestor block and git blame -w --ignore-rev 2cbf261032d in the history clone]: the non-rectangular inputShape parameter, the javadoc 'the non-intersecting cells will all be 0' and the ancestor-spreading code arrived together in the feature's first commit. Not a regression: the later commits on the block (LUCENE-6650 Filter to Bits, LUCENE-7015/7060 module and package moves, LUCENE-7291 b33d7176aa3 dateline split, LUCENE-7787 7d57d63375e MatchNoBits short-circuit, LUCENE-9570 reformatting) did not change the omission. (2015-02-09 (trunk 248121bb3f7 and branch_5x b1c673bcca4 the same day; first released in 5.1.0))
- First affected version
- 5.1.0 [exec] (the first release with HeatmapFacetCounter; the history repro fails there with the same 8 cells, and its points-only control passes). 5.0.0, the last release before it, has no heatmap API [exec: the repro does not compile], so every release that ships the heatmap is affected.
- Affected versions
- Every release from 5.1.0 through 10.6.0 (10.6.0 by reading), and main a9779ef3c193; not fixed. [exec] the finder's repro (the JUnit, or a main() port of it for the 5.x, 6.x, 7.x and 8.x APIs) fails with the same 8 cells on 5.1.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da on main); the points-only control passes on 5.1.0 and 6.0.0; the independent reproduction's JUnit fails 4 of 6 on 9.12.3, 10.5.2 and main; the intended-behaviour check's downstream-scenario probe fails on 10.5.2 and main. [read] git tag --contains: 5.1.0-5.5.5 (backport), 6.0.0-8.8.1 and 9.0.0-10.5.2; the 8.8.2-8.11.4 tags are not in the history clone (8.11.2 and 8.11.4 were executed); HeatmapFacetCounter.java and PrefixTreeFacetCounter.java on a9779ef3c193 are byte-identical to the history clone HEAD, and HeatmapFacetCounter is functionally unchanged on upstream main a95c754ee003, branch_10x and branch_10_6 (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): a scripted prior-report search in 3 runs plus the finder's first run (56 GitHub issue/PR queries on apache/lucene, 9 commit queries, 2 downstream-repository queries, 75 mailing-list queries over the dev, java-user, issues, commits and solr-user lists, 14 JIRA queries, 11 web queries; 0 errors), 43 manual read-only GitHub searches (apache/lucene, apache/solr, Elasticsearch, all of GitHub), the changed files of all 409 open apache/lucene PRs (2 unrelated PRs touch the heatmap files: #16847 long doc ids and #11873 logging), the Solr JIRA (45 heatmap issues; SOLR-7005, -7591, -7702, -8197, -10499, -10526, -12398 and -13263 read with all comments), the Solr user and dev lists, and the files' full history. No issue, PR, JIRA ticket, list thread, commit or web page reports that heatmap cells outside a non-rectangular input get counts from coarse leaves. Closest prior art, not this bug: in LUCENE-5735 (gh#6797, open) the original author noted in 2015 that NumberRangePrefixTreeStrategy.calcFacets counts coarser leaves as topLeaves without checking that they contain the facet range, an analogous defect in a different class with a different fix site, never fixed (by code reading still present on main). Precedent: LUCENE-7291 (gh#8346, fixed in 6.1, 6.0.2 and 5.5.2), a different ancestor-spreading bug in the same method, on the dateline path. The feature issue gh#7253 (LUCENE-6191, 21 comments read) introduced the contract and discusses 'blocky effects' of coarse indexed shapes inside the region, not cells outside the input. Downstream golden output, not a report: apache/solr SpatialHeatmapFacetsTest expects 1 in two top-row cells of its circle case that are disjoint from the circle (the intended-behaviour check probe E). Gaps: searches cannot prove absence of private or differently worded discussions.
- 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 release jars. A history sweep ran the repro on 5.0.0 (no heatmap API), 5.1.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2. The cross-model reviews ran on mainea57360912ed, whoselucene/spatial-extrasis identical. The recommended fix was validated in the scratch worktree (the new test, the standalone repro and verifier suites; the wholelucene/spatial-extrasmodule, 129 tests including 12 added ones, with assertions on and off plus format, lint and forbidden-API checks) and with the finder's harness (3,125,522 heatmaps).
- Finding record
lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/HeatmapFacetCounter.java:38 (at a9779ef3c193) (@lucene.experimental), :86-90 (calcFacets @param inputShape: 'We always return a rectangle of integers even if the inputShape isn't a rectangle -- the non-intersecting cells will all be 0'), :173-175 (MatchNoBits short-circuit), :182-184 (allCellsAncestorCount, ancestors map), :187-226 (visitor: facet-level cells counted directly :198-214; a coarser leaf that CONTAINS the heatmap region goes to allCellsAncestorCount :216-217; other coarser leaves go to ancestors :218-224), :231-235 (allCellsAncestorCount added to every cell), :246-286 (ancestor counts spread through intersectInterval/incrementRange; no inputShape check on either path)lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/PrefixTreeFacetCounter.java:45 (at a9779ef3c193) (@lucene.experimental), :54-60 (FacetVisitor.visit javadoc: 'When an ancestor cell is given with non-zero count, the count can be considered to be added to all cells below'), :160-191 (facet-level and leaf visiting); PrefixTreeStrategy.java:217-229 (calcFacets delegates to HeatmapFacetCounter.calcFacets)lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/TestHeatmapFacetCounter.java:94-132 (at a9779ef3c193) (testQueryCircle: circle input, point documents only), :160-291 (testRandom and helpers: rectangle inputs only)GitHub and JIRA, read-only (2026-10-27): apache/lucene #7253 (LUCENE-6191), #8346 (LUCENE-7291), #6797 (LUCENE-5735), #7931 (LUCENE-6873), #8838 (LUCENE-7787); apache/solr solr/core/src/test/org/apache/solr/search/facet/SpatialHeatmapFacetsTest.java (circle-input blocks); SOLR-7005, SOLR-7591, SOLR-12398, SOLR-13263
- The finding in 30 seconds
lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/HeatmapFacetCounter.java:38, (at a9779ef3c193) :86-90, :198-235, :246-338lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/PrefixTreeFacetCounter.java:45, (at a9779ef3c193) :54-59, :131-136, :160-191lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/AbstractVisitingPrefixTreeQuery.java:212-245 (at a9779ef3c193)lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/FilterCellIterator.java:51; (at a9779ef3c193) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/RecursivePrefixTreeStrategy.java:56, (at a9779ef3c193) :125-131, :162-174
- Who is affected
lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/HeatmapFacetCounter.java:38; (at a9779ef3c193) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/PrefixTreeFacetCounter.java:45, (at a9779ef3c193) :85-103; lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/PrefixTreeStrategy.java:76, (at a9779ef3c193) :101-107, :220-229
- Why it looks unintended
lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/HeatmapFacetCounter.java:86-90; (at a9779ef3c193) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/PrefixTreeFacetCounter.java:54-59; (at a9779ef3c193) lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/PrefixTreeStrategy.java:50-51 (at a9779ef3c193)lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/TestHeatmapFacetCounter.java:94-132, (at a9779ef3c193) :160-291
- TestHeatmapAncestorCellsOutsideInputShape.java (JUnit test):
repro/LFV-670/src/org/apache/lucene/spatial/prefix/TestHeatmapAncestorCellsOutsideInputShape.java(58 lines)
A step-by-step picture of the repro's example and a map-scale example, made in parallel with this doc from executed probes on main a9779ef3c193 and the code; the PNG is the final summary card. The alt text describes the intended content.
assets/LFV-670/bug.png(415 KB)
![Summary card: spatial heatmap cells outside a circle input count a large indexed shape. Left, the top-right quarter of the repro's 16 x 16 heatmap for circle(0, 0, 89) at level 4: the square document [45, 90]² is one level-2 cell; all 16 heatmap cells under it report 1, and the 8 of them outside the circle are marked red with a cross; the point (88, 88) is not counted. Middle, at map scale: the expected heatmap of a 3-degree circle is a disc; the actual one over a single large rectangle is almost filled, with 164 of the 184 cells outside the circle reporting 1. Right: why (the coarse cell's count is added to every cell under it without checking the input shape), who is affected, and the fix.](../assets/LFV-670/bug.png)