All findings · Lucene finding · LFV-1057 · numeric-sort-leaf-start-intersects-points-before-scorer-cost

Numeric sort reads a later segment's points before it knows the query's cost (main and branch_11x since #16857)

Since #16857 (unreleased: main and branch_11x), a numeric-sorted search reads the points of each segment that starts after the 1,000-hit threshold was crossed, even where the query matches one document or none: 1,001 point doc IDs instead of 0 in the repro, roughly 1.5-1.9x slower on a selective 1M-doc segment. Hits stay correct.

Summary

Priority
Low, rank 363A performance-only regression with correct results on the default numeric-sorted search path, but only in unreleased main/branch_11x code and with measured slowdowns of about 10% to 1.9x, so it is Low.
Impact
minor
Since
#16857, 2026-10-29 (unreleased)introduced 2026-10-29
Affected
main and branch_11x (the future 11.0.0); no release: 10.6.0, 10.5.2 and 9.12.3 pass
Status
documented
Found by
reading the #16857 diff, then a probe that counts point reads

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

Each segment decides from the query's cost whether reading the sort field's points is worth it. Since #16857 a later segment makes that decision before the cost is known, using the placeholder -1, so it always reads.

Background: how a numeric sort skips documents

  1. search(query, n, sort) counts hits exactly up to a threshold, 1,000 by default. Past it, Lucene may skip documents that cannot enter the top n.
  2. For a numeric sort field that is also indexed as points, NumericComparator can ask the points index for the documents whose value still beats the current n-th hit, and then visit only those (a "competitive iterator").
  3. Reading points costs work, so it does that only when the result is at least 8x smaller than what the query itself matches. The query's match count (its cost) arrives with setScorer(); until then the comparator stores -1, meaning "not known yet".
A sorted search over two segments. Segment 0 has 1,001 hits and crosses the 1,000-hit threshold; the 10th value is v = 1,009. Segment 1 has 1,001 points and 1 matching document. Before #16857 the segment waits for setScorer(), sees a query cost of 1, and reads 0 point doc IDs. Since #16857 the collector's constructor reports the threshold first, the cost is still -1, the 8x limit becomes 2^61 - 1, and the segment reads all 1,001 point doc IDs. The top 10 is the same in both.
Two segments sorted by v: segment 0 crosses the 1,000-hit threshold; segment 1 then reads 1,001 point doc IDs before its scorer cost is known, where the 8x check would have read none. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index two segments with a LongPoint + NumericDocValuesField field v. Segment 0: 1,001 docs with f:a (v = 1,000 to 2,000). Segment 1: 1,000 docs with f:b (v = 0 to 999) and one f:a doc with v = 1,000,000.
  2. Search f:a, top 10, sorted by v. Segment 0 crosses the default threshold of 1,000 hits, so from now on Lucene may skip documents that cannot beat the 10th value, v = 1,009.
  3. You expect segment 1 to read none of its points: the query matches 1 doc there, and the comparator reads points only when that cuts the documents to check at least 8x. 10.6.0, 10.5.2, 9.12.3 and main before #16857 read 0.
  4. Instead segment 1 reads all 1,001 of its points and builds a set of its 1,000 f:b docs, which the query does not even match. The top 10 is still right; the work is wasted (1,000,001 doc IDs per search for a later segment of 1,000,001 docs).
  5. Why: #16857 tells the comparator "threshold reached" when the segment's collector is created, before setScorer(). The query's cost is still the placeholder -1, so the 8x limit cost >>> 3 becomes 2^61 - 1 and never says no.

Who is affected

Users of main or branch_11x (the future 11.0.0) who sort by a numeric points field (a price, a timestamp) with search(query, n, sort) or searchAfter over several segments, default settings. Each later segment pays a points pass over its competitive range, wasted where the query is selective there. Results are correct; no release has it.

Mechanism, terms and numbers844 words

Since #16857 ("Consolidate Sort pruning logic", merged 2026-10-29, on main and branch_11x, in no release), TopFieldLeafCollector's constructor calls comparator.setHitsThresholdReached() when the hits threshold was already crossed in an earlier segment (TopFieldCollector.java:56-59 at 716d6f216f5). The constructor runs before setScorer(). For a numeric sort field indexed as points, that call runs the segment's first competitive-iterator update at once (NumericComparator.java:154-158), while the points builder's iteratorCost is still -1, its "no scorer yet" placeholder (:315). The 8x selectivity threshold iteratorCost >>> 3 (:426) becomes 2^61 - 1, so the "not selective enough to be worth materializing" check (:430-431) never rejects, and the segment intersects its points over the whole competitive range (:441). iteratorCost then holds the new iterator's cost estimate (1,001 here), so setScorer() skips the normal first update with the query's real cost (:346-355).

In the one-method repro, a later segment where the query matches 1 document hands all 1,001 of its points to the visitor; main before #16857, 10.6.0, 10.5.2 and 9.12.3 read 0. It also happens in a segment where the query matches nothing, because IndexSearcher.searchLeaf creates the leaf collector before it asks for a scorer. The top hits are identical in every executed shape. Rough timings on a shared host: 1.5-1.9x on a selective query with a 1M-doc later segment, about +10% for a 2% filter over 10 x 100,000 docs; a 3-line guard restores both.

Terms

  • Hits threshold: IndexSearcher#search(query, n, sort) counts hits exactly up to TOTAL_HITS_THRESHOLD = 1,000 (IndexSearcher.java:100). The 1,001st hit switches the collector's totalHitsRelation to GREATER_THAN_OR_EQUAL_TO and calls comparator.setHitsThresholdReached() from countHit() (TopFieldCollector.java:64-77).
  • Competitive iterator: once the threshold is reached and the queue is full, NumericComparator's PointsCompetitiveDISIBuilder intersects the field's points with the range of values that can still enter the top n and hands the collector a doc-ID iterator over the result (NumericComparator.java:363-446).
  • The 8x rule: before intersecting, it estimates the number of points in that range and gives up when the estimate is at least iteratorCost >>> 3: "the new range is not selective enough to be worth materializing, it doesn't reduce number of docs at least 8x" (:426-439). iteratorCost starts as the query's cost, taken in setScorer(): "starting iterator cost is the scorer's cost" (:346-355).
  • The placeholder: private long iteratorCost = -1; (:315) means "not set from the scorer yet" (LUCENE-10555, #864, 2022).

What #16857 changed

It added to the TopFieldLeafCollector constructor (TopFieldCollector.java:56-59):

if (totalHitsRelation == TotalHits.Relation.GREATER_THAN_OR_EQUAL_TO) {
  // we only get told when the threshold is crossed, which may have been in a previous segment
  comparator.setHitsThresholdReached();
}

The constructor runs inside getLeafCollector, which IndexSearcher.searchLeaf calls before weight.scorerSupplier (IndexSearcher.java:836, :842), so it runs before setScorer() and even for a segment where the query has no scorer. NumericLeafComparator.setHitsThresholdReached() calls competitiveDISIBuilder.updateCompetitiveIterator() (NumericComparator.java:154-158). The flags it checks, hitsThresholdReached and queueFull, are shared by all segments of the collector (:59-60) and, in the repro, already true, so doUpdateCompetitiveIterator() runs with iteratorCost == -1: the threshold is (-1) >>> 3 = 2^61 - 1, the estimate can never reach it, pointValues.intersect reads the whole competitive range (:441), and iteratorCost becomes the new iterator's cost estimate (:444; 1,001 here). When setScorer() arrives, iteratorCost != -1, so the update with the real cost is skipped (:347).

The call itself is needed. #16857's new IndexSortedLeafFieldComparator keeps its own per-segment hitsThresholdReached flag (IndexSortedLeafFieldComparator.java:35, :91-97). And when the segment that crossed the threshold was index-sorted by the sort field and the search does not page, its comparator is that class (FieldValueHitQueue.java:190-191), which does not pass setHitsThresholdReached() on to its delegate (:91-97), while countHit() makes the call only once per collector (TopFieldCollector.java:70-72): the shared NumericComparator flag stays false, and the constructor call is what turns points skipping on in the next, unsorted segment. An independent check executed that shape: the second segment reads 512 point doc IDs on main, with either fix and on 645d4fe14a20, but 0 with the call deleted (no skipping: 5101+ hits counted instead of 1101+). So the defect is not the call but that, for the points builder, it runs the first update while the cost is still -1. When the earlier segment was not index-sorted, as in the repro, the flag is already set and that early update is the call's only effect on NumericComparator. Before #16857, setHitsThresholdReached() was called only from countHit(), during collect(), after setScorer() (645d4fe14a20 TopFieldCollector.java:87).

In the repro, segment 1's competitive range is v <= 1,008 (the 10th value is 1,009; with a single sort field ties are pruned too, FieldValueHitQueue.java:150). It holds the 1,000 f:b documents, so the intersect visits every one of segment 1's 1,001 points. With the real cost, 1 for f:a there, the threshold is 1 >>> 3 = 0 and nothing is read.

Exposure and likelihood444 words
  • Who. Anyone on main or branch_11x code (so 11.0.0 unless fixed, and downstream projects that build on Lucene snapshots) who calls IndexSearcher#search(query, n, sort) or searchAfter sorted on a numeric field indexed with points plus doc values, over an index with more than one segment. It needs the points builder: the field is the first sort field with pruning on (the default; later sort fields get Pruning.NONE, FieldValueHitQueue.java:146-151) and has points (otherwise the doc-values-skipper builder is used), NumericComparator.java:98-111.
  • Default. Default codec, IndexWriterConfig and IndexSearcher, the default 1,000-hit threshold, assertions on or off. It starts in the later segments (with points for the field) that a collector reaches after an earlier segment crossed the threshold. Without an executor one collector sees every segment (IndexSearcher.java:226-231); with one, each slice has its own collector and hit count (IndexSearcher.java:767-777, TopDocsCollector.java:50-53), so the threshold must have been crossed earlier in the same slice.
  • Cost. One points pass over the segment's competitive range plus one doc-ID set, in cases where the 8x rule would have said no. It is bounded by the segment's points in that range. Measured point doc IDs read in later segments, before #16857 vs after:
shapebefore vs after
the repro: 1,001 points, query matches 10 vs 1,001
a later segment where the query matches nothing0 vs 10,000
a later segment of 1,000,000 non-matching docs plus 1 non-competitive match, page 10 vs 1,000,001 per search, plus about 141 KB more allocated (mostly a 1M-bit doc-ID set)
the same, page 2 via searchAfter0 vs 65
newest-first time series, 4 x 100,000 docs, rare term0 vs 111,424 per later segment
the same, common term (98% of docs) / MatchAllDocsQuery8,352 vs 108,352 / 10,400 vs 110,400
  • Rough wall-clock (shared aarch64 host, JDK 25, medians of 5 rounds; indicative only): a selective query with that 1,000,001-doc later segment, 40-80 us per search before vs 73-125 us after (1.5x to 1.9x over 5 paired runs); a 2% filter over 10 x 100,000 random prices sorted by price, 435-443 us vs 483-485 us (about +10%). In these runs the 3-line fix restored both (40.6 us; 432-438 us). A newest-first time-series shape was inconclusive.
  • Not claimed. Wrong results: the top hits were identical in every executed shape. The TotalHits lower bound can change (for example 2005+ instead of 19774+), because the early iterator prunes more; the GREATER_THAN_OR_EQUAL_TO contract allows that. String sorts and the doc-values-skipper builder may also get an extra update per segment from the same call; its cost was not measured.
  • Workaround. None worth recommending: SortField.withoutPruning avoids the read but turns off all points-based skipping.

Minimal repro

One JUnit 4 test method on the public API: two segments in a ByteBuffersDirectory, NoMergePolicy, a sorted search(query, 10, sort), and a FilterLeafReader around segment 1 that counts the point doc IDs handed to the visitor. No randomness, single thread; it fails with assertions on and off.

TestSortLaterSegmentPointsWork.java (lucene/core, package org.apache.lucene.search, 1 test)184 lines
package org.apache.lucene.search;

import static org.junit.Assert.assertEquals;

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.LongPoint;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.FilterLeafReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReader;
import org.apache.lucene.index.MultiReader;
import org.apache.lucene.index.NoMergePolicy;
import org.apache.lucene.index.PointValues;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.junit.Test;

public class TestSortLaterSegmentPointsWork {

  @Test
  public void testSelectiveLaterSegmentReadsNoPoints() throws IOException {
    Directory dir = new ByteBuffersDirectory();
    IndexWriterConfig iwc = new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE);
    try (IndexWriter w = new IndexWriter(dir, iwc)) {
      for (int i = 0; i <= 1000; i++) {
        addDoc(w, "a", 1000 + i);
      }
      w.flush();
      for (int i = 0; i < 1000; i++) {
        addDoc(w, "b", i);
      }
      addDoc(w, "a", 1_000_000);
    }
    try (DirectoryReader reader = DirectoryReader.open(dir)) {
      long[] pointDocsRead = new long[1];
      LeafReader second = new CountingLeafReader(reader.leaves().get(1).reader(), pointDocsRead);
      IndexSearcher searcher =
          new IndexSearcher(new MultiReader(reader.leaves().get(0).reader(), second));
      searcher.search(
          new TermQuery(new Term("f", "a")), 10, new Sort(new SortField("v", SortField.Type.LONG)));
      // Expected 0 (segment 1 has 1 hit, so its points are not worth reading); main since #16857: 1001.
      assertEquals(0, pointDocsRead[0]);
    }
  }

  private static void addDoc(IndexWriter w, String f, long v) throws IOException {
    Document doc = new Document();
    doc.add(new StringField("f", f, Field.Store.NO));
    doc.add(new LongPoint("v", v));
    doc.add(new NumericDocValuesField("v", v));
    w.addDocument(doc);
  }

  private static final class CountingLeafReader extends FilterLeafReader {
    private final long[] count;

    CountingLeafReader(LeafReader in, long[] count) {
      super(in);
      this.count = count;
    }

    @Override
    public PointValues getPointValues(String field) {
      PointValues values = in.getPointValues(field);
      return values == null ? null : new CountingPointValues(values, count);
    }

    @Override
    public CacheHelper getCoreCacheHelper() {
      return null;
    }

    @Override
    public CacheHelper getReaderCacheHelper() {
      return null;
    }
  }

  private static final class CountingPointValues extends PointValues {
    private final PointValues in;
    private final long[] count;

    CountingPointValues(PointValues in, long[] count) {
      this.in = in;
      this.count = count;
    }

    @Override
    public PointTree getPointTree() throws IOException {
      return new CountingPointTree(in.getPointTree(), count);
    }

    @Override
    public byte[] getMinPackedValue() {
      return in.getMinPackedValue();
    }

    @Override
    public byte[] getMaxPackedValue() {
      return in.getMaxPackedValue();
    }

    @Override
    public int getNumDimensions() {
      return in.getNumDimensions();
    }

    @Override
    public int getNumIndexDimensions() {
      return in.getNumIndexDimensions();
    }

    @Override
    public int getBytesPerDimension() {
      return in.getBytesPerDimension();
    }

    @Override
    public long size() {
      return in.size();
    }

    @Override
    public int getDocCount() {
      return in.getDocCount();
    }
  }

  private record CountingPointTree(PointValues.PointTree in, long[] count)
      implements PointValues.PointTree {
    @Override
    public PointValues.PointTree clone() {
      return new CountingPointTree(in.clone(), count);
    }

    @Override
    public boolean moveToChild() throws IOException {
      return in.moveToChild();
    }

    @Override
    public boolean moveToSibling() throws IOException {
      return in.moveToSibling();
    }

    @Override
    public boolean moveToParent() throws IOException {
      return in.moveToParent();
    }

    @Override
    public byte[] getMinPackedValue() {
      return in.getMinPackedValue();
    }

    @Override
    public byte[] getMaxPackedValue() {
      return in.getMaxPackedValue();
    }

    @Override
    public long size() {
      return in.size();
    }

    @Override
    public void visitDocIDs(PointValues.IntersectVisitor visitor) throws IOException {
      count[0] += in.size();
      in.visitDocIDs(visitor);
    }

    @Override
    public void visitDocValues(PointValues.IntersectVisitor visitor) throws IOException {
      count[0] += in.size();
      in.visitDocValues(visitor);
    }
  }
}
Command
cp $REPRO/TestSortLaterSegmentPointsWork.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestSortLaterSegmentPointsWork -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false (same failure); with this seed gradle's default is assertions on
Expected
0 point doc IDs read in segment 1, where the query matches 1 document, as on 10.6.0, 10.5.2, 9.12.3 and main before #16857.
Observed
AssertionError: expected:<0> but was:<1001> on main 716d6f216f5, with assertions on and off; the search result itself is unchanged.

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

More files, environment, full output366 words

TestSortLaterSegmentPointsWork (one method). It does not extend LuceneTestCase: it fixes the IndexWriterConfig (default codec, NoMergePolicy) and uses ByteBuffersDirectory, and it runs as is under gradle. The counting wrapper adds the size of every points cell whose doc IDs are handed to the visitor (visitDocIDs / visitDocValues); estimates that only walk the tree are not counted. It is a work count: a cell handed to visitDocValues can hold values the visitor then rejects (here the v = 1,000,000 point).

The file uses the main signatures (on main LeafReader#getPointValues and the PointValues getters no longer declare IOException). For 10.6.0, 10.5.2 and 9.12.3 it needs throws IOException on six overrides; the independent fact check ran exactly that port on the releases, and this test on the commit bracket. The hunt's own release and bracket runs used an earlier test of the same shape, TestSortLeafStartPointsWork (a FilterDirectoryReader counter, other field names, a top-10 assertion), with the same outcomes.

As a regression test the same method can go into an existing sort test class (for example TestSortOptimization) or stay its own class.

Environment

lucenemain 716d6f216f5 (12.0.0-SNAPSHOT) and branch_11x 603af870375: fail; main before #16857 (e0fa3535ea5, 645d4fe14a20), 10.6.0, 10.5.2, 9.12.3: pass
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (nothing platform-specific)
Command (environment)
cp $REPRO/TestSortLaterSegmentPointsWork.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestSortLaterSegmentPointsWork -Ptests.seed=DEADBEEF

Observed output

# gradle, worktree at main 716d6f216f5, -Ptests.seed=DEADBEEF (-Ptests.asserts=true and false)
TestSortLaterSegmentPointsWork > testSelectiveLaterSegmentReadsNoPoints FAILED
    java.lang.AssertionError: expected:<0> but was:<1001>
        at org.apache.lucene.search.TestSortLaterSegmentPointsWork.testSelectiveLaterSegmentReadsNoPoints(TestSortLaterSegmentPointsWork.java:48)
1 test completed, 1 failed

# the same worktree with fix A (assertions on)
TestSortLaterSegmentPointsWork: 1 test, 0 failures; with 11 sort and comparator suites: 12 classes, 112 tests, 0 failures (BUILD SUCCESSFUL)

# standalone JUnit runner, seed DEADBEEF, this test
main 716d6f216f5 code (13 changed search files over 645d4fe jars), -ea / -da   1 run, 1 failure   expected:<0> but was:<1001>
full lucene/core source tree at 716d6f216f5 (javac build), -ea / -da         1 run, 1 failure   expected:<0> but was:<1001>
13 changed search files + fix A, -ea / -da                                    1 run, 0 failures
13 changed search files + fix B, -ea / -da                                    1 run, 0 failures
main 645d4fe14a20 (predates #16857), -ea / -da                                1 run, 0 failures

Why it looks unintended

  • The comparator reads points only when that cuts the documents to check 8x versus the query's cost; otherwise the range is "not selective enough".
  • -1 is the "no scorer cost yet" placeholder; (-1) >>> 3 = 2^61 - 1 disables the rule by accident.
  • The call serves #16857's index-sort comparator; that PR calls needless iterators from points "redundant work".
The full argument354 words
  • The rule. NumericComparator.java:430-431 (main 716d6f216f5): "the new range is not selective enough to be worth materializing, it doesn't reduce number of docs at least 8x", with threshold = iteratorCost >>> 3 (:426) and the starting cost set in setScorer(): "starting iterator cost is the scorer's cost" (:349). It is an implementation heuristic in a source comment, not a public contract; the report is the measured regression against it.
  • The placeholder. LUCENE-10555 (#11591 "avoid repeated NumericLeafComparator setScorer calls", PRs #864 and #878, 2022) introduced iteratorCost == -1 to mean "not set from the scorer yet". It was never meant to reach the threshold computation.
  • The order is new. On 645d4fe14a20 the production callers of setHitsThresholdReached() are TopFieldCollector.countHit() (:87) and the MultiLeafFieldComparator delegation, both after setScorer(). The only other caller, the unit test TestTermOrdValComparatorAdaptiveSkipping (:145, :192), calls it on a string-sort leaf comparator that never gets a scorer; it does not reach the numeric points builder. #15757 (2026-02) describes the first per-segment update as running from the builder's setScorer(), "by analogy with the points-based version".
  • Purpose of the call. Its comment, "we only get told when the threshold is crossed, which may have been in a previous segment", fits IndexSortedLeafFieldComparator's per-segment flag (IndexSortedLeafFieldComparator.java:35, :91-97) and, after an index-sorted segment, the shared NumericComparator flag; neither needs the points update before the scorer cost is known. That class's comment (:31-33) says that when collection terminates anyway, "the delegate's skipping, e.g. building iterators from points, is redundant work: it measurably slows down index-sorted searches."
  • The PR record. #16857's body, its CHANGES entry ("Sort pruning decisions are now entirely made through the Pruning enum", 11.0.0 API Changes, CHANGES.txt:39-47), MIGRATE.md and all 4 reviews and 4 inline comments say nothing about reading points before the scorer is known. Its one benchmark comment ("no significant difference") ran non-selective tasks.
  • Precedent. #13498, "Avoid performance regression by constructing lazily the PointTree in NumericComparator", is listed under Bug Fixes in 9.11.1 and 10.0.0: avoidable points work in this very comparator was treated as a bug.
The devil's-advocate check211 words

The strongest case for "intended" (built by an intended-behaviour checker and by a reviewer from another model family): no public contract covers this cost. LeafFieldComparator#setHitsThresholdReached says only that it "is called from a collector when hits threshold is reached" (LeafFieldComparator.java:109-113) and orders nothing against setScorer; these APIs are @lucene.experimental; the constructor call is deliberate and commented; and an eagerly built iterator prunes more hits, so it could in principle be faster.

Why it fails: no public contract covers this cost, and an eager iterator can prune more (2005+ instead of 19774+ hits counted in one timed shape). But the call is deliberate for the threshold flag, and no PR record we found discusses whether it should run the points update before a scorer cost is known; fix A keeps the call and drops only that update. The value that reaches the rule is the placeholder, not an estimate. In a segment where the query matches 0 or 1 documents nothing can be gained by building an iterator over 1,000 or 1,000,000 competitive documents. The measured wall-clock is worse with the early read (1.5x to 1.9x, about +10%), and in those runs the 3-line guard restored the old numbers. Being unreleased lowers the priority, not the verdict.

Suggested fix

Recommended A: skip the points update until the scorer cost is known

Return early from the points builder's update while its cost is still -1: if (iteratorCost == -1) return; at the top of PointsCompetitiveDISIBuilder.doUpdateCompetitiveIterator() (3 lines). setScorer() then makes the first decision with the query's real cost, as before #16857. With it the test and 11 sort and comparator suites pass (12 classes, 112 tests).

    @Override
    protected void doUpdateCompetitiveIterator() throws IOException {
      if (iteratorCost == -1) {
        return; // no scorer cost yet: setScorer() performs the first update
      }
All options and trade-offs2 options ยท 604 words

Two fixes were built and checked; fix A was also validated with gradle in a worktree at 716d6f216f5 (JDK 25, -Ptests.seed=DEADBEEF). Open, stale PR #16163 changes the same updateCompetitiveIterator guard family (for leafTopSet); it does not touch the placeholder, the points builder or the constructor call, but a fix should not conflict with it. Choosing between them is the maintainers' call: A is the smaller, local change; B restores the "scorer first" order for every comparator.

A: skip the points update until the scorer cost is known (validated by the checks) (recommended)

comparators/NumericComparator.java, top of PointsCompetitiveDISIBuilder.doUpdateCompetitiveIterator() (:363), +3 lines:

    @Override
    protected void doUpdateCompetitiveIterator() throws IOException {
      if (iteratorCost == -1) {
        return; // no scorer cost yet: setScorer() performs the first update
      }

Trade-offs

  • setScorer() performs the segment's first update with the real cost (:346-355), as before #16857, while IndexSortedLeafFieldComparator still learns about the threshold from the constructor. A segment with no scorer does no points work.
  • It keeps the constructor call's purposes: setHitsThresholdReached() sets the flag before the update returns (:155), so after an index-sorted segment the next segment still skips (512 point doc IDs read, as on main and 645d4fe14a20).
  • Local to the points builder; the collector protocol is unchanged. The skipped call still increments updateCounter (:235) before it returns, so in each later segment the count runs one ahead of the pre-#16857 code and the sampling that starts after 256 updates (:237-240, :456) shifts by one update. Not measured; for the maintainers to judge.

What the checks validated

  • Gradle, worktree at 716d6f216f5, assertions on (the seed's default), repeated by the independent fact check: the minimal test fails without the fix (assertions on and off) and passes with it; together with TestSort (30), TestSortOptimization (31), TestTopFieldCollector (13), TestTopFieldCollectorEarlyTermination (10), TestSortedNumericSortField (9), TestNonPruningSortField (4), TestSearchAfter (1), TestSortRandom (1) and the comparators package (TestNumericComparator 3, TestTermOrdValComparatorAdaptiveSkipping 5, TestUpdateableDocIdSetIterator 4): 12 classes, 112 tests, 0 failures.
  • Standalone runner: the test passes with -ea and -da over the 13 changed search files. The one standalone full-core run with fix A used the hunt's earlier test (-da) and passed.
  • Every reach shape returns to the pre-#16857 counts; the hunt's two probes written to check #16857, a brute-force probe of its pruning decisions (28,512 + 874,368 checks) and an index-sorted exhaustive probe (928,320 searches), stay clean; a reviewer from another model family ran TestSortOptimization (31 tests), TestTopFieldCollectorEarlyTermination (10) and TestNumericComparator (3) with it, 0 failures; in the TLA+ model the properties NoBlindUpdate, FirstUpdateNotLost and LeafKnowsThreshold hold.

B: move the constructor's call into setScorer() (validated by the checks)

In TopFieldLeafCollector, delete TopFieldCollector.java:56-59 and add the same if (totalHitsRelation == TotalHits.Relation.GREATER_THAN_OR_EQUAL_TO) comparator.setHitsThresholdReached(); right after comparator.setScorer(scorer) (:117).

Trade-offs

  • Restores the "scorer first" order for every comparator and still tells the leaf comparator before its first collect.
  • setScorer() can run more than once per segment: a bulk scorer calls it on every score() call (Weight.java:254), for example once per interval under a query timeout (TimeLimitingBulkScorer), so the call repeats. A segment with no scorer never hears about the threshold, which is harmless because it collects nothing.

What the checks validated

The test passes with -ea and -da over the 13 changed search files; every reach shape returns to the pre-#16857 counts (the 1,000,001-doc page-1 and page-2 shapes, 0 and 0, run by the independent fact check); the hunt's two #16857 probes stay clean; the cross-model reviewer's suite runs (TestSortOptimization, TestTopFieldCollectorEarlyTermination, TestNumericComparator) pass; the TLA+ properties hold. B has no gradle run.

Downstream note

Downstream services that build on Lucene snapshots and sort by numeric fields pay this extra CPU on selective sorted queries until it is fixed; the hits are unaffected.

How it was found

Reading the newly merged #16857 diff for changed call orders, the constructor-time setHitsThresholdReached() call stood out. A probe that counts the points a sorted search reads, run before and after #16857, showed 0 vs 80,000 doc IDs with the same top 10. It was reduced to one test, and the per-segment protocol was modelled in TLA+.

The method in full170 words

Method: non-formal (the first signal came from code reading; the formal model came later).

  • Lead. A lens on newly merged upstream code read the #16857 diff and flagged the constructor call that runs before setScorer().
  • Probe. A differential count with a PointTree wrapper on the public FilterLeafReader API, on the code before #16857 and on upstream main: 0 vs 80,000 point doc IDs read in later segments (5 x 20,000 docs, threshold 10), identical top 10. A skeptic re-run confirmed it.
  • Reduction. The hunt reduced it to one JUnit test (later the one-method test above) and ran it on main code, the commit bracket and three releases, with assertions on and off.
  • Model. A TLA+ model of the per-segment collector and comparator protocol (TLC): the property NoBlindUpdate (no points are read before the scorer cost is known) is violated by upstream main, the shortest trace being a second segment with no scorer; it holds for the code before #16857 and for both fixes, and the vacuity probes are reachable.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-30: three search-tool runs, GitHub issues and PRs including the files of all 409 open PRs and every #16857 comment, the mailing lists, the JIRA archive, Elasticsearch, OpenSearch and Solr; refreshed). #16857 introduced it.

Searches5 searches

Related, not reports of this bug: #16857 (introduced it; its discussion is about hasBlocks plus one non-selective benchmark); #16947 (document blocks, FieldValueHitQueue only); #11591, #864 and #878 (origin of the -1 placeholder); #15757 (setScorer-first design of the skipper builder); #16163 (open, stale, the same guard family); OpenSearch #17404 and Elasticsearch #150714 (both discuss iteratorCost >>> 3 in other situations, before #16857). Tracking issue #16937 does not mention it. Limits: keyword search only; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues/PRs, commits, Elasticsearch, OpenSearch and Solr trackers, dev@, java-user@, issues@, commits@, JIRA archive, web)2026-10-30 (finder's run 00:10, follow-up runs 01:18-01:26, refresh 01:30-01:36)three runs: identifiers (iteratorCost, setHitsThresholdReached, TopFieldLeafCollector, PointsCompetitiveDISIBuilder), symptoms, and trigger phrasing ("not selective enough", "threshold is crossed"); 51, 51 and 45 queries97, 476 and 286 distinct hits, none a report
GitHub apache/lucene (manual)2026-10-30iteratorCost, setHitsThresholdReached, TopFieldLeafCollector, PointsCompetitiveDISIBuilder, IndexSortedLeafFieldComparator, "16857"; sort items updated since 2026-10-28; all items created since 2026-10-29no report; only unrelated items
Open apache/lucene PRs2026-10-30the changed files of all 409 open PRs; the diffs of #16947, #16163 and #14395 readnone changes the constructor call, the -1 placeholder or the points builder's update
The introducing PR #168572026-10-30every comment, review and inline comment, and its mailing-list mirror threadreviews about hasBlocks; one benchmark, "no significant difference"; nothing about point reads before the scorer
GitHub apache/lucene, refresh2026-10-30main head and the history of TopFieldCollector.java and NumericComparator.java on main and branch_11x; items created since midnight UTC and updated since a recent cutoffmain still at 716d6f216f5, no change to either file since #16857; only #16952 is new, about bulk-scoring bounds, unrelated

History

Introduced by #16857 (70a428b5ba9, merged 2026-10-29) and its branch_11x backport 203699279f5. Bracketed: the parent e0fa3535ea5 passes, 70a428b5ba9 fails. No release has it: 10.6.0, 10.5.2 and 9.12.3 pass, and a control shows the counter registers reads there.

Versions tested5 rows

Introduced by 70a428b5ba9, apache/lucene#16857 "Consolidate Sort pruning logic" (romseygeek, Alan Woodward), opened 2026-10-16 and squash-merged to main on 2026-10-29; backported to branch_11x as 203699279f5 the same day. The constructor call is already in the PR's first commit 466a0384cb8. CHANGES lists it under Lucene 11.0.0 (API Changes).

The older ingredients were harmless alone, because setHitsThresholdReached() ran only from countHit(), after setScorer(): the iteratorCost >>> 3 check with its 8x comment (b0333ab5c8d, LUCENE-9280, #1351, 2020-06-23, then in FilteringNumericLeafComparator); the hook setHitsThresholdReached -> updateCompetitiveIterator, added by 99220677fe4 (LUCENE-9449, #1725, 2020-09-08), which also moved the check into comparators/NumericComparator.java; and the -1 placeholder (f431511cb71, LUCENE-10555 / #864, 2022).

Versions and branches executed, with results
Version or commitVerdictResult
main 716d6f216f5 (head, 12.0.0-SNAPSHOT) affectedfails: gradle in a worktree at that commit and the standalone runner, assertions on and off, expected:<0> but was:<1001>
branch_11x 603af870375 (head) affectedfails (-ea, -da); both files are identical to main
70a428b5ba9 (#16857) vs its parent e0fa3535ea5 bisect#16857 fails (-ea, -da); the parent passes (-ea, -da)
main 645d4fe14a20 (2026-10-28) not affectedpasses (-ea, -da; predates #16857)
10.6.0 (4ae946e5a9b), 10.5.2, 9.12.3 not affectedpass (-ea, -da); a control counts 512 point doc IDs on 10.6.0 and 9.12.3 when the 8x rule allows a read

Independent checks

An independent reproducer, reviewers from another model family (OpenAI GPT-5.6), an intended-behaviour devil's advocate and an adjudication each ran their own or the finder's tests on main code. All agree on the root cause and the fixes; one reviewer's formal "not real" came from a checkout older than #16857, and its own test fails on main.

Checks and verdicts447 words

The main-line evidence first came from the 13 search files #16857 changed, compiled over the 645d4fe14a20 jars, and from a javac build of the full lucene/core tree at 716d6f216f5. This doc adds gradle runs in a worktree at 716d6f216f5, which closes the remaining caveat that no real main checkout had been run.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateIts test (the earlier TestSortLeafStartPointsWork) fails on main code (-ea, -da) and passes before #16857, with fix A, with fix B, on 10.6.0 and 10.5.2; the TLA+ property NoBlindUpdate is violated by main and holds for the old code and both fixes.
Independent reproducer realOwn test fails on main code (1,000 vs 0) and passes on 645d4fe, 10.6.0, 10.5.2 and 9.12.3; a stack probe puts the first read in the collector's constructor, before setScorer; each of two one-line probes fixes it.
cross-model review, refute lens formally not real, in substance realOpenAI GPT-5.6. It used a checkout older than #16857, where nothing fails. On the main code its own trigger test fails with 9 point visits (a later segment with 8 non-matches and one cost-1 match), and a second test, whose later segment has no match and so no scorer, fails with 8. Agrees on the root cause and both fixes; ran the sort suites with each fix.
Cross-model review, intended lens real, highOpenAI GPT-5.6. Its own test with a later segment that has no scorer fails on main code and passes with A and with B; the intended case is moderate and rejected.
Intended-behaviour devil realBuilt the strongest case for intended and rejected it; measured the rough wall-clock cost and that fix A restores it.
adjudication confirmed, highRe-ran the finder's test: main code fails (-ea, -da), the old code passes, fix A passes. No-noise gate passed at Low.
Registration check, audit and this doc keepA javac build of the full core tree at 716d6f216f5 fails (-ea, -da) and, with fix A, passes (-da; the hunt's earlier test); this doc's gradle run in a worktree at 716d6f216f5 fails with assertions on and off and passes with fix A.
independent fact checks of this doc real as described; wording correctedA checker re-ran the gradle command and fix A in its own worktree, ran this test on the commit bracket and the three releases (-ea, -da), fix B on the 1,000,001-doc shapes and an index-sorted-then-unsorted shape; a reviewer from another model family (OpenAI GPT-5.6) reviewed the text read-only. Their corrections are applied.

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

Finding record

LFV-1057, snapshot of 2026-11-06

Registry title
Since #16857 (upstream main and branch_11x, unreleased), a numeric field-sorted search reads a later segment's whole competitive points range before the scorer is set: TopFieldLeafCollector's constructor calls setHitsThresholdReached() while NumericComparator's iteratorCost is still its -1 'no scorer yet' value, so the 8x selectivity check never rejects (1,001 point doc IDs read where releases read 0; performance regression, results correct)
Area
search / field sorting: TopFieldCollector + NumericComparator points-based sort pruning (competitive iterator)
Priority
Low, rank 363: A performance-only regression with correct results on the default numeric-sorted search path, but only in unreleased main/branch_11x code and with measured slowdowns of about 10% to 1.9x, so it is Low.
Impact class
minor
Impact note
Performance regression only, on upstream main and branch_11x since #16857 (no release has it; fixable before 11.0.0), with the default codec, IndexWriterConfig and IndexSearcher: every IndexSearcher#search(query, n, sort) (also searchAfter) sorted on a numeric field indexed with points, over a multi-segment index, runs one points intersect over the competitive range of each segment that starts after the default 1,000-hit threshold was crossed, even where the query matches few or no documents and the 8x rule would have skipped it; the waste per segment is bounded by that segment's points in the range (1,001 doc IDs in the repro, 1,000,001 plus a 1M-bit set for a 1M-doc segment), rough timings on a shared host showed about 1.9x on a selective 1M-doc later segment and about 10% on a 2%-filter 10 x 100k shape (both restored by the one-line fix), and the top hits are identical in every executed shape.
Summary
You run searcher.search(new TermQuery(new Term("brand", "lux")), 10, new Sort(new SortField("price", SortField.Type.LONG))) over two segments: the first has 1,001 hits, so the default 1,000-hit threshold is crossed there, and in the second the query matches one document that cannot compete. You expect the second segment not to read its points, because the comparator builds its competitive iterator from points only when that cuts the query's own cost at least 8x; 645d4fe, 10.6.0, 10.5.2 and 9.12.3 read 0 point doc IDs there. Instead, since #16857 the new leaf collector tells the comparator about the threshold before setScorer(), while its cost is still the -1 'no scorer yet' value (so the 8x limit becomes 2^61-1), and it intersects the whole competitive range, 1,001 doc IDs; the top 10 is still right, only the work is wasted.
Introduced
70a428b5ba90cdbcf3c8380e21b006a68b4dcb85 (apache/lucene#16857 'Consolidate Sort pruning logic', author romseygeek (Alan Woodward), squash-merged to main 2026-10-29; branch_11x backport 203699279f5 the same day; the constructor call is already in the PR's first commit 466a0384cb8 of 2026-10-16) [exec: commit-level bracket with the shipped repro, each commit's core sources compiled over the 645d4fe jars: parent e0fa3535ea5 passes with -ea and -da, 70a428b5ba9 fails with -ea and -da, 'expected:<0> but was:<1001>'; read: git log -S finds the constructor's comment 'we only get told when the threshold is crossed' only in 70a428b5ba9]. The other ingredients were harmless before, because setHitsThresholdReached() was only called from countHit(), after setScorer(): the leaf hook setHitsThresholdReached -> updateCompetitiveIterator and the 'iteratorCost >>> 3' check (99220677fe4, LUCENE-9449 era, 2020) and the iteratorCost == -1 sentinel (f431511cb71, LUCENE-10555 / #864, 2022). (2026-10-29 (author date of 70a428b5ba9, 2026-10-29, equal to the merge time; the branch_11x backport 203699279f5 has the same author date; the code was first written on the PR branch on 2026-10-16))
First affected version
unreleased (11.0.0 would be the first release; #16857's CHANGES entry is under Lucene 11.0.0); 10.6.0, released 2026-10-29 from 4ae946e5a9b, and every earlier release are not affected; the last unaffected main commit is e0fa3535ea5
Affected versions
[exec] upstream main at the introducing commit 70a428b5ba9 (-ea and -da) and at the head 716d6f216f5 (-ea and -da, both through the 13-file shadow over the 645d4fe jars and, at the registration check on 2026-10-30, through a javac build of the full lucene/core source tree at 716d6f216f5: 'expected:<0> but was:<1001>'), branch_11x head 603af870375 (-da); every run of the finder, the independent reproduction, the intended-behaviour check, the GPT reviewers and the adjudication on the upstream-main code fails. Not affected [exec]: main parent e0fa3535ea5 (-ea, -da), main 645d4fe14a20 (-ea, -da; it predates #16857), 10.6.0 = 4ae946e5a9b (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3 (-ea, -da), all with the 10.x-signature port on releases; a non-vacuity control counts 512 point doc IDs on 10.6.0 and 9.12.3, so the 0 there is a real measurement. [read] branch_10_6 has no constructor call (setHitsThresholdReached only in countHit); TopFieldCollector.java and NumericComparator.java are byte-identical on main and branch_11x, and #16857 is the last commit on both files on both branches (checked 2026-10-30). [exec 2026-11-02] upstream main e1faeba08bd, a native 12.0.0-SNAPSHOT build with no shadow, fails with -ea and -da ('expected:<0> but was:<1001>'); [read 2026-11-02] branch_11x da3ea39fa59 has both files identical to main.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), 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 TLA+/TLC
Upstream and prior reports
none found (2026-10-30, read-only; the prior-report record verdict new): three scripted prior-report searches on identifiers, symptoms and trigger phrasing (51, 51 and 45 queries over GitHub issues/PRs, commits, Elasticsearch/OpenSearch/Solr trackers, dev@/java-user@/issues@/commits@ and the JIRA archive; 97, 476 and 286 distinct hits, none a report); manual gh searches for iteratorCost, setHitsThresholdReached, TopFieldLeafCollector, PointsCompetitiveDISIBuilder, IndexSortedLeafFieldComparator, NumericComparator and sort items updated since 2026-10-28, and all items created since 2026-10-29 (only #16951, unrelated); the changed files of all 409 open apache/lucene PRs and, at the registration check, of the 30 open PRs updated since 2026-10-28 (only #16947, which changes FieldValueHitQueue (hasBlocks) and a test; #16163, #14395 and others touching these files do not change the constructor call, the -1 sentinel or PointsCompetitiveDISIBuilder.doUpdateCompetitiveIterator); every comment of #16857 (1 issue comment with a non-selective benchmark, 'no significant difference'; 4 reviews and 4 inline comments, all about hasBlocks); tracking issue #16937 (no mention). Upstream main head is still 716d6f216f5 and branch_11x head 603af870375; the last commit on TopFieldCollector.java and NumericComparator.java is #16857 on both branches (author date 2026-10-29), so no fix exists by author date or by PR date.
Lucene commit tested
this one-method test on upstream main 716d6f216f5 (gradle in a worktree at that commit with -Ptests.asserts=true and false, then with fix A together with the sort suites; the standalone JUnit runner over the 13 search files #16857 changed, compiled over the 645d4fe14a20 jars, and over a javac build of the full lucene/core source tree, -ea and -da), the commit bracket e0fa3535ea5 (parent, passes) and 70a428b5ba9 (#16857, fails), branch_11x 603af870375 (fails), main 645d4fe14a20 (predates #16857, passes) and the 10.6.0, 10.5.2 and 9.12.3 release jars (pass, with a control that the counter registers reads there), all with -ea and -da. The bracket and release runs of this test come from the independent fact check; the hunt ran them with an earlier test of the same shape, with the same outcomes.

Lucene and upstream sources

Finding record
  • a Lucene clone@716d6f216f5 (git show, read-only) lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:50-62 (TopFieldLeafCollector constructor; :56-59 'if (totalHitsRelation == TotalHits.Relation.GREATER_THAN_OR_EQUAL_TO) { // we only get told when the threshold is crossed, which may have been in a previous segment comparator.setHitsThresholdReached(); }', added by #16857), :115-123 (setScorer, which runs after the constructor)
  • a Lucene clone@716d6f216f5 lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java:154-158 (NumericLeafComparator.setHitsThresholdReached -> competitiveDISIBuilder.updateCompetitiveIterator()), :315 ('private long iteratorCost = -1;'), :346-355 (setScorer: 'if (iteratorCost == -1) { ... iteratorCost = s.iterator().cost(); // starting iterator cost is the scorer's cost ... updateCompetitiveIterator(); // update an iterator when we have a new segment }'), :426-444 ('final long threshold = iteratorCost >>> 3;', 'the new range is not selective enough to be worth materializing, it doesn't reduce number of docs at least 8x', pointValues.intersect(visitor), iteratorCost = newIterator.cost())
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:836-843 (at 645d4fe14a20) (searchLeaf calls collector.getLeafCollector before weight.scorerSupplier, so the constructor runs even for a segment where the query matches nothing)
  • https://github.com/apache/lucene/pull/16857 (introducing PR: description 'consolidates these decisions into segment-level logic', benchmark comment 'no significant difference', reviews about hasBlocks only)
  • https://github.com/apache/lucene/pull/864 and https://github.com/apache/lucene/issues/11591 (LUCENE-10555: origin of the iteratorCost == -1 'no scorer yet' sentinel)
  • https://github.com/apache/lucene/pull/13498 (GITHUB#13498, avoidable PointTree work in NumericComparator fixed as a bug fix: classification precedent)
The finding in 30 seconds
  • a Lucene clone@716d6f216f5 (read-only) lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:50-62 (constructor, :56-59 the #16857 call), :64-77 (countHit), :114-123 (setScorer)
  • a Lucene clone@716d6f216f5 lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java:59-60 (shared flags), :154-158 (setHitsThresholdReached), :222-247 (updateCompetitiveIterator guards), :315 (iteratorCost = -1), :346-355 (setScorer), :363-446 (doUpdateCompetitiveIterator; :426 threshold, :430-431 the 8x comment, :441 intersect, :444 cost)
  • a Lucene clone@716d6f216f5 lucene/core/src/java/org/apache/lucene/search/IndexSortedLeafFieldComparator.java:31-35, :91-97
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, :836, :842 (identical at 716d6f216f5); TopFieldCollector.java:87 (the only call before #16857)
  • a Lucene clone@716d6f216f5 lucene/core/src/java/org/apache/lucene/search/FieldValueHitQueue.java:150 (single sort field: GREATER_THAN_OR_EQUAL_TO), :190-191 (IndexSortedLeafFieldComparator wrapper); TopFieldCollector.java:70-72 (countHit calls setHitsThresholdReached only while EQUAL_TO)
Who is affected
  • a Lucene clone@716d6f216f5 lucene/MIGRATE.md:22-28 (SortField.withoutPruning never lets comparators skip documents using points)
  • a Lucene clone@716d6f216f5 lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java:98-111 (Pruning.NONE: no builder; points first, else DocValuesSkipper); FieldValueHitQueue.java:146-151; IndexSearcher.java:226-231, :767-777; TopFieldCollectorManager.java:111-140; TopDocsCollector.java:50-53
Why it looks unintended
  • a Lucene clone@716d6f216f5 lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java:315, :349, :426-431
  • a Lucene clone@716d6f216f5 lucene/core/src/java/org/apache/lucene/search/IndexSortedLeafFieldComparator.java:31-35, :91-97; LeafFieldComparator.java:109-113; lucene/CHANGES.txt:39-47 and the GITHUB#13498 entries (:2410 under 10.0.0 Bug Fixes, :2765 under 9.11.1 Bug Fixes)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:87
  • https://github.com/apache/lucene/pull/16857, https://github.com/apache/lucene/issues/11591, https://github.com/apache/lucene/pull/864, https://github.com/apache/lucene/pull/13498
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/comparators/TestTermOrdValComparatorAdaptiveSkipping.java:145, :192 (setHitsThresholdReached on a leaf comparator without setScorer)
Suggested fix
  • a Lucene clone@716d6f216f5 lucene/core/src/java/org/apache/lucene/search/Weight.java:254 (DefaultBulkScorer.score calls collector.setScorer); comparators/NumericComparator.java:237
  • a Lucene clone@716d6f216f5 lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java:155 (flag set before the update), :235-240 (updateCounter and sampling), :456 (updateSkipInterval)
Prior reports
  • GitHub API, read-only, 2026-10-30: repos/apache/lucene/commits?sha=main (head 716d6f216f5), commits on TopFieldCollector.java and NumericComparator.java (main, branch_11x: last is #16857), search/issues created:>=2026-10-30 (#16952 only), gh pr view 16952 (files: CHANGES.txt, IndexSearcher.java, LRUQueryCache.java, SeededKnnVectorQuery.java, TestConstantScoreBulkScorer.java, SuggestIndexSearcher.java)
History
  • a Lucene clone git log: 70a428b5ba9 (author date 2026-10-29), 716d6f216f5; lucene/core/src/java/org/apache/lucene/util/Version.java:256 at 716d6f216f5 (LATEST = LUCENE_12_0_0)

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

Repro files

About the diagrams

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.