All findings · Lucene finding · LFV-432 · sampling-leftover-index-equal-counter-drops-bin-sample

RandomSamplingFacetsCollector never samples the first hit of a segment that starts mid-bin

RandomSamplingFacetsCollector keeps one random hit from each run of hits, but when the chosen hit is the next segment's first hit, that run gets no sample. 4 hits in segments [1 | 3], sample size 2: 1 sampled hit instead of 2; one > to >= fixes it.

Summary

Priority
Low, rank 741With opt-in facet sampling, a bin that crosses a segment boundary loses its sample, a bounded bias that is negligible at the scale the feature targets.
Impact
minor
Since
LUCENE-5476 (4.8.0, 2014), when the class was addedintroduced 2014-03-20 · over 12 years
Affected
4.8.0 to 10.6.0 and main
Status
documented
Found by
non-formal: code reading, then a bounded-exhaustive check on the stock jar

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

Sampling keeps one hit per bin. A bin that a segment boundary cuts in two is meant to continue in the next segment; when its chosen hit is that segment's first hit, the carry-over is not recorded and the bin loses its sample.

Background

  1. Facet sampling. RandomSamplingFacetsCollector (lucene/facet, opt-in) counts facets on a sample of the hits when a query has more than sampleSize hits. amortizeFacetCounts divides the sampled counts by the sampling rate, sampleSize / totalHits, to estimate the full counts.
  2. One hit per bin. It walks the hits in doc order across segments, cuts them into bins of binSize = totalHits / sampleSize (rounded down) hits, and keeps the hit at a random offset in each bin.
  3. Carry-over. Each segment is sampled on its own. When a segment ends inside a bin, two fields carry the rest of the bin into the next segment: how many of its hits are still to come (leftoverBin) and the offset of the hit to sample there (leftoverIndex, or -1 if the bin was already sampled).
Diagram of the bug: 4 hits in two segments, 1 hit then 3 hits, cut into two bins of 2 hits, {hit 0, hit 1} and {hit 2, hit 3}. The sampler picks hit 1 for the first bin, which is the first hit of segment 2. At the end of segment 1 the code handles only randomIndex > counter and randomIndex < counter; here both are 1, so the choice is not carried over, the first bin gets no sample, and only 1 hit is sampled instead of 2. With >= the carried offset is 0 and both bins are sampled.
The repro: 4 hits in segments [1 | 3], bins {0,1} and {2,3}. Seed 1 picks hit 1, the first hit of segment 2; the carry-over is not recorded, so bin {0,1} gets no sample and 1 hit is sampled instead of 2. With >= both bins are sampled. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 4 documents in two segments, 1 then 3 (NoMergePolicy, a commit after the first). Run a match-all query through RandomSamplingFacetsCollector.createManager(2, 1): sample size 2, sampler seed 1.
  2. 4 hits and sample size 2 give a sampling rate of 0.5. The collector cuts the hits, in order across segments, into runs ("bins") of 2, {hit 0, hit 1} and {hit 2, hit 3}, and keeps one random hit from each. You expect 2 sampled hits.
  3. Seed 1 picks the second hit of the first bin, hit 1, which is the first hit of segment 2. You get 1 sampled hit: the first bin contributes nothing, and for a label on all 4 documents amortizeFacetCounts reports 2 instead of 4.
  4. Why: when a segment ends inside a bin, the code carries the choice over only if it lies beyond the hits seen (randomIndex > counter), or marks the bin done if already sampled (<). The equal case, the next segment's first hit, matches neither branch, so that hit is never sampled.
  5. Nothing fails. On average the sample shrinks by at most about (segment boundaries) / (hits): a quarter here, where half the seeds lose half the sample, and about 0.05% at 19,940 hits over 20 segments. Changing > to >= fixes it.

Who is affected

Applications that opt in to facet sampling with RandomSamplingFacetsCollector, on multi-segment indexes, for queries with at least twice sampleSize hits. Each segment boundary inside a bin loses that bin's sample with probability about 1/binSize, so counts are on average low by at most about (boundaries) / (hits): large for few hits over many segments, about 0.05% at 19,940 hits.

Mechanism, terms and numbers781 words

RandomSamplingFacetsCollector (lucene/facet, opt-in) estimates facet counts from a sample: when a query has more than sampleSize hits, it cuts the hits, in doc order across segments, into bins of binSize = (int) (1.0 / samplingRate) hits and keeps the hit at a random offset in each bin; amortizeFacetCounts divides the sampled counts by samplingRate = sampleSize / totalHits. A bin that a segment boundary cuts in two is carried into the next segment through leftoverBin and leftoverIndex. At the end of a segment createSample handles only randomIndex > counter and randomIndex < counter; when the chosen hit is the first hit of the next segment (randomIndex == counter) neither branch runs, so the carried bin gets no sample, or one at a stale offset. The first hit of a segment that starts mid-bin is therefore never sampled, the sample is smaller than getSamplingRate() says, and amortized counts come out low, silently, in every release since 4.8.0. On average the sample shrinks by at most about (segment boundaries) / (hits): for 4 hits in segments [1 | 3] half the seeds sample 1 hit instead of 2, at 19,940 hits over 20 segments the loss is about 0.05%. The fix is > to >=.

