All findings · Lucene finding · LFV-884 · grouping-score-mode-ignores-score-dependent-group-selector

Grouping by score ranges reads a placeholder score when a pass requests no scores

Grouping hits by score ranges (DoubleRangeGroupSelector over DoubleValuesSource.SCORES) silently reads a placeholder score (0 for term and boolean queries) in any grouping pass that requests no scores: hits scoring 1, 2, 3, 1, 3 come back as one empty group [0, 1) once groups are sorted by a field. Affected since 8.6.0; the defaults are not.

Summary

Priority
Low, rank 327Needs uncommon score-range grouping with a non-scoring sort, and GroupingSearch defaults are unaffected.
Impact
silent wrong results
Since
8.6.0 (2020), when the range selectors were addedintroduced 2020-05-11 · about 6 years
Affected
8.6.0 to 10.6.0 (all 49 releases to 10.5.2 run, 10.6.0 at 4ae946e5a9b) and main
Status
documented
Found by
bytecode scan and code reading, confirmed by a test

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

A range group selector can bucket hits by their score; it reads the score from the collector's scorer. But only each pass's sorts (and the max-score setting) decide whether the query computes scores, so with a field group sort the first pass reads 0.

Summary card titled "Grouping by score ranges reads a placeholder score when a pass requests no scores". Panel 1, the executed example with groups sorted by id: the five documents score 1, 2, 3, 1, 3; expected, each hit in its own range ([1, 2) docs 0 and 3, [2, 3) doc 1, [3, 4) docs 2 and 4); actual, pass 1 reads 0.0 for every hit and pass 2 skips all five, leaving one empty group [0, 1). Panel 2, inside GroupingSearch.search: pass 1 runs with COMPLETE_NO_SCORES because the id sort needs no scores, the selector reads 0.0, pass 2 scores because includeMaxScore defaults to true and rejects every real score; totalHitCount 5, totalGroupedHitCount 0, totalGroupCount 1. A bottom strip lists the other wrong cases (includeMaxScore false, AllGroups and AllGroupHeads alone), the score tie-break workaround and the fix.
The repro's example: the first pass runs without scores, so all five hits read 0.0 and land in [0, 1); the second pass scores them, finds none in [0, 1) and returns one empty group. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index five documents and search a OR b OR c made of constant-score clauses: they score 1, 2, 3, 1, 3. Group them with GroupingSearch and new DoubleRangeGroupSelector(DoubleValuesSource.SCORES, new DoubleRangeFactory(0, 1, 10)).
  2. With the defaults plus setGroupDocsLimit(10) (the default returns one document per group) the ranges are right: [1,2) = docs 0, 3; [2,3) = doc 1; [3,4) = docs 2, 4. Now sort the groups by the id field. You expect the same three groups: a document's score does not depend on how groups are ordered.
  3. You get one group, [0, 1), with no documents: 5 hits, 0 grouped. No error on main, with assertions on or off.
  4. Why: each pass asks the query for scores only when its own sort or max-score setting needs them, and a group selector cannot ask. Pass 1 runs without scores: every hit reads 0 and lands in [0, 1). Pass 2 scores (for the max score), and every real score falls outside [0, 1), so every hit is skipped.
  5. Same cause, other outputs: setIncludeMaxScore(false) with a field sort inside groups gives the three right ranges, all empty; AllGroupsCollectorManager alone reports 1 range instead of 3, and AllGroupHeadsCollectorManager alone with a field sort 1 head instead of 3.

Who is affected

Applications that group with DoubleRangeGroupSelector or LongRangeGroupSelector over a source that needs scores, and either sort groups by a field, turn off includeMaxScore with a field sort inside groups, or run AllGroupsCollectorManager (or AllGroupHeadsCollectorManager with a field sort) alone. Opt-in and rare; the GroupingSearch defaults are not affected; any index size; queries that compute scores only when asked (term, boolean).

Mechanism, terms and numbers759 words

DoubleRangeGroupSelector and LongRangeGroupSelector group hits by ranges of a values source, and they hand that source the collector's scorer, so a source that reads the score (DoubleValuesSource.SCORES) can group hits into score bands. But a GroupSelector cannot say that it needs scores, and the four grouping collectors that use one decide whether the query computes scores from their sorts or reducer alone. In a pass where none of them needs scores, the query runs in COMPLETE_NO_SCORES, the selector reads a placeholder (0 for the term and boolean queries of every repro) for every hit, and the groups come out silently wrong: one empty group with a field group sort, the right groups without their documents with includeMaxScore=false, and 1 group or head instead of several from AllGroupsCollectorManager or AllGroupHeadsCollectorManager run alone. It has been there since the selectors were added in 8.6.0; the GroupingSearch defaults (relevance group sort, includeMaxScore=true) are not affected.

