Range facet counters count, for each range, the hits whose value falls in it, and FacetResult.value is the number of hits in at least one range. An optional fastMatchQuery lets them skip hits that cannot be in any range, such as a bounding box around distance rings.
fastMatchQuery, then the same documents in two segments). Bottom left: inside LongRangeFacetCounts, +3 hits, −0 misses, value 3 next to a range count of 1. Right: expected 1; actual 3 in one segment and 1 in two. Select the still to open it at full size. Generated with AI, checked against the code.- Index three documents with prices 0, 10 and 20 and count one range, [0, 5], with
LongRangeFacetCounts. One document is in the range, so the range count and the dimension totalFacetResult.valueare both 1. - Pass a
fastMatchQuerythat matches only the document priced 0. That is its documented use: a cheap filter that skips documents that cannot be in any range. You expect the same two numbers. - You get a range count of 1 but a total of 3, with
LongRangeFacetCounts,DoubleRangeFacetCountsand both range-on-range counters, default index settings and no error. - Why: each segment first adds all its hits to the total, then subtracts only the checked hits that fall in no range. The two hits the
fastMatchQueryskipped are added and never subtracted. - On main, put the documents priced 10 and 20 in their own segment and the total is 1: the
fastMatchQuery, a term query, has no scorer there, so that segment is skipped before the add. A small fix (four loops, +6/-8 lines) that counts each checked document gives 1 in both layouts.
Anyone who passes a fastMatchQuery to LongRangeFacetCounts or DoubleRangeFacetCounts (single-valued values or fields) or to the range-on-range counters and shows the dimension total, on 4.7.0 to 10.5.2 and main. The total is inflated by the skipped hits; per-range counts, hits and drill-down are right.
Mechanism, terms and numbers748 words
LongRangeFacetCounts, DoubleRangeFacetCounts and Long/DoubleRangeOnRangeFacetCounts accept an optional fastMatchQuery: only hits that match it are checked against the ranges. In the single-valued and range-on-range counting loops each segment first adds its whole hits.totalHits() to the dimension total, then subtracts only the checked hits that have no value or fall in no range, so the hits the fastMatchQuery rejected stay in FacetResult.value. With the values 0, 10 and 20, the range [0, 5] and a fastMatchQuery that matches only the document with 0, the range count is 1 but the total is 3 instead of 1. On main, a segment where the fastMatchQuery has no scorer is skipped before the add, so the same documents in two segments give 1: the total depends on the segment layout. The multi-valued path of the same classes counts each checked document and gives 1. Per-range counts, hits and drill-down are correct. Every release tested from 4.7.0 (LUCENE-5418, 2014) to 10.5.2, and main, is affected (the releases not run have the same code), range-on-range since 9.5.0. Counting each checked document instead of the bulk add fixes it; the whole lucene/facet test suite passes with that change.
- Range facets:
LongRangeFacetCountsandDoubleRangeFacetCountscount, per range, the collected hits whose value (from field doc values or aLongValuesSource/DoubleValuesSource) falls in it;LongRangeOnRangeFacetCountsandDoubleRangeOnRangeFacetCountsdo the same for indexed ranges (LongRangeDocValuesField). - Dimension total:
FacetResult.value, the "Total number of documents containing a value for this path" (FacetResult.java:30-34). For range facets that is the hits in at least one range: upstreamtestBasicLongcollects 101 documents and assertsvalue=22. fastMatchQuery: optional; "we first test this Query to see whether the document should be checked for matching ranges" (FacetCountsWithFilterQuery.java:41-44).createIterator(hits)intersects the segment's hits with it and returns null when it has no scorer in the segment (:59-90,:75-78).
- The single-valued loops skip a segment whose iterator is null, then add the whole segment's hit count:
totCount += hits.totalHits();(RangeFacetCounts.java:140for field doc values,LongRangeFacetCounts.java:150,DoubleRangeFacetCounts.java:177,RangeOnRangeFacetCounts.java:100). - The loop visits only the documents of the intersected iterator. Checked documents with no value, or in no range (
LongRangeCounter.finish(),LongRangeCounter.java:163-167), are subtracted:totCount -= missingCount(RangeFacetCounts.java:190-191,LongRangeFacetCounts.java:165-166,DoubleRangeFacetCounts.java:192-193,RangeOnRangeFacetCounts.java:123). - A hit the
fastMatchQueryrejects is never visited, so it is added and never subtracted. The total becomes "documents in some range" plus "rejected hits of segments where thefastMatchQueryhas a scorer". - A segment where the
fastMatchQueryhas no scorer is skipped before the add, so its rejected hits are not counted: the total depends on how documents are spread over segments. A term query has no scorer in a segment that lacks its term; a point range query has none in a segment with no points for the field or whose minimum and maximum values lie outside the range (PointRangeQuery.java:252-275), but a segment whose values straddle the range without a match gets an empty scorer, and its rejected hits are counted. A doc-values range query returns no scorer only when the segment lacks the field or a doc-values skip index rules the range out (SortedNumericDocValuesRangeQuery.java:124-132,:182-186); in the diagram's probe (no skip index) it had a scorer in both segments, so every rejected hit was counted. - The multi-valued loops count each checked document (
totCount++,RangeFacetCounts.java:168,:180;LongRangeFacetCounts.java:199,:211;DoubleRangeFacetCounts.java:226,:238) and are right. The field path switches every segment to the multi-valued loop when one hit segment is truly multi-valued (RangeFacetCounts.java:95-106,:126).
Values 0, 10 and 20; MatchAllDocsQuery; one range [0, 5]; fastMatchQuery = TermQuery(tag:in), which matches only the document with 0.
| counter | no fastMatchQuery | with it, one segment | with it, 0 alone in its own segment |
|---|---|---|---|
LongRangeFacetCounts, LongValuesSource | 1 | 3 | 1 |
LongRangeFacetCounts, field doc values | 1 | 3 | 1 |
DoubleRangeFacetCounts, DoubleValuesSource | 1 | 3 | 1 |
DoubleRangeFacetCounts, field doc values | 1 | 3 | 1 |
LongRangeOnRangeFacetCounts (INTERSECTS) | 1 | 3 | 1 |
DoubleRangeOnRangeFacetCounts (INTERSECTS) | 1 | 3 | 1 |
LongRangeFacetCounts, MultiLongValuesSource (control) | 1 | 1 | 1 |
Every range count is 1. The same numbers on main with assertions on and off, on 10.5.2 and on 9.12.3. Larger examples from the checks: 10 documents, range [0, 2], fastMatchQuery [0, 4]: value 8 where 3 is right; a fastMatchQuery that matches only an out-of-range document: every range count 0 and value 9.
Exposure and likelihood272 words
- Who is exposed. Applications that pass a
fastMatchQuerytoLongRangeFacetCountsorDoubleRangeFacetCounts(with aLongValuesSource/DoubleValuesSource, or field doc values when no hit segment is truly multi-valued) or toLongRangeOnRangeFacetCounts/DoubleRangeOnRangeFacetCounts, and readFacetResult.value, for example to show "N results within 10 km". Lucene's ownDistanceFacetsExamplepasses a bounding box as thefastMatchQuery(DistanceFacetsExample.java:215-231); on java-user@ (2022) gsmiller described it as extra filtering "to only documents that might match one of the ranges being faceted on", such as a price filter around price ranges. - Default vs opt-in. The
fastMatchQueryis optional (the constructors without it pass null).RangeFacetCounts,LongRangeFacetCounts,DoubleRangeFacetCountsandFacetCountsWithFilterQueryare@lucene.experimental; the range-on-range counters carry no such tag. Nothing else needs a non-default setting:IndexWriterConfig, codec and searcher can be the defaults (the repro setsNoMergePolicyonly to pin the segment layout). - Scale. Any index size. The total is too high by the hits the
fastMatchQueryrejects in segments where it has a scorer, at most all main-query hits. In 60 random indexes, 2,160 of 4,800 counter checks were overcounted, by up to 1,269. - Silent. No exception and no log line, and the per-range counts look right. Nothing is stored wrong, so a fix needs no reindex.
- Not affected. Per-range counts,
childCount, hits, scores and drill-down; the multi-valued paths;MatchingFacetSetsCounts; any counter without afastMatchQueryover an ordinaryFacetsCollector(a sampled collector is a separate case, see the fix). No use of these classes was found on the default branches of three large downstream search engines (9 code searches); that is "no known use", not proof.
The test shows that LongRangeFacetCounts with a fastMatchQuery that only rejects documents outside the range counts those rejected hits in FacetResult.value (one segment, v = 0, 10, 20, range [0,5]).
/*
* 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.facet.range;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.facet.FacetResult;
import org.apache.lucene.facet.FacetsCollector;
import org.apache.lucene.facet.FacetsCollectorManager;
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.LongValuesSource;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestLongRangeFacetFastMatchTotal extends LuceneTestCase {
public void testFastMatchQueryDoesNotInflateTotal() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
for (long v : new long[] {0, 10, 20}) {
Document doc = new Document();
doc.add(new NumericDocValuesField("v", v));
w.addDocument(doc);
}
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
FacetsCollector fc =
new IndexSearcher(reader).search(MatchAllDocsQuery.INSTANCE, new FacetsCollectorManager());
FacetResult result =
new LongRangeFacetCounts(
"v",
LongValuesSource.fromLongField("v"),
fc,
NumericDocValuesField.newSlowRangeQuery("v", 0, 5),
new LongRange("0-5", 0, true, 5, true))
.getAllChildren("v");
// only v=0 is in a range, so expected total 1; actual 3 (the 2 hits the fastMatchQuery rejects are kept)
assertEquals(1, result.value.intValue());
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-151/, 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/facet/build/libs/lucene-facet-$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.facet.range.TestLongRangeFacetFastMatchTotal
# the module(s) facet also need their third-party runtime jars
- Expected vs actual
- Expected total 1 (only v=0 is in a range, the same as without the fastMatchQuery); actual 3 (
expected:<1> but was:<3>).
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:<1> but was:<3>
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.facet.range.TestLongRangeFacetFastMatchTotal.testFastMatchQueryDoesNotInflateTotal(TestLongRangeFacetFastMatchTotal.java:57)FacetResult.valueis the "Total number of documents containing a value for this path";testBasicLongasserts 22 for 101 hits.- The
fastMatchQueryis a speed-up: "only documents passing the filter are checked for the matching ranges". - The same documents give 3 in one segment and 1 in two, and the multi-valued path of the same class gives 1.
The full argument310 words
- Contract.
FacetResult.java:30-34: "Total number of documents containing a value for this path ... it will only be counted once in this value."FacetCountsWithFilterQuery.java:41-44: "we first test this Query to see whether the document should be checked for matching ranges. If this is null, all documents are checked."LongRangeFacetCounts.java:76-79: "only documents passing the filter are checked for the matching ranges, which is helpful when the providedLongValuesSourceis costly per-document, such as a geo distance."LongRangeOnRangeFacetCounts.java:27-31: the class counts "all the documents in theFacetsCollector(or that match a fast match query) that contain ranges that "match" the provided ranges".CHANGES.txt:12787-12792(LUCENE-5418): a fast-match filter "to avoid computing the value for documents that are out of bounds". - Tests.
testBasicLongassertsvalue=22for 101 collected documents (TestRangeFacetCounts.java:73-107). The randomized tests that pass afastMatchQueryassert only per-range counts;testCustomDoubleValuesSourceassertsvalue=3with afastMatchQuerythat wrapsMatchAllDocsQueryand rejects nothing (:1671-1700); the demo test's three documents all lie inside the bounding box. No test asserts the total under afastMatchQuerythat rejects hits, and the fix breaks none. - History. LUCENE-5371 (2013) made the total subtract the hits in no range. LUCENE-5418 (2014) added the fast-match filter in front of the older bulk add; its 16 comments, and the 80 texts of #11901 (range-on-range, 2022), never mention the total. The field doc-values loop (since 9.0.0) and range-on-range (since 9.5.0) put the add after the null check from the start, so their totals have depended on segment layout since they were added; #12408 (9.11.0 / 10.0.0), a lazy-initialisation change, did the same for the
LongValuesSource/DoubleValuesSourceloops, which until then counted every rejected hit. - Precedent. The 2021 dev@ thread "FacetResult#value semantics?" (mikemccand: "we really should strive to count each hit only once") led to LUCENE-9952, 9953 and 9964, which fixed double counting in other classes.
The devil's-advocate check150 words
The strongest "intended" case: the classic range classes are @lucene.experimental; the totCount field comment says "Total number of hits" (RangeFacetCounts.java:49, RangeOnRangeFacetCounts.java:45) and the code seeds it with hits.totalHits(); the bulk add has stood since 2014; and the range-on-range javadoc calls the fastMatchQuery a way "to quickly discard hits using some heuristic" (LongRangeOnRangeFacetCounts.java:63).
Why it fails: the code already subtracts the checked hits in no range, so the total is not "all hits" in any configuration (the comment is stale since LUCENE-5371, and testBasicLong asserts 22 for 101 hits); no reading gives 3 in one segment and 1 in two for the same documents; the same class gives 1 on its multi-valued path; @lucene.experimental is about API stability, not wrong numbers. Verdict: a defect (adjudication: confirmed, high; the cross-model review rated the intended case weak). It lowers the severity: only the aggregate is wrong.
Recommended Count each checked document
Count each document the loop checks (totCount++) instead of adding the segment's hits.totalHits() up front, in the four single-valued loops, and fix the stale totCount comment. The repro and the whole lucene/facet suite (308 tests) pass with assertions on and off.
// RangeFacetCounts, LongRangeFacetCounts, DoubleRangeFacetCounts, RangeOnRangeFacetCounts
- totCount += hits.totalHits();
for (int doc = it.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; ) {
+ totCount++; // count checked documents only: the fastMatchQuery may skip some hits
... // unchanged: count the value, or missingCount++
}
...
totCount -= missingCount; // unchangedAll options and trade-offs4 options · 560 words
Over an ordinary FacetsCollector without a fastMatchQuery every hit is checked, so totals do not change; with one, the total becomes the checked documents in some range, which the multi-valued loops and MatchingFacetSetsCounts already compute. Side effect, executed: RandomSamplingFacetsCollector keeps each segment's unsampled totalHits() but passes only the sampled documents, so a sampled range facet today reports value=950 next to a range count of 50 (1,000 documents, range [0, 499], sample of 100), even without a fastMatchQuery; with the fix it reports 50, consistent with the sampled count. After amortizeFacetCounts, which divides the total by the sampling rate and caps it at the live documents, that is 1,000 today and 500 with the fix (read, not run). No upstream test combines sampling with range facets, but callers may rely on the raw or the amortized value, so a maintainer may want to decide it explicitly or handle sampling in a separate change. The fix conflicts textually with open PR #16439. Only query-time numbers change, so no reindex is needed.
In RangeFacetCounts.java:140, LongRangeFacetCounts.java:150, DoubleRangeFacetCounts.java:177 and RangeOnRangeFacetCounts.java:100, drop totCount += hits.totalHits(); and add totCount++ as the first statement of the loop; the existing totCount -= missingCount stays. The totCount comment (RangeFacetCounts.java:49, RangeOnRangeFacetCounts.java:45) becomes "Number of checked documents that fall in at least one range." +6/-8 in four files.
// RangeFacetCounts, LongRangeFacetCounts, DoubleRangeFacetCounts, RangeOnRangeFacetCounts
- totCount += hits.totalHits();
for (int doc = it.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; ) {
+ totCount++; // count checked documents only: the fastMatchQuery may skip some hits
... // unchanged: count the value, or missingCount++
}
...
totCount -= missingCount; // unchangedTrade-offs
- One increment per checked document instead of one add per segment; no benchmark was run.
- Changes sampled range totals as described above (a consistency gain, but a visible change that may deserve its own test, CHANGES note or separate change).
What the checks validated
- Scratch worktree of
a9779ef3c193, gradle, JDK 25: the new test fails 2 of 2 on stock (assertions on and off) and passes 2 of 2 with the fix; the whole:lucene:facet:test, 40 suites and 308 tests, 0 failures, with assertions on (7 skipped) and off (8 skipped), using the test before one over-long line was wrapped (the same code);:lucene:facet:check -x test(google-java-format, ecj lint and forbidden APIs) passes. - Standalone runner with the fix shadowed: the new test 2 of 2 and the finder's, the reproducer's and the devil's advocate's tests 12 of 12 pass. Earlier:
TestRangeFacetCountsandTestRangeOnRangeFacetCounts42 of 42, and the same change patched into 4.7.0 and 9.5.0 fixes the release probes there.
Count the visited documents and subtract hits.totalHits() - checked after each segment loop.
Trade-offs
Same numbers as counting each checked document, with more code; not run.
Describe FacetResult.value with a fastMatchQuery as an upper bound.
Trade-offs
Not recommended: the bound would depend on the segment layout and on whether the fastMatchQuery has a scorer per segment, which no caller can use.
Drop the subtraction too, so the total is every collected hit.
Trade-offs
Not recommended: it would not depend on the segment layout, but it contradicts the FacetResult.value javadoc and testBasicLong (22 of 101), a larger contract change; not run.
Products that build distance or price range facets with a fastMatchQuery inherit the inflated totals until they upgrade; nothing wrong is stored, so the fix needs no reindex.
An AI finder read the range-on-range counting code while wiring it into a bounded-exhaustive check, saw that the total adds every hit before the fastMatchQuery filters, and confirmed it with a JUnit test. A second unit re-found the same root cause independently with an SMT model of the counting loops.
The method in full207 words
Method: code reading (the hunt's "non-formal" tier), a hunt unit (document and field factories), 2026-10-26.
- The finder was wiring
RangeOnRangeFacetCountsinto the unit's bounded-exhaustive differential check ofBinaryRangeDocValues/QueryType.matches. That harness passes nofastMatchQuery, so it could not see the bug; the finder noticed the bulk add while reading the counting loop, confirmed it with a probe and a public-API JUnit on the stock jars, and found the same pattern in the classic range counters. - About two hours later a hunt unit re-found the same root cause, by its own record independently, with an SMT model (linear integer arithmetic, Z3) of the counting loops: obligation O7c (a
fastMatchQuerythat excludes hits makes the single-valued total differ from the documents in a range) is satisfiable, so the model has such a case. Three properties hold in the model, because their negations are unsatisfiable: O7a (without afastMatchQuerythe single-valued total equals the documents in a range), O7b (the multi-valued total equals the documents in a range) and O7d (the overcount is exactly the number of excluded hits). A 24-seed randomized differential against a brute-force oracle agreed. That candidate was withdrawn as a duplicate. - Then independent AI tools reproduced, reviewed and judged it (see the checks).
No prior report found (best-effort search, 2026-10-26): the scripted prior-report search's 89 queries over GitHub, commits, downstream projects, four mailing lists, JIRA and the web; 31 direct searches; and all 413 open PRs. Nearest: the 2021 FacetResult#value accuracy fixes in other classes.
Searches4 searches
No issue, PR, JIRA entry, list thread or web page found reports this defect. Upstream main fetched on 2026-10-26 still has the four bulk adds, and no open PR changes them.
Nearest items, none of them this bug: #6481 (LUCENE-5418) and #11901 / #11869, which introduced the code and never discussed the total; the 2021 dev@ thread "FacetResult#value semantics?" with #10991 (LUCENE-9952), #131 (LUCENE-9953) and #11003 (LUCENE-9964), which fixed double counting of multi-valued documents in other classes and support the "count each hit once" contract; #10985 / #127 (LUCENE-9946), which added the multi-valued range paths; #16439 (open, stale), batching in LongRangeFacetCounts that keeps the bulk add. Gaps: GitHub Discussions were not searched; one GitHub and two web queries were throttled.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | a scripted prior-report search (40 issue and PR queries, 3 commit queries; comments read for the top 20) and 18 direct searches: fastMatchQuery, fastMatchFilter, "fast match", the range and range-on-range class names, FacetResult#value, "range facet" value wrong, hits.totalHits facet; Lucene.NET | no report; the items listed above |
| Open PRs | 2026-10-26 | the heads of all 413 open apache/lucene PRs, diffed against the range, range-on-range, FacetCountsWithFilterQuery, FacetResult and FacetsCollector sources | none changes the bulk add; #16439 keeps it |
| Mailing lists and JIRA | 2026-10-26 | 32 tool queries over dev@, java-user@, issues@ and commits@, 10 direct list searches, 6 JIRA-archive queries and 7 issues read | related threads only (FacetResult#value semantics, 2021; RangeFacetsCount Question, 2022) |
| Downstream repositories and web | 2026-10-26 | 2 tool and 2 direct downstream queries; 6 tool and 3 direct web queries | no report |
Introduced by LUCENE-5418 (2014, first released in 4.7.0) and copied into range-on-range by #11901 (9.5.0). Release probes fail on every tested release from 4.7.0 to 10.5.2 and main. The overcount has depended on segment layout since 9.0.0 for the field doc-values path, since 9.5.0 for range-on-range and since 9.11.0 for the values-source paths. Not fixed upstream.
Versions tested8 rows
Introduced by LUCENE-5418 "Don't use .advance on costly (e.g. distance range facets) filters" (commit subject "faster drill-down/sideways on costly filters"; mikemccand; an svn patch, JIRA migrated as #6481): trunk 20a280c33cd, branch_4x 5c0bbfb86f5 (2014-02-06), released in 4.7.0. It added the fast-match filter in front of the bulk add from LUCENE-5339 / LUCENE-5371 (2013). The range-on-range counters copied the same accounting in #11901 (mdmarshmallow, cbfed77fd33 / d9fc418914a, 2022-12-30, released in 9.5.0). #12408 (179b45bc23e, main since 10.0.0; backport #13300 26ec4c600d7, 9.11.0) moved the add after the null check in the LongValuesSource / DoubleValuesSource loops (which before 9.0.0 also served the field constructors): until then they counted every rejected hit, and from 9.11.0 only those of segments where the fastMatchQuery has a scorer. The field doc-values loop (since 9.0.0) and range-on-range (since 9.5.0) had the add after the null check from the start. No commit-level builds: the parents of the introducing commits lack the API.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test: 2 of 2 fail (gradle and the standalone runner, assertions on and off); totals [3, 3, 3, 3, 3, 3, 1] |
| 10.5.2, 9.12.3 | affected | this doc's test: 2 of 2 fail, the same totals; the finder's, the independent reproduction's and the intended-behaviour check's tests fail too |
| 9.11.0, 9.12.3, 10.0.0, 10.5.2, main | affected | values-source release probe: 3 instead of 1, also after adding a second segment of out-of-range documents (skipped: the fast match has no scorer there) |
| 4.7.0, 4.10.4, 5.3.0, 5.5.5, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.10.0 | before 9.11.0 | values-source release probe (before 9.0.0 the field constructors used a values source too): 3 instead of 1, and 5 after adding that second segment (every rejected hit counted) |
| 9.10.0, 9.5.0 | affected | this doc's test: 2 of 2 fail; two-segment totals [3, 1, 3, 1, 1, 1, 1]: the values-source counters count every rejected hit, while field doc values and range-on-range already skip the segment where the fast match has no scorer |
| 9.5.0 (range-on-range), 9.12.3, 10.5.2, main | affected | Long and Double range-on-range probe: 3 instead of 1; the finder's JUnit fails 2 of 2 |
| 4.6.1, 9.4.2 | no API | the probes do not compile: no LongRangeFacetCounts with a fast match (4.6.1), no rangeonrange package (9.4.2) |
| 4.7.0, 9.5.0 and main + the fix | not affected | the probes give 1 everywhere; on main the whole lucene/facet suite passes |
An independent reproducer, a devil's advocate, an adjudication and reviews by another model family (which ran their own tests) all found it real; the adjudication confirmed it with high confidence. Registry re-runs and this doc's gradle runs reproduced it on main a9779ef3c193.
Checks and verdicts359 words
Every check wrote or ran its own test against the stock jars, and each ran at least one of them with assertions off.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-API JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; the fix passes it and TestRangeFacetCounts + TestRangeOnRangeFacetCounts (42 of 42). |
| cross-model review, refute lens | real (high) | An independent reviewer from another model family (OpenAI GPT-5.6) wrote its own tests on the LongValuesSource, field doc-values, DoubleValuesSource and range-on-range paths (assertions on; the LongValuesSource test also with them off); root cause agreed; the fix passes the focused upstream suites (22/22 and 20/20). |
| Independent reproducer | real | Own 9-test class with a fixed segment layout: 7 of 9 fail on main, 10.5.2 and 9.12.3 (value 8 where 3 is right; value 9 with every range count 0; single- vs multi-valued; segment layout); controls pass. |
| Cross-model review, intended lens | real (high) | OpenAI GPT-5.6: the intended-behaviour argument is weak; LongValuesSource gives 3 and MultiLongValuesSource 1 on the same data, and one rejected multi-valued document lowers the total from 3 to 1. |
| Devil's advocate | real | No doc, test or discussion makes the overcount intended; found the segment-layout dependence (3 in one segment, 1 in two) on main, 10.5.2 and 9.12.3. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's test with a new seed (7 of 9 fail, -ea and -da), the finder's test with -da (2 of 2 fail), and the independent reproduction's test with the fix (9 of 9 pass). |
| security assessment | none | A wrong aggregate only, bounded by the main query's live hits (4,800 executed checks); file publicly. |
| registry re-runs | reproduced | Follow-up re-run: 2 of 2 fail, and 100 of 100 over 50 derived seeds with -da; re-run on main a9779ef3c193: 2 of 2 fail. |
| this doc's validation | reproduced, fix validated | New fixed-layout test: 2 of 2 fail on main (gradle and standalone, -ea and -da), 10.5.2 and 9.12.3; with the fix it passes, and so does the whole lucene/facet suite (308 tests) with assertions on and off. |
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 513 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,221 words
- Registry title
- Range facet counters add every hit the fastMatchQuery rejects to FacetResult.value, so the dimension total is too high and depends on the segment layout
- Area
- facet / range and range-on-range facet counts (FacetResult.value with a fastMatchQuery)
- Priority
- Low, rank 324: Only the aggregate FacetResult.value of the opt-in, experimental range facet counters with a filtering fastMatchQuery is inflated, while per-range counts, hits and drill-down stay correct.
- Impact class
- silent wrong results
- Impact note
- Users of LongRangeFacetCounts or DoubleRangeFacetCounts on the single-valued paths (a LongValuesSource or DoubleValuesSource, or field doc values when no hit segment is truly multi-valued) or of Long/DoubleRangeOnRangeFacetCounts who pass the optional fastMatchQuery and let it reject some hits (its documented use, for example the bounding box around distance ranges in Lucene's DistanceFacetsExample) get a dimension total FacetResult.value that is too high by the rejected hits of every segment where the fastMatchQuery has a scorer, with the default IndexWriterConfig, codec and searcher at any scale; per-range counts, childCount, hits and drill-down are correct, the classes are @lucene.experimental, and every release since 4.7.0 (range-on-range since 9.5.0) is affected.
- Summary
- You build a range facet counter (LongRangeFacetCounts or DoubleRangeFacetCounts on a single-valued field or values source, or a range-on-range counter) with a fastMatchQuery that removes only hits outside every range, which is what it is for, and read FacetResult.value. You expect the same dimension total as without the fastMatchQuery: the number of hits that fall into at least one range. Instead each segment adds all of its hits up front but subtracts only the documents the fastMatchQuery let through, so the hits it rejected stay in the total (3 instead of 1 in the minimal repro, while the range count is 1), and the same documents and query give 3 in one segment and 1 in two.
- Introduced
- 20a280c33cd9 (trunk, 'LUCENE-5418: faster drill-down/sideways on costly filters', by mikemccand; same-day branch_4x commit 5c0bbfb86f5e, the one released in 4.7.0; JIRA LUCENE-5418, migrated as apache/lucene#6481, svn patch without a GitHub PR): it added the optional fast-match filter to LongRangeFacetCounts and DoubleRangeFacetCounts and kept the older per-segment 'totCount += hits.totalHits' (LUCENE-5339/5371, 2013), so rejected documents are added and never subtracted [read: git log -L / -S and git show in the history clone; exec: release level only, 4.6.1 has no such API, 4.7.0 fails, the per-checked-document fix shadowed over the 4.7.0 jars passes; no commit-level build]; the range-on-range copy came with cbfed77fd33c (apache/lucene#11901 'Github#11869: Add RangeOnRangeFacetCounts', by mdmarshmallow, merged 2022-12-30; branch_9x d9fc418914ae, released in 9.5.0) (2014-02-06 (LUCENE-5418, classic range facets); 2022-12-30 (#11901, range-on-range facets))
- First affected version
- 4.7.0 [exec] for LongRangeFacetCounts and DoubleRangeFacetCounts (4.6.1 has no such class or fast-match parameter [exec: probe does not compile]); 9.5.0 [exec] for Long/DoubleRangeOnRangeFacetCounts (9.4.2 has no rangeonrange package [exec: does not compile])
- Affected versions
- every release from 4.7.0 to 10.5.2 and main ea57360912ed. [exec] classic range facets on 4.7.0, 4.10.4, 5.3.0, 5.5.5, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.10.0, 9.11.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea, and -da on 4.7.0 and main); range-on-range (Long and Double) on 9.5.0, 9.12.3, 10.5.2 and main; the candidate JUnit fails 2/2 on 9.5.0, 9.12.3, 10.5.2 and main. [read] the same bulk add is at every release tag from 4.7.0 to 10.5.2; 5.0.0-5.2.1 and 8.9.0-8.11.3 not run. The shape changed in 9.11.0 (#12408, backported as #13300; on main since 10.0.0): before it every rejected hit was counted (two-segment probe 5 instead of 1), from it on only the rejected hits of segments where the fastMatchQuery has a scorer (3 instead of 1), which is what makes the total depend on segment layout. Not fixed upstream: upstream main fetched 2026-10-26 still has the four bulk adds.
- 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: code reading; also reached by Z3, fuzzing/PBT
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new: a scripted prior-report search with 89 queries (40 GitHub issue/PR, 3 commit, 2 downstream-repo, 32 mailing-list over dev@, java-user@, issues@ and commits@, 6 JIRA-archive, 6 web; 1,223 distinct hits, none the same bug), 18 own read-only GitHub searches (including apache/lucenenet), 10 own mailing-list searches, 3 own web queries, and the heads of all 413 open apache/lucene PRs diffed against the range, rangeonrange, FacetCountsWithFilterQuery, FacetResult and FacetsCollector sources; earlier runs by the finder (14 GitHub, 3 commit, 2 downstream queries) and the intended-behaviour check (20 list, 3 JIRA queries) also found nothing); upstream main fetched 2026-10-26 is still ea57360912e with the four bulk adds unchanged, and no open PR changes them (#16439, open and stale, keeps the bulk add); related, not reports of this bug: #6481 / LUCENE-5418 (introduced the fast-match filter), #11901 / #11869 (range-on-range), the 2021 dev@ thread 'FacetResult#value semantics?' with #10991 / LUCENE-9952, #131 / LUCENE-9953 and #11003 / LUCENE-9964 (multi-valued double counting in other classes; they support the 'count each hit once' contract); gaps: GitHub Discussions not searched, one GitHub and two web queries throttled
- 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 and 9.12.3 release jars (this doc's test). Earlier checks ran on mainea57360912ed, whose facet module is identical, 35 release-level runs covered 4.6.1 to main, and the fact check also ran this doc's test on 9.10.0 and 9.5.0. The fix was validated in the scratch worktree: the new test, the wholelucene/facetsuite (40 suites, 308 tests) with assertions on and off, and:lucene:facet:check -x test.
- Finding record
lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/RangeOnRangeFacetCounts.java:45-46 (comment 'Total number of hits'), :100 (totCount += hits.totalHits()), :123 (totCount -= missingCount)lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java:74-80 (constructor javadoc: only documents passing the fastmatch are checked), :150, :166 (single-valued path), :199, :211 (multi-valued path: totCount++ per checked document)lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java:177, :193 (single-valued path), :226, :238 (multi-valued path)lucene/facet/src/java/org/apache/lucene/facet/range/RangeFacetCounts.java:49-50 (comment 'Total number of hits'), :140, :191 (single-valued field path), :168, :180 (multi-valued field path)lucene/facet/src/java/org/apache/lucene/facet/FacetCountsWithFilterQuery.java:35, :42-43 ('filter the match set'; fastMatchQuery javadoc: 'whether the document should be checked for matching ranges')lucene/facet/src/java/org/apache/lucene/facet/FacetResult.java:31-33 (value: 'Total number of documents containing a value for this path')lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/LongRangeOnRangeFacetCounts.java:63 ('query to quickly discard hits using some heuristic')lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:73, :106 (testBasicLong: 101 collected documents, value=22 = documents in at least one range)lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java:214-231 (DoubleRangeFacetCounts with a bounding-box fastMatchQuery)
- The finding in 30 seconds
lucene/facet/src/java/org/apache/lucene/facet/range/RangeFacetCounts.java:49-50, :95-106, :126, :132-149, :168, :180, :190-191lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java:74-80, :140-166, :199, :211lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java:167-193, :226, :238lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/RangeOnRangeFacetCounts.java:45-46, :95-123lucene/facet/src/java/org/apache/lucene/facet/FacetCountsWithFilterQuery.java:41-44, :59-90lucene/facet/src/java/org/apache/lucene/facet/FacetResult.java:30-34; lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeCounter.java:163-167lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:73-107 (testBasicLong, value=22)lucene/core/src/java/org/apache/lucene/search/PointRangeQuery.java:252-275; lucene/core/src/java/org/apache/lucene/document/SortedNumericDocValuesRangeQuery.java:124-132, :182-186 (when the fastMatchQuery has no scorer)
- Who is affected
lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java:215-231lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java:39, :57-61
- Why it looks unintended
lucene/facet/src/java/org/apache/lucene/facet/FacetResult.java:30-34; lucene/facet/src/java/org/apache/lucene/facet/FacetCountsWithFilterQuery.java:41-44lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java:74-80; lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/LongRangeOnRangeFacetCounts.java:27-31, :63lucene/facet/src/java/org/apache/lucene/facet/range/RangeFacetCounts.java:49; lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/RangeOnRangeFacetCounts.java:45lucene/CHANGES.txt:12787-12792 (LUCENE-5418)lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:73-107, :805-893, :1022-1060, :1185-1240, :1365-1405, :1671-1700
- Suggested fix
lucene/facet/src/java/org/apache/lucene/facet/RandomSamplingFacetsCollector.java:118-119, :209, :245-250
- TestLongRangeFacetFastMatchTotal.java (JUnit test):
repro/LFV-151/src/org/apache/lucene/facet/range/TestLongRangeFacetFastMatchTotal.java(61 lines)
A light summary card of the repro, made in parallel with this doc from executed runs on main a9779ef3c193 and the code.
assets/LFV-151/bug.png(513 KB)
