All findings · Lucene finding · LFV-151 · range-facet-total-count-ignores-fast-match-query

Range facet totals count the hits that the fastMatchQuery skipped

With a fastMatchQuery, range facet counters add the hits it skipped to the dimension total FacetResult.value: three documents, one in the range, give a total of 3 instead of 1 while the range count stays 1. Every release tested since 4.7.0 does this.

Summary

Priority
Low, rank 324Only 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
silent wrong results
Since
4.7.0 (LUCENE-5418, 2014); range-on-range since 9.5.0introduced 2014-02-06 · over 12 years
Affected
4.7.0 to 10.5.2 and main
Status
documented
Found by
code reading, confirmed by JUnit; re-found by an SMT model

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Summary card. Title: Range facet totals count the hits that the fastMatchQuery skipped. Steps: 1, three documents with prices 0, 10 and 20 in one segment, MatchAllDocsQuery, one range 0-5; 2, LongRangeFacetCounts without a fastMatchQuery: every hit is checked, +3 hits, -2 in no range, value 1; 3, with fastMatch = LongPoint.newRangeQuery(price, 0, 5): only doc 0 is checked but line 150 still adds 3, value 3; 4, the same documents with docs 1 and 2 in a second segment: the fastMatchQuery has no scorer there, the segment is skipped before the add, value 1. Bottom left: totCount += hits.totalHits() gives +3, checked doc 0 is in the range, totCount -= missingCount gives -0, value 3 but the range count is 1. Right: expected value 1, as without the fastMatchQuery; actual 3 in one segment and 1 in two; the FacetResult.value contract; the same add in the four single-valued loops; per-range counts, childCount, hits and drill-down are right; affected 4.7.0 to 10.5.2 and main, range-on-range from 9.5.0.
Top: the repro in four steps (prices 0, 10 and 20, one range [0, 5], without and with a 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.
  1. 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 total FacetResult.value are both 1.
  2. Pass a fastMatchQuery that 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.
  3. You get a range count of 1 but a total of 3, with LongRangeFacetCounts, DoubleRangeFacetCounts and both range-on-range counters, default index settings and no error.
  4. 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 fastMatchQuery skipped are added and never subtracted.
  5. 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.

Who is affected

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.

Terms

  • Range facets: LongRangeFacetCounts and DoubleRangeFacetCounts count, per range, the collected hits whose value (from field doc values or a LongValuesSource / DoubleValuesSource) falls in it; LongRangeOnRangeFacetCounts and DoubleRangeOnRangeFacetCounts do 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: upstream testBasicLong collects 101 documents and asserts value=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 bug

  1. The single-valued loops skip a segment whose iterator is null, then add the whole segment's hit count: totCount += hits.totalHits(); (RangeFacetCounts.java:140 for field doc values, LongRangeFacetCounts.java:150, DoubleRangeFacetCounts.java:177, RangeOnRangeFacetCounts.java:100).
  2. 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).
  3. A hit the fastMatchQuery rejects is never visited, so it is added and never subtracted. The total becomes "documents in some range" plus "rejected hits of segments where the fastMatchQuery has a scorer".
  4. A segment where the fastMatchQuery has 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.
  5. 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).

The numbers

Values 0, 10 and 20; MatchAllDocsQuery; one range [0, 5]; fastMatchQuery = TermQuery(tag:in), which matches only the document with 0.

counterno fastMatchQuerywith it, one segmentwith it, 0 alone in its own segment
LongRangeFacetCounts, LongValuesSource131
LongRangeFacetCounts, field doc values131
DoubleRangeFacetCounts, DoubleValuesSource131
DoubleRangeFacetCounts, field doc values131
LongRangeOnRangeFacetCounts (INTERSECTS)131
DoubleRangeOnRangeFacetCounts (INTERSECTS)131
LongRangeFacetCounts, MultiLongValuesSource (control)111

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 fastMatchQuery to LongRangeFacetCounts or DoubleRangeFacetCounts (with a LongValuesSource / DoubleValuesSource, or field doc values when no hit segment is truly multi-valued) or to LongRangeOnRangeFacetCounts / DoubleRangeOnRangeFacetCounts, and read FacetResult.value, for example to show "N results within 10 km". Lucene's own DistanceFacetsExample passes a bounding box as the fastMatchQuery (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 fastMatchQuery is optional (the constructors without it pass null). RangeFacetCounts, LongRangeFacetCounts, DoubleRangeFacetCounts and FacetCountsWithFilterQuery are @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 sets NoMergePolicy only to pin the segment layout).
  • Scale. Any index size. The total is too high by the hits the fastMatchQuery rejects 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 a fastMatchQuery over an ordinary FacetsCollector (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.

Minimal repro

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]).