Terms

  • Group selector (GroupSelector): decides the group of each hit. DoubleRangeGroupSelector and LongRangeGroupSelector read a value per hit from a values source and put the hit into a fixed-width range (DoubleRangeFactory(0, 1, 10): ranges of width 1 from 0 to 10).
  • Score-dependent source: a DoubleValuesSource whose needsScores() is true, such as DoubleValuesSource.SCORES (the hit's score) or its toLongValuesSource(). The range selectors feed it the collector's scorer through DoubleValuesSource.fromScorer (DoubleRangeGroupSelector.java:60-63, LongRangeGroupSelector.java:61-64).
  • Score mode: each collector tells the query whether it needs scores (Collector.scoreMode()). In COMPLETE_NO_SCORES, "scores won't be available" (ScoreMode.java:25-26); a non-scoring term weight, for example, uses a dummy scorer that returns 0 (TermQuery.java:77-91).
  • Two-pass grouping: GroupingSearch runs a first pass (FirstPassGroupingCollector) to find the top groups, then a second pass (TopGroupsCollector, a SecondPassGroupingCollector) to collect each group's documents; AllGroupsCollector and AllGroupHeadsCollector can run alongside or on their own.

The bug

  1. GroupSelector has setScorer but no needsScores() (GroupSelector.java:34-72), so a selector cannot ask for scores.
  2. The four collectors that use a group selector take their score mode from their sorts or reducer only, each pass on its own: the first pass from the group sort (FirstPassGroupingCollector.java:105, 124-127), the second pass from the reducer, which needs scores only for the max score or a within-group sort that needs them (SecondPassGroupingCollector.java:76-79, TopGroupsCollector.java:127-128), AllGroupHeadsCollector from its sort (:119-122), and AllGroupsCollector never (:91-94, "the result is unaffected by relevancy").
  3. So with a field group sort the first pass runs without scores. The selector reads 0 for all five hits and keeps one group, [0, 1).
  4. The second pass scores, because includeMaxScore defaults to true, and the selector now reads the real scores 1, 2, 3, 1, 3. None falls into [0, 1), the only group, so advanceTo returns SKIP for every hit (DoubleRangeGroupSelector.java:75, SecondPassGroupingCollector.java:90): one empty group.
  5. With the relevance group sort but includeMaxScore=false and a field within-group sort, the first pass is right and the second pass reads 0: the three right ranges come back empty. AllGroupsCollector and AllGroupHeadsCollector alone read 0 for every hit: 1 range, 1 head.

DoubleValuesSource.SCORES.needsScores() is true (DoubleValuesSource.java:349-368); the information exists, but no grouping collector asks the selector. When a sort happens to need scores, the selector is right by accident.

Not every query is hit. Without scores the value a scorer returns is unspecified (ScoreMode only says scores "won't be available"). Term and boolean queries and ConstantScoreQuery clauses return 0 (TermQuery.java:77-91, ConstantScoreQuery.java:141-146, 202-203), so they are grouped wrongly; queries whose score is a constant boost in every mode, such as MatchAllDocsQuery or a point range query (ConstantScoreWeight), still return their real score and are grouped right (executed).

The numbers

Five documents with tags {a}, {a,b}, {a,b,c}, {a}, {a,b,c}; a OR b OR c of ConstantScoreQuery(TermQuery) clauses scores them 1, 2, 3, 1, 3 (a plain search, asserted by the control test). Expected for every configuration (with setGroupDocsLimit(10)): {[1,2)=[0, 3], [2,3)=[1], [3,4)=[2, 4]}. Group sort by id: {DoubleRange(0.0, 1.0)=[]} (totalHitCount 5, totalGroupedHitCount 0; with setAllGroups(true) also totalGroupCount 1). LongRangeGroupSelector over SCORES.toLongValuesSource(): {LongRange(0, 1)=[]}. includeMaxScore=false with a within-group sort by id: {[1,2)=[], [2,3)=[], [3,4)=[]}. AllGroupsCollectorManager alone: [DoubleRange(0.0, 1.0)]. AllGroupHeadsCollectorManager with sort id alone: heads [0] instead of [0, 1, 2]. An independent reproducer with 12 documents whose scores are 10.8 to 16.8 recorded what the selector reads under an id sort: 0.0 for all 12.

Exposure and likelihood340 words
  • Who. Grouping with DoubleRangeGroupSelector or LongRangeGroupSelector over a values source that needs scores: DoubleValuesSource.SCORES, its toLongValuesSource(), or (by reading, not run) an expression that binds _score to it, the pattern the expressions module documents (SimpleBindings.java:35), plus any one of: (a) a group sort that does not need scores; (b) includeMaxScore=false and a within-group sort that does not need scores, even with the default relevance group sort; (c) AllGroupsCollectorManager, or AllGroupHeadsCollectorManager with a sort that does not need scores, run on its own.
  • Where (c) shows. On 10.6.0 (the 10.6 branch) and main. On 8.6.0-10.5.2 those stand-alone collectors did not forward the scorer, and the same search throws a NullPointerException instead (a related bug in the same selectors); #16247 (released in 10.6.0) turned that exception into these wrong groups.
  • Default vs opt-in. Grouping by score ranges is opt-in; the GroupingSearch defaults (relevance group sort, includeMaxScore=true) request scores in both passes and are right. Once configured with a trigger, a search is mis-grouped whenever the placeholder falls into a different range from the real scores, as for the repro's boolean query; constant-score queries such as MatchAllDocsQuery are grouped right.
  • Scale. None: five documents in one segment show it, deterministically, with assertions on and off.
  • Effect. Silent wrong groups: one empty group, the right groups without documents, documents missing from their groups, or 1 group or head instead of several. Nothing fails on main with a plain IndexSearcher; totalHitCount stays right while totalGroupedHitCount drops to 0.
  • How likely. Rare: grouping by the score is unusual, the defaults hide it, no use of the range selectors was found in Elasticsearch, OpenSearch or Solr, and no public report was found in the searched sources.
  • Workaround. Add a score tie-break to the group sort, new Sort(new SortField("id", SortField.Type.INT), SortField.FIELD_SCORE) (executed), and keep includeMaxScore=true or a within-group sort that needs scores. For the stand-alone collectors, run them in a MultiCollectorManager next to a collector that needs scores (executed by the fact check: 3 right ranges, heads [0, 1, 2]).

Minimal repro

The test shows that grouping by DoubleRangeGroupSelector(DoubleValuesSource.SCORES, ...) with a field group sort runs the first pass without scores, so every hit falls in the range of score 0.

TestScoreRangeGroupingFieldGroupSort.java (JUnit test)79 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.search.grouping;

import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.ConstantScoreQuery;
import org.apache.lucene.search.DoubleValuesSource;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestScoreRangeGroupingFieldGroupSort extends LuceneTestCase {

  public void testGroupByScoreRangeSortedByField() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc0 = new Document();
        doc0.add(new StringField("tag", "a", Field.Store.NO));
        doc0.add(new NumericDocValuesField("id", 0));
        w.addDocument(doc0);
        Document doc1 = new Document();
        doc1.add(new StringField("tag", "a", Field.Store.NO));
        doc1.add(new StringField("tag", "b", Field.Store.NO));
        doc1.add(new NumericDocValuesField("id", 1));
        w.addDocument(doc1);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query q =
            new BooleanQuery.Builder()
                .add(new ConstantScoreQuery(new TermQuery(new Term("tag", "a"))), Occur.SHOULD)
                .add(new ConstantScoreQuery(new TermQuery(new Term("tag", "b"))), Occur.SHOULD)
                .build();
        GroupingSearch grouping =
            new GroupingSearch(
                () ->
                    new DoubleRangeGroupSelector(
                        DoubleValuesSource.SCORES, new DoubleRangeFactory(0, 1, 10)));
        grouping.setGroupSort(new Sort(new SortField("id", SortField.Type.INT)));
        TopGroups<DoubleRange> groups = grouping.search(new IndexSearcher(reader), q, 0, 10);
        List<DoubleRange> ranges = new ArrayList<>();
        for (GroupDocs<DoubleRange> group : groups.groups) {
          ranges.add(group.groupValue());
        }
        // doc0 scores 1 and doc1 scores 2: expected [1,2) and [2,3); actual one group [0,1)
        assertEquals(List.of(new DoubleRange(1, 2), new DoubleRange(2, 3)), ranges);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-884/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/grouping/build/libs/lucene-grouping-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.grouping.TestScoreRangeGroupingFieldGroupSort
Expected vs actual
Expected groups [DoubleRange(1.0, 2.0), DoubleRange(2.0, 3.0)] for two hits that score 1 and 2; actual [DoubleRange(0.0, 1.0)], with -ea and -da.

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

More files, environment, full output38 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<[DoubleRange(1.0, 2.0), DoubleRange(2.0, 3.0)]> but was:<[DoubleRange(0.0, 1.0)]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.grouping.TestScoreRangeGroupingFieldGroupSort.testGroupByScoreRangeSortedByField(TestScoreRangeGroupingFieldGroupSort.java:75)

Why it looks unintended

  • The selectors take any values source and pass it the collector's scorer on purpose, which only matters for sources that need scores.
  • Lucene's value-source sort passes needsScores() on; a missing pass-through was fixed as a bug (#14654).
  • Group membership changes with the group sort. The "unaffected by relevancy" comment predates score-reading selectors.
The full argument296 words
  • Selector contract. DoubleRangeGroupSelector takes "a DoubleValuesSource to retrieve double values per document" (DoubleRangeGroupSelector.java:46; LongRangeGroupSelector.java:47 the same for longs), with no restriction; the package javadoc lists it as grouping "based on the value of a DoubleValuesSource" (package-info.java:67-70).
  • The bridge serves this case. setScorer binds source.getValues(context, DoubleValuesSource.fromScorer(scorer)). DoubleValuesSource.getValues says callers "may safely pass null" for the scores when needsScores() is false (DoubleValuesSource.java:60-66), so the scorer, which the selectors pass to every source, is needed only by score-dependent sources; the class javadoc documents scores as a value source through fromScorer (:43-46).
  • Score-mode contract. COMPLETE_NO_SCORES: "Produced scorers will allow visiting all matches but scores won't be available" (ScoreMode.java:25-26); Collector.scoreMode(): "Indicates what features are required from the scorer" (Collector.java:55-56).
  • Unrelated settings change membership. setIncludeMaxScore is documented as "Whether to include the score of the most relevant document per group" (GroupingSearch.java:329-338); turning it off empties every group. A group sort by a field merges every hit into [0, 1).
  • Convention and precedent. DoubleValuesSortField.needsScores() returns producer.needsScores() (DoubleValuesSource.java:637-640), which is why a sort by a score-dependent source works in grouping today. #14654 (CHANGES 10.3.0, Bug Fixes) fixed a missing pass-through of the same kind: "would throw errors when used if the DoubleValuesSource needed scores".
  • Tests. No upstream grouping test uses a score-dependent source: TestDoubleRangeGroupSelector and TestLongRangeGroupSelector use field sources, and nothing in lucene/grouping/src/test uses SCORES or fromScorer. The case is untested, not decided.
  • History. LUCENE-6912 (2015) wrote the sort-based score modes and the "unaffected by relevancy" comment, when no selector could read a scorer. LUCENE-7889 (2020) added setScorer and the range selectors with the score bridge and changed no score mode; its PR has no review comments and its discussion never mentions scores.
The devil's-advocate check254 words

The strongest case for "intended" (built by the devil's-advocate checker and the other-family reviews; the reviews rate it moderate, the devil's advocate gives no rating): AllGroupsCollector states "the result is unaffected by relevancy"; GroupSelector never had a way to ask for scores, its javadoc says nothing about score availability (GroupSelector.java:25-33), and ValueSourceGroupSelector.setScorer is empty, so the module may never have meant group keys to depend on scores; the defaults work; a score tie-break is a workaround; the tests use field sources only; the collectors and GroupingSearch are marked @lucene.experimental, which leaves maintainers room to narrow the contract; and LUCENE-7889's commit message gives numeric-valued fields as the motivation.

Why it fails: the comment predates score-reading selectors by 4.5 years and was not revisited when they were added; the fromScorer bridge is needed only by score-dependent sources, so wiring it and never requesting scores looks like an oversight; no javadoc restricts the source, SCORES is public and documented, and the numeric-field motivation says why the feature was added, not what it excludes; and the groups change with unrelated settings, which no contract can intend. Verdict: most likely not intended, a defect born with the feature; a javadoc restriction remains a legitimate maintainer alternative. The fact checks read it the same way. The expected behaviour is inferred from the API's shape and the needsScores convention, not quoted from a grouping javadoc, so a maintainer may prefer to document a restriction. Not security: wrong groups through correct use of public APIs, chosen by the application.

Suggested fix

Recommended Let a GroupSelector declare that it needs scores

Add GroupSelector#needsScores() (default false), return source.needsScores() from the two range selectors, and OR it into the score modes of the first-pass, second-pass, all-groups and all-group-heads collectors (+26/-4 in 7 files). The whole grouping module passes, the repros included, with assertions on and off, and its static checks pass.

All options and trade-offs3 options · 486 words

One code fix was built and run in a scratch worktree at a9779ef3c193; the javadoc alternative was not run. Both are legitimate.

Let a GroupSelector declare that it needs scores (validated by the checks) (recommended)

The finder's fix, formatted with gradlew tidy: a non-abstract GroupSelector#needsScores() returning false; DoubleRangeGroupSelector and LongRangeGroupSelector override it with source.needsScores(); FirstPassGroupingCollector, SecondPassGroupingCollector (so also TopGroupsCollector and DistinctValuesCollector), AllGroupsCollector and AllGroupHeadsCollector OR it into their score modes. +26/-4 in 7 files.

// GroupSelector
public boolean needsScores() {
  return false;
}
// DoubleRangeGroupSelector, LongRangeGroupSelector
@Override
public boolean needsScores() {
  return source.needsScores();
}
// FirstPassGroupingCollector
this.needsScores = groupSort.needsScores() || groupSelector.needsScores();
// SecondPassGroupingCollector (AllGroupHeadsCollector: sort.needsScores(); AllGroupsCollector: the selector alone)
return groupReducer.needsScores() || groupSelector.needsScores()
    ? ScoreMode.COMPLETE
    : ScoreMode.COMPLETE_NO_SCORES;

Trade-offs

  • A new public, non-abstract method on a public abstract class: additive and source-compatible. A custom GroupSelector that reads scores must override it; say so in the javadoc and in CHANGES.
  • A score-dependent selector now makes the query compute scores: a cost only for those users, and what they need for a correct result. Score-independent selectors are unchanged.
  • It follows the existing convention (DoubleValuesSortField.needsScores() returns producer.needsScores()).
  • It drops the "the result is unaffected by relevancy" comment, which could stay as "unless the selector reads scores".
  • Out of scope (a related bug in the same selectors): the score-less cache replay (setCaching(n, false)) still cannot serve a score-dependent selector, and DistinctValuesCollector's value selector never gets setScorer.

What the checks validated

Gradle in a scratch worktree at a9779ef3c193, seed DEADBEEF: the whole :lucene:grouping:test with the 5 repro classes added, 19 classes, 82 tests, 0 failures, with assertions on and off; all 19 repro tests that fail on stock pass. :lucene:grouping:check -x test with the fix and the primary test (google-java-format, ecj lint, forbidden APIs): BUILD SUCCESSFUL. The repro runner's re-runs pass with the fix shadowed too (the finder's test 4 of 4, the reproducer's 13 of 13, the probe on main); those used the finder's untidied copy of the same change, which differs only in line wrapping. An independent fact check repeated the primary test with the fix and the static checks in a fresh scratch worktree: 7 of 7 pass, BUILD SUCCESSFUL. The brief's own scratch worktree was deleted and its HEAD was not logged. Not run: the full Lucene test suite, benchmarks.

Document the restriction instead

Say in the javadoc of both range selectors that the source must not need scores unless a sort that needs scores is used, and correct the AllGroupsCollector comment. Not run.

Trade-offs

The smallest change and a legitimate maintainer choice, but a documented public input keeps giving a silent wrong answer, and the selectors' fromScorer bridge would then serve only passes whose sort or reducer already requests scores.

Reject score-dependent sources in the selectors' constructors

Throw IllegalArgumentException when source.needsScores() is true. Not run.

Trade-offs

Breaks configurations that work today (relevance sorts); not recommended.

Downstream note

Only grouping output changes (no exception, nothing extra returned or exposed), so a downstream product is affected only if it offers grouping by score ranges together with a field sort or the stand-alone group collectors.

How it was found

A bytecode scan of 20 Lucene jars listed every non-scoring score mode that reaches a consumer and every bridge that feeds scores into a values source (561 rows). The range selectors' bridges sat next to grouping collectors that never ask for scores; reading them gave the lead and a public-API test confirmed it.

The method in full183 words

Method: static bytecode inventory plus code reading, then a public-API test (the hunt's "semi-formal" tier; the lead came from reading rows of a mechanical inventory, which a strict reading could put in the non-formal tier).

  • The hunt unit looked for weights or collectors that run without scores while something reads scores. It scanned the class files of 20 non-test jars with the JDK 25 class-file API: 561 rows, among them every site where COMPLETE_NO_SCORES or a needsScores()-conditional mode reaches a consumer and every DoubleValuesSource.fromScorer bridge. Each non-test row was triaged by reading.
  • The bridge rows DoubleRangeGroupSelector.setScorer and LongRangeGroupSelector.setScorer sat next to grouping collectors whose scoreMode() is COMPLETE_NO_SCORES unless a sort or reducer needs scores. The finder wrote the public-API JUnit test, which failed 3 of 4 with assertions on and off.
  • A secondary dynamic sweep ran 5,696 module compositions under the test framework's asserting searcher; its only new scorer-protocol sites were this bug's first-pass and second-pass collectors, including the second-pass-only trigger. A planted non-scoring collector that reads scores fired as a positive control.
  • Independent checks then reproduced it (see the checks below).

Prior reports, history and checks

Prior reports

No prior report found: 127 queries over GitHub issues, PRs and commits, the mailing lists, JIRA, downstream trackers and the web, plus the file lists of all 410 open PRs (2026-10-28). Every search for the two selector names returns nothing; two open PRs rewrite the first-pass score mode and would keep the bug.

Searches3 searches

Registry verdict new: no report or discussion of this bug by anyone else was found. Nearest items, none a report: #8940 / apache/lucene-solr#1484 (LUCENE-7889, the feature; no discussion of scores), LUCENE-6912 (#7970, the 2015 sort-only score modes and the comment), #16247 (merged 2026-07-08; scorer forwarding for the related null-values bug, score modes unchanged), open PRs #15210 and #15494 (rewrite FirstPassGroupingCollector.scoreMode(), still from the group sort only), #16569, #16548 and #16847 (touch the same collectors without consulting the selector), #14654 and SOLR-9494 (the same class of defect fixed elsewhere). Limits: GitHub search returns 50 results per query, which cut two generic one-word queries (every class-name query came back complete); loose mailing-list matches were read by title; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; downstream trackers; web2026-10-28a scripted prior-report search, 70 queries (DoubleRangeGroupSelector, LongRangeGroupSelector, GroupSelector needsScores / scoreMode, grouping by score range, AllGroupsCollector scoreMode); 1,958 distinct hits, 0 high, 24 medium; every medium hit and every narrow-query hit readno report
GitHub (apache/lucene, apache/solr, all of GitHub), JIRA, lists.apache.org, web2026-10-2857 manual searches and reads, refreshed; the finder's 45 queries0 issues or PRs naming either selector anywhere; #240 (unrelated) for AllGroupsCollector scoreMode
apache/lucene open PRs and recent activity2026-10-28the changed-file lists of all 410 open PRs (the grouping diffs of five read); issues and PRs created since 2026-10-26; grouping commits since 2026-09-0113 PRs touch grouping, ScoreMode or values sources; none changes a collector's score need for the selector

History

Born with the feature: LUCENE-7889 (7c350d22c70, backport 5e62c42c5f3, 2020-05-11) added the range selectors and their score bridge but no way to ask for scores; first released in 8.6.0. All 49 releases from 8.6.0 to 10.5.2, 10.6.0 (the 10.6 branch at 4ae946e5a9b) and main fail the same probe.

Versions tested7 rows

Introduced by LUCENE-7889 "Allow grouping on Double/LongValuesSource" (apache/lucene-solr#1484, JIRA mirror #8940), by @romseygeek, author date 2020-05-11: 7c350d22c7070acf362fb4f477e6797f2711c910 on the master line (first release 9.0.0) and 5e62c42c5f304eee7b5695f97f02a2aea7d00366 on branch_8x (first released in 8.6.0). It added GroupSelector.setScorer and the two range selectors with the fromScorer bridge, and changed no collector's scoreMode(); those lines come from LUCENE-6912 (2015), LUCENE-4100 (2017) and LUCENE-8412 (2018) and were complete when written, because no selector read a scorer before. Not a regression. #16247 (2026-07-08, released in 10.6.0) made the stand-alone AllGroupsCollector and AllGroupHeadsCollector forward setScorer: there the related bug's NullPointerException (8.6.0-10.5.2) became these silent wrong groups (10.6.0, main). Upstream main c5ab3e57b9b (2026-10-28) has no change at the fix site.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected5 repro classes through gradle: 19 of 26 tests fail, assertions on and off
branch_10_6 at 4ae946e5a9b (2026-10-26) affectedthe finder's test fails 3 of 4, assertions on and off; AllGroups alone silently returns [DoubleRange(0.0, 1.0)]
10.5.2, 10.0.0, 9.12.3, 9.0.0, 8.11.4 affectedthe finder's API-adapted test (assertions on; also off on 10.5.2, 9.12.3, 8.11.4): field sort gives {DoubleRange(0.0, 1.0)=[]}; AllGroups alone throws the related NullPointerException
8.6.0 first releasethe same, assertions on and off
all 49 releases 8.6.0-10.5.2 affecteda version-neutral main() probe fails 4 of 4 checks with identical result lines (assertions on; also off on 10.5.2)
8.5.2 no APIthe range selectors do not exist yet (the repros do not compile)
fix on a9779ef3c193 not affectedthe whole grouping module, repros included: 82 tests, 0 failures, assertions on and off

Independent checks

An independent reproducer (its own 12-document test), the test written by the step that drove a reading-only review from another model family, an intended-behaviour devil's advocate and an adjudication all reproduced it on main with assertions on and off; the fix makes every repro pass. No other-family reviewer executed code.

Checks and verdicts575 words

Each check read or ran on its own; the registration check and an audit re-ran the finder's test with fresh seeds, and this doc re-ran all five repro classes through gradle on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder (bytecode inventory, code reading, JUnit, module sweep) candidateJUnit fails 3 of 4 on main (-ea, -da) and branch_10_6; wrong groups on 10.5.2, 9.12.3, 8.11.4 and 8.6.0; the fix passes 4 of 4 and keeps the upstream grouping tests green.
cross-model review (reading only) real, highAn independent reviewer from another model family (OpenAI GPT-5.6) read the code and the executed outputs: mechanism agrees, fix fixes it, intended-behaviour case moderate. An earlier refute-lens attempt could not start any command and returned a procedural "not real".
independent reproducer realOwn test from the claim: 12 documents with real BM25-based scores 10.8 to 16.8; the selector reads 0.0 for all 12 under an id sort; 7 of 9 fail on main (-ea, -da), its GroupingSearch test 3 of 4; reproduces on 10.5.2 and 9.12.3; 13 of 13 pass with the fix.
cross-model review, intended lens real, high (reading only)The other-family reviewer could only read code: a first attempt returned real, low because no command could start; the reading-only second attempt returned real, high. A separate run ran its own probe test (boosts 1, 2, 4: fails 3 of 5 on main) and re-ran the finder's test (3 of 4 fail, -ea and -da).
intended-behaviour devil's advocate real, highBuilt the strongest "intended" case (the 2015 comment, no hook, defaults work) and rejected it; found the includeMaxScore=false trigger (three right ranges, all empty), fails 1 of 2 on main (-ea, -da); re-ran the finder's 10.5.2 test.
adjudication confirmed, highRe-ran the reproducer's test with a new seed: 7 of 9 fail with -ea and -da. Duplicate of none; related to the null-values bug in the same selectors.
registry re-runs and audit keepThe finder's test fails 3 of 4 on main with -ea and -da (three fresh seeds), passes 4 of 4 with the fix shadowed.
this doc's gradle runs stock fails, fix passesStock main: 19 of 26 repro tests fail with assertions on and off, every control passes. Fix: the whole grouping module, 82 tests, 0 failures, on and off; static checks pass.
independent fact check (executed) claim stands; wording fixedRe-ran the primary test through gradle in a fresh scratch worktree at a9779ef3c193 (5 of 7 fail, assertions on and off; 7 of 7 pass with the fix; static checks pass) and ran its own public-API probe: every trigger and number confirmed; constant-score queries such as MatchAllDocsQuery are grouped right; the MultiCollectorManager workaround works. Its four wording fixes are applied.
fact check by the other-family reviewer (reading only) yes, with fixesAn independent reviewer from another model family (OpenAI GPT-5.6) read the code, the logs and this doc: the defect, the repros, the collector paths, the introducing change and the fix hold up. Its wording fixes (per-pass score needs, where no exception is thrown, release coverage, prior-art scope) are applied; it judges the evidence to favour a defect, with a javadoc restriction as a legitimate alternative.
diagram check passEvery code panel, value and claim on screen matches the code at a9779ef3c193 and the probe logs; the title was later reworded (placeholder score, per pass) and re-rendered.

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

Finding record

LFV-884, snapshot of 2026-11-06

Registry title
Grouping collectors never ask for scores on behalf of their group selector, so DoubleRangeGroupSelector or LongRangeGroupSelector over a score-dependent source (e.g. DoubleValuesSource.SCORES) groups every hit by the placeholder score of a non-scoring search (0 in every repro) when no sort or reducer needs scores, or when AllGroups/AllGroupHeads runs alone (since 8.6.0)
Area
grouping / GroupSelector score needs (FirstPass, SecondPass/TopGroups, AllGroups, AllGroupHeads collector score modes)
Priority
Low, rank 327: Needs uncommon score-range grouping with a non-scoring sort, and GroupingSearch defaults are unaffected.
Impact class
silent wrong results
Impact note
Applications that group by DoubleRangeGroupSelector or LongRangeGroupSelector over a values source that needs scores (for example DoubleValuesSource.SCORES) get silently wrong groups, with no exception under -ea or -da, at any index size (5 documents suffice), in every release from 8.6.0 to 10.6.0 and on main, whenever (a) the group sort does not need scores, (b) includeMaxScore=false and the within-group sort does not need scores (even with the default relevance group sort), or (c) AllGroupsCollectorManager, or AllGroupHeadsCollectorManager with a sort that does not need scores, runs on its own (on 10.6/main; on 8.6.0-10.5.2 that path throws LFV-414's NullPointerException instead); GroupingSearch's defaults (relevance group sort, includeMaxScore=true) are not affected, and a sort that needs scores (for example a score tie-break) is a workaround.
Summary
You group hits into score ranges, for example with new DoubleRangeGroupSelector(DoubleValuesSource.SCORES, new DoubleRangeFactory(0, 1, 10)), and sort the groups by a field, expecting each hit in the range of its own score, which is what the same search returns with the default relevance group sort (three ranges, [1,2) = {0, 3}, [2,3) = {1}, [3,4) = {2, 4}, in the repro). Instead GroupingSearch returns one empty group [0,1) (LongRangeGroupSelector: LongRange(0, 1)), and AllGroupsCollectorManager on its own reports one range instead of three, because the grouping collectors take their ScoreMode only from their sorts or reducer, so the query runs without scores and the selector, which reads the collector's Scorable through DoubleValuesSource.fromScorer, sees the placeholder score 0 for every hit. GroupSelector has no way to say that it needs scores; a GroupSelector#needsScores() hook (true for the range selectors when their source needs scores), ORed into the four collectors' score modes, fixes it.
Introduced
7c350d22c7070acf362fb4f477e6797f2711c910 (master; branch_8x backport 5e62c42c5f304eee7b5695f97f02a2aea7d00366), LUCENE-7889 'Allow grouping on Double/LongValuesSource' (apache/lucene-solr#1484 by romseygeek, merged 2020-05-11 with no review comments; JIRA mirror apache/lucene#8940) [read: git blame -w and git log -L]: it added the abstract GroupSelector.setScorer and the two range selectors, whose setScorer binds source.getValues(context, DoubleValuesSource.fromScorer(scorer)), but gave GroupSelector no way to request scores and changed no collector's scoreMode(). The score-mode lines are older and were complete when written, because no selector read a scorer before (LUCENE-6912 31b6cd6851c, 2015; LUCENE-4100 4fc5a872ded, 2017; LUCENE-8412 55bfadbce11, 2018). Not a regression: the bug was born with the feature. (2020-05-11 (author date 17:34:01+01:00 for both the master and the branch_8x commit); first released in 8.6.0)
First affected version
8.6.0 [exec] (the first release with the range selectors; git tag --contains on the branch_8x backport starts at 8.6.0, on the master commit at 9.0.0); 8.5.2 has no range selectors (both repros fail to compile there [exec]), so no earlier release works correctly with this API
Affected versions
every release that has the range selectors: all 49 releases 8.6.0-8.11.4, 9.0.0-9.12.3 and 10.0.0-10.5.2 [exec: a version-neutral public-API probe, main() without the test framework, fails 4 of 4 checks with identical output on all of them], plus branch_10_6 4ae946e5a9b (10.6.0, released 2026-10-29) and main a9779ef3c193 [exec]. The finder's JUnit fails with wrong groups on 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (-ea; -da as well on 8.6.0 and 10.5.2) and 3 of 4 on branch_10_6 and main (-ea and -da). The standalone AllGroups/AllGroupHeads path shows this bug only since apache/lucene#16247 (10.6.0, released 2026-10-29, and main); on 8.6.0-10.5.2 it throws LFV-414's NullPointerException instead. With the candidate fix shadowed over main the probe and the JUnit pass. Not fixed on upstream main c5ab3e57b9b (2026-10-28).
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
semi-formal: code reading
Upstream and prior reports
none found (the prior-report record verdict new, searched 2026-10-28, refreshed 2026-10-28, read-only): no issue, PR, review comment, commit message, JIRA entry, mailing-list message, downstream report or web page reports grouping collectors ignoring a group selector's score needs or score-range grouping putting every hit into the score-0 range. Searched: 127 queries (70 scripted prior-report search: 30 GitHub issue/PR, 6 commit, 4 downstream-repository, 20 over the dev@, java-user@, issues@ and commits@ lists, 4 JIRA, plus web; 1,958 distinct hits, 0 high, 24 medium, all read; 57 manual searches and reads), the finder's 45 queries, and the changed files of all 410 open apache/lucene PRs (13 touch lucene/grouping, ScoreMode.java or the values sources; none changes a collector's score need for the selector). Every query naming DoubleRangeGroupSelector or LongRangeGroupSelector returns 0 on GitHub (apache/lucene, apache/solr, all of GitHub), JIRA and the lists. Refresh for this row: GitHub issue/PR search for DoubleRangeGroupSelector, LongRangeGroupSelector, 'GroupSelector needsScores', 'GroupSelector scoreMode', 'grouping score range selector' (0 each) and 'AllGroupsCollector scoreMode' (1, #240, unrelated); the 22 issues and PRs created since 2026-10-26 (none on grouping); PRs updated since 2026-10-26 that mention grouping (only #16731, HNSW); upstream lucene/grouping commits since 2026-09-01 by author date: d148795b182 (#16292, 2026-10-06) and 3e4a7a02ce0 (#16779, 2026-10-07), both facet collectors. Context, not reports: #8940 / apache/lucene-solr#1484 (LUCENE-7889, the feature; no discussion of scores), LUCENE-6912 (#7970, origin of the sort-only score modes and of 'the result is unaffected by relevancy', 2015), #16247 (merged 2026-07-08; added setScorer forwarding to AllGroups and AllGroupHeads for the LFV-414 mechanism, score modes unchanged), open PRs #15210 and #15494 (rewrite FirstPassGroupingCollector.scoreMode() but still derive it from the group sort only, so they would keep the bug), #16569 and #16548 (TopGroupsCollector, selector not consulted), #14654 and SOLR-9494 (the same class of defect fixed in other consumers). Not fixed upstream: upstream main c5ab3e57b9b (2026-10-28) has no grouping change at the fix site.
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree: the 5 repro classes on stock with assertions on and off; the fix on the whole grouping module with assertions on and off, and the module's static checks; the repro runner: the finder's test, the independent reproducer's, the test written and run by the step that drove the cross-model review (the other-family model ran no code), the devil's-advocate probe, the adjudication's, the registry and audit re-runs, several seeds, assertions on and off; an independent fact check re-ran the primary test and the fix through gradle in a fresh scratch worktree, plus its own probe); branch_10_6 at 4ae946e5a9b (2026-10-26); a version-neutral public-API probe on the release jars of all 49 releases 8.6.0-10.5.2 (assertions on; also off on 10.5.2), and the finder's API-adapted test on 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (assertions on; also off on 8.6.0, 8.11.4, 9.12.3 and 10.5.2); 8.5.2 (API absent). JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeGroupSelector.java:44-49 (constructor javadoc: 'a DoubleValuesSource to retrieve double values per document'), :61-63 (setScorer binds source.getValues(context, DoubleValuesSource.fromScorer(scorer)))
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRangeGroupSelector.java:63 (same bridge)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupSelector.java:45-46 ('Set the current Scorer'; the class has no needsScores())
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/FirstPassGroupingCollector.java:105 (needsScores = groupSort.needsScores()), :126 (COMPLETE_NO_SCORES otherwise)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/AllGroupsCollector.java:93 (always COMPLETE_NO_SCORES, comment 'the result is unaffected by relevancy')
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/AllGroupHeadsCollector.java:121 (sort.needsScores() only)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/SecondPassGroupingCollector.java:78 (groupReducer.needsScores() only) and TopGroupsCollector.java:128 (getMaxScores || withinGroupSort.needsScores())
  • lucene/core/src/java/org/apache/lucene/search/ScoreMode.java:25-26 (COMPLETE_NO_SCORES: 'scores won't be available')
  • lucene/core/src/java/org/apache/lucene/search/TermQuery.java:77-91 (non-scoring TermWeight uses a dummy SimScorer that returns 0)
  • lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:44 (scores through fromScorer), :356 (SCORES), :479 (fromScorer)
The finding in 30 seconds
  • a Lucene main checkout lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeGroupSelector.java:60-75; LongRangeGroupSelector.java:61-64; GroupSelector.java:34-72
  • a Lucene main checkout lucene/grouping/src/java/org/apache/lucene/search/grouping/FirstPassGroupingCollector.java:105, 124-127; SecondPassGroupingCollector.java:76-90; TopGroupsCollector.java:127-128; AllGroupsCollector.java:72-94; AllGroupHeadsCollector.java:119-140
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/ScoreMode.java:25-26; TermQuery.java:77-91; ConstantScoreQuery.java:141-146, 202-203; MatchAllDocsQuery.java:40, 48; PointRangeQuery.java:132; DoubleValuesSource.java:349-368, 479-491
Who is affected
  • a Lucene main checkout lucene/expressions/src/java/org/apache/lucene/expressions/SimpleBindings.java:35; ExpressionValueSource.java:43-53
  • a Lucene main checkout lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:51-56 (defaults)
Why it looks unintended
  • a Lucene main checkout lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeGroupSelector.java:46, 60-63; LongRangeGroupSelector.java:47; package-info.java:67-70; GroupingSearch.java:44, 329-338; AllGroupsCollector.java:32, 91-94; GroupSelector.java:25-33; FirstPassGroupingCollector.java:41; SecondPassGroupingCollector.java:33; AllGroupHeadsCollector.java:39
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:43-46, 60-66, 637-640; ScoreMode.java:25-26; Collector.java:55-56
  • a Lucene main checkout lucene/CHANGES.txt:1743 (#14654), :5710 (LUCENE-7889), :9732 (LUCENE-6912)
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:637-640

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

Repro files

About the diagrams

A step-by-step picture of the repro's example, made in parallel with this doc from an executed probe on main (the probe also sorts within groups by id and sets setAllGroups(true), hence its totalGroupCount). The PNG is the final summary frame.

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.