The code (main 645d4fe14a20)

  1. Sampling starts in getMatchingDocs() when totalHits > sampleSize (RandomSamplingFacetsCollector.java:132-134): samplingRate = (1.0 * sampleSize) / totalHits (:137); createSample then runs once per segment, in order (:149-155), with binSize = (int) (1.0 / samplingRate) (:164).
  2. A segment's first bin (:169-177): if a bin was carried over, limit = leftoverBin and randomIndex = leftoverIndex ("either NOT_CALCULATED, which means we already sampled from that bin, or the next document to sample"); otherwise limit = binSize and randomIndex = random.nextInt(binSize).
  3. The loop (:179-189): the hit at counter == randomIndex is sampled (:180-181, the only sampleDocs.set); when counter reaches limit a new bin starts with a fresh randomIndex.
  4. End of segment (:191-207): counter == 0 clears the carry-over (:196): the segment ended on a bin edge, or had no hits. A segment with no hits still gets its own MatchingDocs, so it ends an outstanding carry-over (the loss the comment at :191-195 accepts) and the next segment starts a fresh bin, not mid-bin. Otherwise leftoverBin = limit - counter (:198), then if (randomIndex > counter) (:199, "the document to sample is in the next bin") sets leftoverIndex = randomIndex - counter (:201), and else if (randomIndex < counter) (:202) sets NOT_CALCULATED (:205). With randomIndex == counter the hit to sample is offset 0 of the next segment, but neither branch runs.
  5. What is left in leftoverIndex. Usually -1 (its initial value, :83, or cleared at :196 / :205): the carried bin starts with randomIndex = -1 and gets no sample. If an earlier carry left an offset k >= 1 that nothing cleared, the carried bin samples offset k instead of 0, or nothing when k is past the bin's end (follows from the code; not isolated in a test). A bin is never sampled twice.
  6. Consequence. leftoverIndex is only ever -1 or >= 1 (:83, :196, :201, :205), and a segment that starts mid-bin starts its loop with counter = 0, so its first hit is never sampled.

The numbers

In the repro, XORShift64Random(1).randomLong() is 0x2200011 (odd), so the first nextInt(2) is 1 (:55-71). Segment 1 (1 hit): limit 2, randomIndex 1; counter ends at 1, equal to randomIndex, so leftoverBin = 1 and leftoverIndex stays -1. Segment 2 (3 hits): the carried bin has limit 1 and randomIndex -1, so hit 1 is skipped; hits 2 and 3 form a fresh bin and one of them is sampled. Total 1; with >= it is 2.

A boundary inside a bin hits the equal case with probability about 1/binSize, so a query with B such boundaries loses up to about B/binSize of its about totalHits/binSize sampled hits: at most about B/totalHits. Executed: a bounded-exhaustive check on the stock jar (3-12 hits, sampleSize 2-3, every split into 1-3 segments, 50 seeds) left 8,429 of 76,250 complete bins without a sample and none with two, 0 with the fix. At 20 segments of 997 hits (sampleSize 200, binSize 99, 3,000 seeds) the first hit of a segment that starts mid-bin was sampled 0 of 57,000 times, 577 with the fix (about 576 expected); the mean sample was 201.285 against 201.393.

Exposure and likelihood232 words
  • Who. Only applications that opt in to facet sampling with RandomSamplingFacetsCollector (its constructors, or createManager(sampleSize, seed) with IndexSearcher.search). A read-only code search (2026-10-26) found no use of the class in Elasticsearch, OpenSearch or Solr.
  • When. A multi-segment index and a query with totalHits >= 2 * sampleSize, so that binSize >= 2; with binSize 1 every hit is sampled and no bin crosses a boundary. Each segment boundary that falls inside a bin then triggers it with probability about 1/binSize, as long as the next segment has hits (a segment with no hits drops the carried bin anyway). Deterministic for a given seed and segment layout, with assertions on and off.
  • What the user sees. Silently smaller samples than getSamplingRate() implies and amortized counts biased low. The first hit of every segment that starts mid-bin has sampling probability exactly 0: a systematic bias, not sampling noise.
  • How much. Large only for few hits over many segments: in the repro half the seeds sample 1 hit instead of 2 and the mean amortized count is 3.0 instead of 4; in small filtered multi-segment probe indexes 66 of 2,400 searches got an empty sample and so no facet result, 0 with the fix. Negligible at the scale the feature targets (CHANGES.txt: "in cases where there are millions of hits"): about 0.05% of the sample at 19,940 hits over 20 segments.