TestLongRangeFacetFastMatchTotal.java (JUnit test)61 lines
/*
 * 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());
      }
    }
  }
}
Command
# 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>).

All files of this repro: repro/LFV-151/

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • FacetResult.value is the "Total number of documents containing a value for this path"; testBasicLong asserts 22 for 101 hits.
  • The fastMatchQuery is 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 provided LongValuesSource is costly per-document, such as a geo distance." LongRangeOnRangeFacetCounts.java:27-31: the class counts "all the documents in the FacetsCollector (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. testBasicLong asserts value=22 for 101 collected documents (TestRangeFacetCounts.java:73-107). The randomized tests that pass a fastMatchQuery assert only per-range counts; testCustomDoubleValuesSource asserts value=3 with a fastMatchQuery that wraps MatchAllDocsQuery and rejects nothing (:1671-1700); the demo test's three documents all lie inside the bounding box. No test asserts the total under a fastMatchQuery that 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 / DoubleValuesSource loops, 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.

Suggested fix

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; // unchanged
All 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.

Count each checked document (validated by the checks) (recommended)

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; // unchanged

Trade-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: TestRangeFacetCounts and TestRangeOnRangeFacetCounts 42 of 42, and the same change patched into 4.7.0 and 9.5.0 fixes the release probes there.

Keep the bulk add and subtract the unchecked hits

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.

Document the current total

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.

Count all collected hits

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.

Downstream note

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.

How it was found

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 RangeOnRangeFacetCounts into the unit's bounded-exhaustive differential check of BinaryRangeDocValues / QueryType.matches. That harness passes no fastMatchQuery, 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 fastMatchQuery that 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 a fastMatchQuery the 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).

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26a 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.NETno report; the items listed above
Open PRs2026-10-26the heads of all 413 open apache/lucene PRs, diffed against the range, range-on-range, FacetCountsWithFilterQuery, FacetResult and FacetsCollector sourcesnone changes the bulk add; #16439 keeps it
Mailing lists and JIRA2026-10-2632 tool queries over dev@, java-user@, issues@ and commits@, 10 direct list searches, 6 JIRA-archive queries and 7 issues readrelated threads only (FacetResult#value semantics, 2021; RangeFacetsCount Question, 2022)
Downstream repositories and web2026-10-262 tool and 2 direct downstream queries; 6 tool and 3 direct web queriesno report

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis 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 affectedthis 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 affectedvalues-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.0values-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 affectedthis 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 affectedLong 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 APIthe 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 affectedthe probes give 1 everywhere; on main the whole lucene/facet suite passes

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-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 realOwn 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 realNo 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 noneA wrong aggregate only, bounded by the main query's live hits (4,800 executed checks); file publicly.
registry re-runs reproducedFollow-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 validatedNew 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.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,221 words

Finding record

LFV-151, snapshot of 2026-11-06

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 main ea57360912ed, 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 whole lucene/facet suite (40 suites, 308 tests) with assertions on and off, and :lucene:facet:check -x test.

Lucene and upstream sources

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-191
  • lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java:74-80, :140-166, :199, :211
  • lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java:167-193, :226, :238
  • lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/RangeOnRangeFacetCounts.java:45-46, :95-123
  • lucene/facet/src/java/org/apache/lucene/facet/FacetCountsWithFilterQuery.java:41-44, :59-90
  • lucene/facet/src/java/org/apache/lucene/facet/FacetResult.java:30-34; lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeCounter.java:163-167
  • lucene/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-231
  • lucene/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-44
  • lucene/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, :63
  • lucene/facet/src/java/org/apache/lucene/facet/range/RangeFacetCounts.java:49; lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/RangeOnRangeFacetCounts.java:45
  • lucene/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

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

A light summary card of the repro, made in parallel with this doc from executed runs on main a9779ef3c193 and the code.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.