Minimal repro

One JUnit test on the public API: 4 empty documents in segments [1 | 3], a match-all query, createManager(2, 1). It counts the sampled hits and expects 2, one per bin. Deterministic: the sampler seed is fixed in the test, and the test seed does not change the outcome.

TestRandomSamplingSegmentBoundary.java (one 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;

import org.apache.lucene.document.Document;
import org.apache.lucene.facet.FacetsCollector.MatchingDocs;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.NoMergePolicy;
import org.apache.lucene.search.DocIdSetIterator;
import org.apache.lucene.search.IndexSearcher;
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 TestRandomSamplingSegmentBoundary extends LuceneTestCase {

  public void testBinCrossingSegmentBoundaryIsSampled() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w =
          new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) {
        w.addDocument(new Document());
        w.commit();
        for (int i = 0; i < 3; i++) {
          w.addDocument(new Document());
        }
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        RandomSamplingFacetsCollector c =
            new IndexSearcher(r)
                .search(
                    MatchAllDocsQuery.INSTANCE, RandomSamplingFacetsCollector.createManager(2, 1));
        int sampled = 0;
        for (MatchingDocs md : c.getMatchingDocs()) {
          DocIdSetIterator it = md.bits().iterator();
          while (it.nextDoc() != DocIdSetIterator.NO_MORE_DOCS) {
            sampled++;
          }
        }
        // bins {0,1} {2,3}; seed 1 picks hit 1, the first of segment 2: expected 2, actual 1
        assertEquals("sampled hits", 2, sampled);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestRandomSamplingSegmentBoundary -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
2 sampled hits, one from each bin, {hit 0, hit 1} and {hit 2, hit 3}.
Observed
1 sampled hit: AssertionError: sampled hits expected:<2> but was:<1>, with assertions on and off.

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

More files, environment, full output314 words

ByteBuffersDirectory, NoMergePolicy and one commit give exactly the segments [1 | 3]; a plain IndexSearcher without an executor runs one slice on the calling thread. Segments [1 | 3] put a boundary inside bin {0,1}; sampleSize 2 on 4 hits gives binSize 2 exactly, so the binSize truncation of LFV-437 plays no part; sampler seed 1 makes the first draw 1, the equal case (any small odd seed does the same). Bin {2,3} lies wholly in segment 2 and is always sampled, so one total count is enough. The test sits in package org.apache.lucene.facet only to live next to TestRandomSamplingFacetsCollector; it uses nothing package-private.

It compiles against 10.4.0 and later: MatchAllDocsQuery.INSTANCE first appears in 10.4.0 (#15499), and the unchanged test does not compile against the 10.3.2 jars. It fails on main and on the 10.6.0 and 10.5.2 jars. For 10.0-10.3 use new MatchAllDocsQuery(): that one-token variant fails the same way on 10.0.0. It does not compile against 9.x (MatchingDocs fields, not record accessors), where the hunt's per-era variants show the same failure.

Regression test: this same test; it passes with the recommended fix and fits as one more method of TestRandomSamplingFacetsCollector.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Releases 10.6.0 and 10.5.2: fail
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is integer logic in one method)
Command (environment)
cp $REPRO/TestRandomSamplingSegmentBoundary.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestRandomSamplingSegmentBoundary -Ptests.seed=DEADBEEF -Ptests.asserts=true
# the same with -Ptests.asserts=false (without the flag, seed DEADBEEF also runs with assertions off)

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=false (stack trimmed; the same with assertions on)
TestRandomSamplingSegmentBoundary > testBinCrossingSegmentBoundaryIsSampled FAILED
    java.lang.AssertionError: sampled hits expected:<2> but was:<1>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:C327B571467E623A]:0)
        at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:647)
        at org.apache.lucene.facet.TestRandomSamplingSegmentBoundary.testBinCrossingSegmentBoundaryIsSampled(TestRandomSamplingSegmentBoundary.java:57)

# with '>' changed to '>=' at RandomSamplingFacetsCollector.java:199
# result: BUILD SUCCESSFUL, 1 test, 0 failures, with assertions on and off

Why it looks unintended

  • The carry-over exists for this case: its comment reads "the document to sample is in the next bin".
  • The javadoc promises "a sample ratio of sampling size / totalN", which amortizeFacetCounts divides by; the real ratio is lower.
  • The only loss the code accepts is after a segment with no hits; here the segment has hits.
The full argument236 words
  • The comments. The branch that misses the case reads "the document to sample is in the next bin" (RandomSamplingFacetsCollector.java:200); the read-back says leftoverIndex is "either NOT_CALCULATED, which means we already sampled from that bin, or the next document to sample" (:171-172). In the equal case neither holds: the bin was not sampled.
  • The contract. The sampleSize javadoc: "sampling will be done using a sample ratio of sampling size / totalN" (:97-99); getSamplingRate() "Returns the sampling rate that was used" (:256-259), and amortizeFacetCounts divides by it (:240, :249).
  • Design history (#6539, LUCENE-5476). An early patch's notes (Rob Audenaerde) listed that in a small segment "there might be a few hits that are never touched", and Michael McCandless replied "I think these limitations are acceptable?". Shai Erera then noted that createSample "doesn't carry over the bin residue to the next segment ... it might affect the total sample size", and their last patch revision before the commit "Fixed a bug in how carryOver was implemented – replaced by two members leftoverBin and leftoverIndex", asking for "another look, to make sure this time leftover works well". The equal case was never discussed.
  • Tests. The only upstream test, TestRandomSamplingFacetsCollector, checks a chi-square bound on per-segment sample proportions and amortized counts within 1.0 of the expected value. Both tolerate this loss; it passes with and without the fix.
The devil's-advocate check208 words

The strongest case for "intended" (rated moderate by the cross-model reviews): sampleSize is only "The preferred sample size" (:97); getMatchingDocs warns that per-segment MatchingDocs "might be empty (not containing any hits) as result of sampling" (:115-116); the comment at :191-195 accepts dropping the carry-over after a segment with no hits ("complicates the code and doesn't seem so important"); and the 2014 thread called small-segment losses acceptable.

Why it fails: that accepted loss is scoped to the counter == 0 branch, whose comment covers a segment that ends exactly on a bin edge (nothing is lost) and a segment with no hits (the only case that drops an outstanding carry-over); here the segment has hits and the else branch computes the carry-over with the wrong comparison. The "acceptable" remark concerned an early patch with no carry-over, which was then added to fix it. The bug is not limited to tiny segments: any boundary inside a bin hits it with probability 1/binSize. Giving a reachable hit sampling probability 0, or reusing a stale offset from an earlier bin, is not a trade-off, and the fix costs nothing. Verdict: not intended, a defect (the independent reproduction, the cross-model review, the intended-behaviour check and the adjudication).

Suggested fix

Recommended Change > to >= at RandomSamplingFacetsCollector.java:199 (validated)

Change if (randomIndex > counter) to >= at RandomSamplingFacetsCollector.java:199, so the equal case carries offset 0 into the next segment. The repro then passes with assertions on and off, and so do TestRandomSamplingFacetsCollector (10 runs) and the whole facet module (307 tests).

       } else {
         leftoverBin = limit - counter;
-        if (randomIndex > counter) {
+        if (randomIndex >= counter) {
           // the document to sample is in the next bin
           leftoverIndex = randomIndex - counter;
         } else if (randomIndex < counter) {
All options and trade-offs3 options · 535 words

Option A was built and run through gradle. B is the same behaviour written as one assignment, so that no stale value can survive; C (document the loss) is not recommended. Workarounds that need no fix: exact counting with a plain FacetsCollector when the hit count is small relative to the number of segments, or a single-segment index.

Change > to >= at RandomSamplingFacetsCollector.java:199 (validated) (recommended)

In the equal case leftoverIndex becomes 0, so the next segment samples its first hit. The comment "the document to sample is in the next bin" then covers the equal case too ("next segment" would be clearer). One file, +1/-1.

       } else {
         leftoverBin = limit - counter;
-        if (randomIndex > counter) {
+        if (randomIndex >= counter) {
           // the document to sample is in the next bin
           leftoverIndex = randomIndex - counter;
         } else if (randomIndex < counter) {

Trade-offs

  • Cost. None: one comparison per segment.
  • Behaviour change. For a fixed seed, which hits are sampled changes whenever the equal case occurs: the bin that lost its sample gets one again, so the sample and amortized counts rise. Tests or applications that pin a seed and expect exact sampled counts would see different numbers. Worth a CHANGES.txt entry under Bug Fixes.
  • Scope. A closes only the equal case. The binSize truncation (LFV-437, so the sample can still exceed sampleSize: mean 201.393 for sampleSize 200 in the scale check with the fix), the documented drop of a carried bin after a segment with no hits (:191-196) and the multi-slice reduce (LFV-765) are unchanged.
  • Neighbours. Open PR #16847 edits createSample (maxDoc() to maxLeafDoc()), so a fix may need a rebase; LFV-437, LFV-425 and LFV-765 in the same collector have their own fix sites.

What the checks validated

Applied to a scratch worktree of 645d4fe14a20 and run through gradle with OpenJDK 25.0.3, seed DEADBEEF (2026-10-31):

  • stock: the minimal test fails with assertions on and off;
  • with the fix: the minimal test passes with assertions on and off;
  • the closest suite TestRandomSamplingFacetsCollector passes with -Ptests.iters=10 (10 runs, 0 failures);
  • the whole :lucene:facet:test, the new test included, passes (40 classes, 307 tests, 0 failures, 7 skipped);
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest for facet pass.
  • Earlier, the same change made the finder's test, the independent reproduction's 5 tests (0 of 58,240 bins empty), the bounded-exhaustive model (0 of 76,250), the upstream sampling test (5 iterations), the 4.8.0 history run and the security probe (first hits of mid-bin segments sampled 1,665 of 5,200 times instead of 0) come out right.

Compute leftoverIndex in one assignment (equivalent cleanup, not run)

leftoverIndex = randomIndex >= counter ? randomIndex - counter : NOT_CALCULATED; after leftoverBin = limit - counter;.

Trade-offs

Same behaviour as A by reading (A's else if (randomIndex < counter) is always true when reached), and it shows that leftoverIndex is always assigned. A slightly larger diff.

Document the loss (not recommended)

Say in the javadoc that a bin crossing a segment boundary may lose its sample.

Trade-offs

Keeps a reachable hit at sampling probability 0 and a sampling rate that overstates the real one, for no saving: the fix is one character.

Downstream note

An application that shows sampled facet counts gets slightly low numbers (no facet result for some very small hit sets); every sampled hit is still one of the query's own live, filtered hits.

How it was found

A hunt unit read createSample while inventorying the facet module's counts: the > / < pair leaves the equal case unhandled. A bounded-exhaustive check on the stock jar (3-12 hits, every split into 1-3 segments, 50 seeds) found 8,429 of 76,250 complete bins without a sample, 0 with the fix; a public-API JUnit confirmed it.

The method in full127 words

Method: non-formal (the hunt's tier for findings from code reading and directed testing).

  • Code reading. While building the count and statistic inventory of lucene/facet, the finder noticed that the end-of-segment block of createSample tests only randomIndex > counter and randomIndex < counter.
  • Bounded-exhaustive check. A driver over the stock jar checked the invariant the code comments state, one sampled hit per complete bin in doc order across segments, for sampleSize 2-3, 3-12 hits, every split into 1-3 segments and 50 sampler seeds: 8,429 of 76,250 complete bins empty, none doubly sampled; 0 empty with the one-character fix.
  • JUnit. A public-API test (4 documents in [1 | 3], sampler seeds 1..20) failed on main, 10.5.2 and 9.12.3; a 2026-10-29 minimization cut it to the one-seed test above.

Prior reports, history and checks

Prior reports

No prior report found: 117 logged searches and checks on 2026-10-26 (GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream projects, the web) and a GitHub re-check on 2026-10-31. Closest: the 2014 design thread #6539, which added the carry-over and never discusses this case.

Searches3 searches
  • #6539 (LUCENE-5476, the design thread that added the carry-over; the equal case is never mentioned); a 2014 dev@ CI-failure thread on testRandomSampling, diagnosed by the committer as a test of the wrong statistic ("it was just wrong and tested the wrong thing") and fixed in the test (5b00b2e9814); open PR #16847 (long doc ids), the only open PR touching the file, which changes createSample's maxDoc() and the amortize arithmetic but not the comparison; #11520 / #765 (LUCENE-10484, createManager), #13542 (intra-segment concurrency).
  • Same collector, the hunt's own findings (different root causes and fix sites): LFV-437 (binSize truncation inflates amortized counts; registered as known through the #6539 review), LFV-425 (docFreq cap in amortizeFacetCounts), LFV-765 (the multi-slice reduce samples twice).
  • Limits: keyword search only; one web query was rate-limited; two manual GitHub phrasings hit the search quota (the tool runs and the intended-behaviour check cover the same terms); GitHub Discussions were not searched; private lists and reports are not visible.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr, Lucene.NET)2026-10-26the scripted prior-report search in two runs (32 issue and PR queries, 6 commit queries, 4 downstream queries) and manual full-text and comment searches (RandomSamplingFacetsCollector, randomIndex counter sampling, sampling carryOver facet, systematic sampling), plus the changed-file lists of all 411 open apache/lucene PRs12 hits read, none the same bug; only #16847 among open PRs touches the file
Mailing lists (dev@, java-user@, issues@, commits@, builds@), JIRA archive, git history, web2026-10-2645 list queries with the user and CI-failure threads read in full, 8 JIRA-archive queries, git log -S on the comparison and on leftoverIndex, 9 web queries (1 rate-limited)no report; the comparison has never changed since the class was added
GitHub issues and PRs (apache/lucene)2026-10-31items updated since 2026-10-20 for RandomSamplingFacetsCollector, leftoverIndex, facet sampling, amortizeFacetCounts; all-time search for RandomSamplingFacetsCollector0 recent results; the 6 all-time items (#6539, #765, #11520, #240, #11041, #9415) are not this bug

History

Present since the class was added: LUCENE-5476 (e50b392f467, 2014-03-20), first released in 4.8.0; the comparison has never changed. Fails on 12 releases from 4.8.0 to 10.5.2, on 10.6.0 and on main; unchanged on upstream main, branch_11x and branch_10_6.

Versions tested6 rows

Introduced by e50b392f467cf1e3dea3999b5449c48742cf2084, "LUCENE-5476: add RandomSamplingFacetsCollector" (Shai Erera, 2014-03-20; a JIRA patch from the svn era, no PR; issue mirrored as #6539); the 4.x copy is the same-day backport 4d26f04cedc. git blame of lines 199-202 at 645d4fe14a20 gives e50b392f467 for every line, and git log -S / -G on the comparison find no later change. Not a regression: the defect is in the first committed version of the carry-over. 4.7.2 does not have the class.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (gradle and standalone runner); passes with the fix
10.6.0, 10.5.2 affectedminimal test fails (release jars, assertions off)
4.8.0, 4.9.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, main ea57360912ed affectedhistory driver: 10 of 40 complete bins empty, 30 sampled hits instead of 40, identical on every version
4.8.0 and main ea57360912ed with the fix fixhistory driver: 0 of 40 bins empty
4.7.2 no such classthe repro does not compile: RandomSamplingFacetsCollector does not exist
upstream main 2f424fd428d, branch_11x 603af870375, branch_10_6 7e5aec4370e same codethe same > / < pair at lines 199 and 202 (read, not run)

Independent checks

Confirmed by an independent reproducer, a devil's-advocate verifier, a reviewer from another model family (OpenAI GPT-5.6) and an adjudication, then re-run by a credibility audit, the minimization and this doc's gradle run. The only dissent was a procedural "not real" from a read-only review that could not run code.

Checks and verdicts366 words

Every check that ran code reproduced the failure on main and saw it pass with the one-character fix. The cross-model reviews could only read code; a separate run ran the repro for them.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (code reading, bounded-exhaustive check, JUnit) candidate8,429 of 76,250 complete bins empty on the stock jar, 0 with the fix; the JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; the upstream sampling test passes with the fix.
Refute review (OpenAI GPT-5.6, read-only) not real (procedural, low)Could not run code; agrees on the root cause and that the inputs are in contract; rates the intended-behaviour argument moderate. A separate run re-ran the repro: fails 1/1.
Independent reproducer realOwn 5-test class: 4 of 5 fail on main, 10.5.2 and 9.12.3; [1 | 3] and [3 | 1] leave a bin empty for 32 of 64 seeds; amortized mean 3.0 instead of 4; controls pass; its fix passes 5/5.
Intended-behaviour lens (OpenAI GPT-5.6, read-only) real (medium)The intended argument is moderate and fails: the accepted loss covers only a segment with no hits. Re-runs fail on main and pass with the fix.
Intended-behaviour devil's advocate real, minorNo intended, documented, misuse or unreachability argument survives; scale check 0 of 57,000 against 577 of 57,000 segment-first-hit inclusions.
Adjudication confirmed (high), class minorRe-ran the independent reproduction's tests (4 of 5 fail with -ea and -da, 0 with the fix) and the finder's repro (fails 1/1).
Follow-up history and security probe affected since 4.8.0; not security-sensitive12 releases and main fail with identical counts; 2,400 filtered searches per run: every sampled hit an original, live, filtered hit.
Credibility audit (4th check) keep, minorRe-ran the finder's test and the independent reproduction's tests on main; the fix passes.
Minimal repro and this doc verifiedThe one-method test fails on 645d4fe14a20 (2 seeds, assertions on and off) and passes with the fix; this doc re-ran it through gradle in a scratch worktree (the fix also passes the closest suite, the facet module and lint) and on the 10.6.0 and 10.5.2 jars (fail).

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,537 words

Finding record

LFV-432, snapshot of 2026-11-06

Registry title
RandomSamplingFacetsCollector never samples the first hit of a segment that starts mid-bin: createSample does not handle randomIndex == counter at the end of a segment, so a sampling bin that crosses a segment boundary loses its sample (or samples at a stale offset)
Area
facet / sampling (RandomSamplingFacetsCollector.createSample, cross-segment bin carry-over)
Priority
Low, rank 741: With opt-in facet sampling, a bin that crosses a segment boundary loses its sample, a bounded bias that is negligible at the scale the feature targets.
Impact class
minor
Impact note
Only applications that opt in to facet sampling with RandomSamplingFacetsCollector are hit, on any multi-segment index once a query has about twice sampleSize hits or more (binSize >= 2): each segment boundary that falls inside a bin loses that bin's sample with probability about 1/binSize, so the sample is smaller than getSamplingRate() implies and amortized counts come out low by at most about (#segment boundaries)/totalHits; that is large only for few hits over many segments (4 hits in segments [1 | 3], sampleSize 2: half the seeds sample 1 hit instead of 2, mean amortized count 3.0 instead of 4; in small filtered probe indexes 66 of 2,400 searches got an empty sample and no facet result, 0 with the fix) and negligible at the scale the feature targets (about 0.05% of the sample at 19,940 hits over 20 segments); deterministic, -ea and -da, every release since 4.8.0.
Summary
You count facets with RandomSamplingFacetsCollector on a multi-segment index for a query with many more hits than sampleSize, and expect systematic sampling: one random hit out of every run of binSize consecutive hits, with a run that is cut by a segment boundary continued in the next segment, so every hit has about a 1/binSize chance to be sampled. Instead, when the hit picked for such a run is the first hit of the next segment, createSample leaves its carry-over state unset (it tests only randomIndex > counter and randomIndex < counter), so that run contributes no sample, or one at a stale offset, and the first hit of a segment that starts mid-bin is never sampled. Nothing fails; the sample and the amortized counts are just smaller than the reported sampling rate implies (4 hits in segments [1 | 3], sampleSize 2: half the seeds sample 1 hit instead of 2).
Introduced
e50b392f467cf1e3dea3999b5449c48742cf2084, 'LUCENE-5476: add RandomSamplingFacetsCollector' (trunk, svn r1579594; no PR, a JIRA patch; issue mirrored as apache/lucene#6539; committer shaie), shipped in 4.x through the same-day branch_4x backport 4d26f04cedcdc487b8c8541bc23ff24df6b71f6a (svn r1579598) [read: git blame of RandomSamplingFacetsCollector.java:191-207 on main, and git log -S'randomIndex > counter' / -G'randomIndex [<>]=? ?counter' find no later change of the comparison; exec: 4.7.2 lacks the class (compile error), 4.8.0 fails]. Not a regression: the defect is in the first committed version of the carry-over (2014-03-20)
First affected version
4.8.0 [exec: fails with -ea and -da, 10 of 40 complete bins empty; 4.8.0 with the one-character fix passes]; 4.7.2 does not have RandomSamplingFacetsCollector [exec: compile error; the pre-4.7 org.apache.lucene.facet.sampling package was different code, removed before 4.7.0]
Affected versions
4.8.0 through 10.5.2 (the last release before 10.6.0) and main ea57360912ed; not fixed upstream (upstream main and branch_10x still read 'randomIndex > counter' at line 199; the last commit to the file is fafd6af004e, 2024-09-10, already in the tested tree; read 2026-10-26). [exec] The history driver (4 documents, segments [1 | 3], sampleSize 2, sampler seeds 1..20, one small API adapter per era) fails with identical counts (10 of 40 complete bins empty, 0 doubly sampled, 30 of 40 intended sampled hits) on 4.8.0, 4.9.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-da also on 4.8.0, 9.12.3 and main), with both IndexSearcher.search(Query, Collector) (4.x-9.x) and createManager (10.x); the finder's JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 4 of 5 on main (-ea, -da), 10.5.2 and 9.12.3. [read] The unchanged '>' / '<' block is in all 107 release tags from 4.8.0 to 10.5.2 in the history clone (8.9.0-8.11.x are not tagged there; 8.11.4 was executed). Not affected: 4.7.2 and older (no such class). Old releases ran on JDK 25 with their published jars
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), 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 exhaustive check
Upstream and prior reports
none found (2026-10-26, all read-only; the prior-report record verdict new: 117 logged queries and checks, namely 95 from the scripted prior-report search in two runs (32 GitHub issue/PR queries on apache/lucene, 6 commit queries, 4 downstream queries over Elasticsearch, OpenSearch and Solr, 45 queries over the dev@, java-user@, issues@, commits@ and builds@ lists, 8 JIRA-archive queries), 13 manual checks (GitHub full-text and comment searches, a changed-file scan of all 411 open apache/lucene PRs, Lucene.NET and downstream repositories, mailing-list archive listings with the user and CI-failure threads read in full, git history, upstream main) and 9 web queries (1 rate-limited); 12 hits read and judged, none the same bug; the finder's and the intended-behaviour check's own searches found the same items). History, not reports: LUCENE-5476 / #6539 (2014) is the design thread that added the carry-over: an early patch accepted that small segments may leave hits unsampled, then the committer (shaie) added leftoverBin/leftoverIndex in the final revision ('Fixed a bug in how carryOver was implemented') and invited another look 'to make sure this time leftover works well'; the randomIndex == counter case is never mentioned. A 2014 dev@ CI-failure thread on TestRandomSamplingFacetsCollector was diagnosed by the committer as a too-strict test bound and fixed in the test (5b00b2e9814); nobody suspected the sampler. Open #16847 (jimczi, 'Support more than 2^31 documents per index') is the only open PR touching the file: it changes createSample's maxDoc() to maxLeafDoc() and widens the amortize arithmetic, but not the carry-over comparison (its per-file patch read 2026-10-26), so it neither reports nor fixes this bug. Context only: #11520 / #765 (LUCENE-10484, createManager), #13542 (intra-segment concurrency), #4575 (the old, deleted sampling package), #11041, #240, #9415
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and a standalone runner; with the one-character fix it passes, together with TestRandomSamplingFacetsCollector and the whole facet module. Release jars: the test fails on 10.6.0 and 10.5.2. The hunt ran on main ea57360912ed, whose facet module is identical, and its history driver fails with identical counts on 12 releases from 4.8.0 to 10.5.2. Upstream main 2f424fd428d, branch_11x 603af870375, branch_10_6 7e5aec4370e and the 10.6.0 tag carry the same comparison (read, 2026-10-30 refs).

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/facet/src/java/org/apache/lucene/facet/RandomSamplingFacetsCollector.java:74 (NOT_CALCULATED = -1), :82-83 (leftoverBin, leftoverIndex fields), :97-100 (sampleSize javadoc: 'preferred sample size', 'a sample ratio of sampling size / totalN'), :115-116 (per-segment MatchingDocs 'might be empty ... as result of sampling'), :132-138 (samplingRate = sampleSize / totalHits when totalHits > sampleSize), :164 (binSize = (int) (1.0 / samplingRate)), :169-177 (carried bin: limit = leftoverBin, randomIndex = leftoverIndex), :178-189 (sampling loop; the only sampleDocs.set at :181), :191-195 (counter == 0 branch and its comment accepting the loss after a segment with no hits), :196-206 (carry-over: 'if (randomIndex > counter)' at :199, 'else if (randomIndex < counter)' at :202; no equal case), :220-254 (amortizeFacetCounts divides by samplingRate)
  • a Lucene main checkout lucene/facet/src/test/org/apache/lucene/facet/TestRandomSamplingFacetsCollector.java (the only upstream test: a per-segment chi-square bound, which tolerates this loss); a Lucene main checkout lucene/CHANGES.txt:12417 (LUCENE-5476 under 4.8.0)
  • upstream apache/lucene#6539 (LUCENE-5476 design thread; comments by shaie on the carry-over, read with gh 2026-10-26) and apache/lucene#16847 (open, jimczi; per-file patch of RandomSamplingFacetsCollector.java read with gh api 2026-10-26: maxDoc() to maxLeafDoc() and long arithmetic in amortizeFacetCounts only)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/RandomSamplingFacetsCollector.java:55-71, (at 645d4fe14a20) :74, :82-83, :132-139, :149-155, :164, :169-177, :179-189, :191-207
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsCollector.java:117-125 (at 645d4fe14a20) and lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:813-815, :862 (one MatchingDocs per visited segment, also for a segment with no hits)
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/RandomSamplingFacetsCollector.java:132-139, (at 645d4fe14a20) :164, :240, :249, :256-259
  • lucene/CHANGES.txt:12450-12452 (at 645d4fe14a20) (LUCENE-5476, 4.8.0)
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/RandomSamplingFacetsCollector.java:97-100, (at 645d4fe14a20) :115-116, :171-172, :191-206, :240, :249, :256-259
  • lucene/facet/src/test/org/apache/lucene/facet/TestRandomSamplingFacetsCollector.java:115-133 (at 645d4fe14a20) (chi-square), :135-149 (amortized counts)
  • apache/lucene#6539 (LUCENE-5476; comments by Rob Audenaerde 13917870, Michael McCandless 13917922, Shai Erera and the final revision 13940616; read with gh 2026-10-31)
Suggested fix
  • upstream apache/lucene#16847 (open; per-file patch of RandomSamplingFacetsCollector.java read 2026-10-26)
History
  • a Lucene clone refs of 2026-10-30 (origin/main 2f424fd428d, origin/branch_11x 603af870375, origin/branch_10_6 7e5aec4370e, tag releases/lucene/10.6.0 4ae946e5a9b; git show of lucene/facet/src/java/org/apache/lucene/facet/RandomSamplingFacetsCollector.java)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro. The diagram numbers segments from 0, as Lucene numbers its leaves: "seg 0" and "seg 1" are segments 1 and 2 of the text.

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.