All findings · Lucene finding · LFV-232 · topgroupscollector-relevance-identity-check

Grouping treats new Sort() differently from Sort.RELEVANCE: wrong top documents per group across slices or shards, and a NaN maxScore

Grouping with new Sort() or new Sort(SortField.FIELD_SCORE) as the within-group sort, both equal to Sort.RELEVANCE, collects hits without scores but merges slices and shards by score. With 2 slices a group shows its score-5 and score-1 documents instead of 5 and 4; since 10.5.0 maxScore is NaN.

Summary

Priority
Medium, rank 47A within-group sort that equals but is not the Sort.RELEVANCE constant (for example a request-built new Sort(SortField.FIELD_SCORE)) silently returns the wrong documents per group whenever slices or shards are merged (hand-sharded TopGroups.merge since 8.0.0, the collector managers with an executor since 10.5.0), plus a NaN maxScore since 10.5.0, a natural trigger for direct grouping users, although the default constant is correct and Solr, Elasticsearch and OpenSearch do not reach it.
Impact
silent wrong results
Since
7.0.0 (LUCENE-7701, 2017); wider in 8.0.0, 10.5.0 and on mainintroduced 2017-03-28 · about 9 years
Affected
7.0.0 to 10.5.2 and main, by path (see Version history); 10.6.0 very likely too (read, not run)
Status
documented
Found by
code reading, confirmed by a bounded-exhaustive grouping check

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

Grouping decides twice whether the within-group sort means "by relevance": the collector compares with the Sort.RELEVANCE object (==), the merge with equals. An equal copy is therefore collected as a field sort, with no scores, and merged as a score sort.

Two-pass grouping of group g on a searcher with two slices. Slice 0 collects doc 1 (score 5) and doc 2 (score 1), slice 1 collects doc 0 (score 4). With the within-group sort new Sort(SortField.FIELD_SCORE) the collector is chosen by == Sort.RELEVANCE, so the hits are field-sorted and carry score NaN; TopGroups.merge checks equals, merges them by score, every NaN comparison is false and the slice order decides: the page is doc 1, doc 2 and maxScore is NaN. With the Sort.RELEVANCE constant the page is doc 1, doc 0 and maxScore is 5.0.
One group split over two slices: slice 0 holds scores 5 and 1, slice 1 holds score 4. Collected with new Sort(SortField.FIELD_SCORE), every hit carries score NaN, so the merge cannot compare them and takes slice 0 first: 5, 1 instead of 5, 4, and maxScore NaN. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 11 documents in one group over 6 segments: doc 0 scores 4, doc 1 scores 5, the other nine score 1. With an executor, default slicing (by segment size; the pool size does not matter) puts doc 0's segment alone in slice 1 and the other five segments in slice 0.
  2. Run GroupingSearch("group") with setGroupDocsLimit(2), setIncludeMaxScore(true) and setSortWithinGroup(new Sort(SortField.FIELD_SCORE)). You expect what the equal Sort.RELEVANCE returns: docs [1, 0] (scores 5 and 4) and maxScore 5.0.
  3. Instead you get docs [1, 2]: doc 2 (score 1) replaces doc 0 (score 4). The collector checks withinGroupSort == Sort.RELEVANCE (the same object), so it uses the field-sort collector, whose hits carry score NaN.
  4. The merge of the two slices checks equals, so it orders those hits by score. Every comparison with NaN is false, so slice 0's hits simply come first. Since 10.5.0 it also takes maxScore from the first hit: NaN, on main even without an executor.
  5. No error, with assertions on or off. Sort.RELEVANCE itself returns [1, 0] and 5.0 on every path. Since 8.0.0 a hand-sharded TopGroups.merge also takes shard 0's hits first, wrong whenever a later shard holds a better hit.

Who is affected

Direct users of Lucene grouping (GroupingSearch, the grouping collectors and managers, TopGroups.merge) who build the within-group relevance sort as a new object, such as a request builder mapping "score" to new Sort(SortField.FIELD_SCORE). Wrong pages need 2+ slices or shards, with a better hit in a later one; NaN maxScore needs 10.5.0+. The default constant is unaffected.

Mechanism, terms and numbers731 words

Sort.RELEVANCE is new Sort(), and new Sort(), new Sort(SortField.FIELD_SCORE) and the constant are all equal. Two-pass grouping does not treat them alike: the second pass, TopGroupsCollector, picks its per-group collector with withinGroupSort == Sort.RELEVANCE (object identity), so an equal copy is collected by the field-sort collector, whose hits carry score NaN, while TopGroups.merge checks equals and merges those hits by score. Every comparison with NaN is false, so the merged page is slice 0's hits, then slice 1's: a group shows docs [1, 2] (scores 5 and 1) instead of [1, 0] (scores 5 and 4). Since 10.5.0 the merge also takes maxScore from the first hit's NaN score. With 10.5.0's collector managers and main's GroupingSearch, a searcher with an executor and 2 or more slices merges the pages slice by slice, wrong whenever a later slice holds a better hit, and maxScore is NaN even with one slice; a hand-sharded TopGroups.merge has merged shard by shard since 8.0.0. Nothing throws, and the Sort.RELEVANCE constant is correct everywhere. A one-line fix (equals instead of ==) passes the grouping module's tests.

Terms

  • Two-pass grouping: the first pass finds the top groups; the second pass (TopGroupsCollector) collects the top documents inside each of them, ordered by the within-group sort (GroupingSearch.setSortWithinGroup, default Sort.RELEVANCE).
  • Slices: an IndexSearcher with an executor runs one collector per slice; the collector manager merges their results in reduce. Default slicing sorts the segments by size, largest first, and closes a slice once it holds 5 segments or more than 250,000 documents; a segment of more than 250,000 documents gets a slice of its own (IndexSearcher.java:106-108, :350-379). The slices do not depend on the executor's pool size.
  • Equal sorts: Sort.RELEVANCE is new Sort() (Sort.java:41), Sort() is this(SortField.FIELD_SCORE) (:54-56), and equals compares the fields (:114-119).

The bug

  1. The TopGroupsCollector.TopDocsReducer constructor (TopGroupsCollector.java:127-148) tests if (withinGroupSort == Sort.RELEVANCE) (:129). Only the constant gets a TopScoreDocCollector (:130-136); an equal copy gets a TopFieldCollector plus a MaxScoreCollector (:137-146).
  2. Field-collected hits are created with score NaN (FieldValueHitQueue.java:36-38); the score lives only in FieldDoc.fields[0]. Within one slice the order and the slice's maxScore are still right: 5.0 for slice 0, 4.0 for slice 1.
  3. TopGroups.merge tests docSort.equals(Sort.RELEVANCE) (TopGroups.java:183), true here, wraps the hits as plain TopDocs (:218-220) and merges them by ScoreDoc.score (:241).
  4. With NaN, score < score and score > score are both false, so TopDocs.merge falls back to its tie-break, shard index then doc id (TopDocs.java:44-45, :110-120): slice 0's hits come first, [doc 1, doc 2], and doc 0 is cut by the page size 2.
  5. Since #15879 (10.5.0) the merge sets maxScore = scoreDocs[0].score for a relevance sort (TopGroups.java:244-245), NaN here, and skips the per-slice values it uses for other sorts (:231-233).
  6. TopGroupsCollectorManager.reduce always calls TopGroups.merge, even for one collector (TopGroupsCollectorManager.java:112-123), and on main GroupingSearch runs its second pass through it (GroupingSearch.java:197-212). One slice: right page, NaN maxScore. Two or more slices, or a hand-sharded merge: the inputs' pages one after another, so the page is also wrong whenever a later input holds a better hit, as doc 0 does here.

Line 129 is the only == comparison with a Sort constant in Lucene's main code; every other relevance check in the grouping module uses equals (AllGroupHeadsCollector.java:63, AllGroupHeadsCollectorManager.java:146, BlockGroupingCollector.java:275, :285, TopGroups.java:183, :405).

The numbers

Index: 6 segments, NoMergePolicy, one group; segment 0 = doc 0 (score 4), segment 1 = doc 1 (5) and doc 2 (1), segments 2-5 = docs 3-10 (1 each). An executor (the test uses 2 threads; any pool size gives the same slices) gives slice 0 = segments 1-5 and slice 1 = segment 0.

within-group sortsearcherdocsmaxScore
Sort.RELEVANCEsequential or executor[1, 0]5.0
new Sort(SortField.FIELD_SCORE) or new Sort()executor (2 slices)[1, 2]NaN
new Sort(SortField.FIELD_SCORE) or new Sort()sequential[1, 0]NaN

Inside the merge (executor, new Sort(SortField.FIELD_SCORE)): slice 0 = [doc 1 (FieldDoc, score NaN, sort value 5.0), doc 2 (NaN, 1.0)], maxScore 5.0; slice 1 = [doc 0 (NaN, 4.0)], maxScore 4.0; merged = [doc 1, doc 2], maxScore NaN.

Exposure and likelihood307 words
  • Who is exposed. Applications that use the grouping module directly and pass an equal copy of Sort.RELEVANCE as the within-group sort, for example a sort builder that turns a requested "score" key into new Sort(SortField.FIELD_SCORE).
  • Which paths, from which release (executed on the releases under Version history; the tags in between by code reading). A wrong page needs a better hit in a later slice or shard:
    • hand-sharded TopGroups.merge, the way the package docs describe sharding ("Sharding is not directly supported, though is not too difficult, if you can merge the top groups and top documents per group yourself", package-info.java:76-77): wrong page since 8.0.0 (7.x only with scores and max scores off), NaN maxScore since 10.5.0;
    • the grouping collector managers on a searcher with an executor and 2 or more slices: wrong page since 10.5.0; NaN maxScore since 10.5.0 even with one slice;
    • GroupingSearch itself: main and, by reading, 10.6.0 (#16247), wrong page with an executor and NaN maxScore even sequentially.
  • Default or opt-in. Opt-in: the default within-group sort is the constant, which is correct on every path. Nothing else is needed: default codec, config and slicing.
  • Scale. None: 11 documents in the test; the bounded-exhaustive check saw wrong pages at 2 documents, one per slice or shard. Deterministic.
  • What you see. No error, the same with assertions on and off. Groups, totals and counts are right; only which matching documents a group shows, their order, and maxScore change. Comparisons with NaN are false, so a rule that drops groups with maxScore < threshold keeps every group, and one that keeps groups with maxScore >= threshold drops every group.
  • Downstream (read, not run). Solr normalizes a plain score desc to the constant; Elasticsearch and OpenSearch do not use TopGroupsCollector. That fits the absence of reports since 2017, and says nothing about private code.

Minimal repro

The test shows that with a within-group sort new Sort(SortField.FIELD_SCORE), which equals Sort.RELEVANCE but is another instance, TopGroups.merge over two shards puts the wrong document at the top of the group, because TopGroupsCollector tests == Sort.RELEVANCE and collects score-NaN field hits.

TestEqualRelevanceSortShardMerge.java (JUnit test)68 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.FeatureField;
import org.apache.lucene.document.SortedDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.index.NoMergePolicy;
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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestEqualRelevanceSortShardMerge extends LuceneTestCase {

  public void testMergedGroupKeepsBestHit() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) {
      for (float score : new float[] {1, 4}) { // one segment per document, each searched as a shard
        Document doc = new Document();
        doc.add(new SortedDocValuesField("group", new BytesRef("g")));
        doc.add(new FeatureField("features", "score", score));
        w.addDocument(doc);
        w.flush();
      }
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        Query query = FeatureField.newLinearQuery("features", "score", 1f);
        Sort withinGroupSort = new Sort(SortField.FIELD_SCORE); // equals Sort.RELEVANCE
        GroupingSearch grouping =
            new GroupingSearch("group").setSortWithinGroup(withinGroupSort).setGroupDocsLimit(1);
        List<TopGroups<BytesRef>> shards = new ArrayList<>();
        for (LeafReaderContext leaf : reader.leaves()) {
          shards.add(grouping.search(new IndexSearcher(leaf.reader()), query, 0, 10));
        }
        TopGroups<BytesRef> merged =
            TopGroups.merge(
                shards, Sort.RELEVANCE, withinGroupSort, 0, 1, TopGroups.ScoreMergeMode.None);
        // expected: group g's top hit is shard 1's score-4 document; actual: shard 0's score-1 document
        assertEquals("shard of group g's top hit", 1, merged.groups[0].scoreDocs()[0].shardIndex);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-232/, 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.TestEqualRelevanceSortShardMerge
Expected vs actual
Expected: group g's top hit is shard 1's score-4 document; actual: shard of group g's top hit expected:<1> but was:<0> (shard 0's score-1 document).

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

More files, environment, full output39 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: shard of group g's top hit expected:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.search.grouping.TestEqualRelevanceSortShardMerge.testMergedGroupKeepsBestHit(TestEqualRelevanceSortShardMerge.java:64)

Why it looks unintended

  • Sort.RELEVANCE is new Sort(), Sort() is Sort(SortField.FIELD_SCORE), and Sort.equals compares fields; setSortWithinGroup accepts any Sort.
  • Every other relevance check in the grouping module, TopGroups.merge included, uses equals; GroupDocs.maxScore is documented as "Max score in this group".
  • A 2017 refactor (LUCENE-7701) replaced an equals check with == without comment, while its migration note promised the old behaviour.
The full argument261 words
  • Contracts. Sort class javadoc: "A Sort can be created with an empty constructor, yielding an object that will instruct searches to return their hits sorted by relevance" (Sort.java:27-29); Sort() "Sorts by computed relevance" and delegates to Sort(SortField.FIELD_SCORE) (:49-56). setSortWithinGroup: "Specified how documents inside a group are sorted. Defaults to Sort#RELEVANCE." (GroupingSearch.java:296-305). GroupDocs.maxScore: "Max score in this group" (GroupDocs.java:27). TopGroups.merge: "Merges a list of TopGroups, for example obtained from the second-pass collector across multiple shards" (TopGroups.java:112-115).
  • History. LUCENE-7701 (caf329717ef, 7.0.0) removed if (withinGroupSort.equals(Sort.RELEVANCE)) { // optimize to use TopScoreDocCollector from the old second-pass collector and wrote withinGroupSort == Sort.RELEVANCE in the new one; the same commit used sort.equals(Sort.RELEVANCE) in the new AllGroupHeadsCollector, and its MIGRATE entry says "To reproduce the old behaviour of SecondPassGroupingCollector, you should instead use TopGroupsCollector." No comment in the issue mentions the change. LUCENE-6900 (2015) and SOLR-9783 (2016) had deliberately made the same check equals.
  • Tests. TestAllGroupHeadsCollector passes new Sort(SortField.FIELD_SCORE) as the within-group sort of AllGroupHeadsCollectorManager (which tests equals) and expects the group heads in score order (TestAllGroupHeadsCollector.java:363-371, :511-514, :542-548): upstream already treats an equal copy as relevance there. No test passes an equal copy as the within-group sort of TopGroupsCollector, TopGroupsCollectorManager or GroupingSearch: TestGrouping.getRandomSort always appends an id field (TestGrouping.java:524-542), and the other grouping tests use Sort.RELEVANCE or field sorts.
  • One-line swaps flip it both ways (executed): 6.6.6 with == fails like 7.0.0; 7.0.0, 8.0.0, 9.12.3 and 10.5.2 with equals pass.
The devil's-advocate check232 words

The strongest case for "intended" (built by OpenAI GPT-5.6 reviewers, a devil's-advocate AI tool and the adjudication): grouping is @lucene.experimental; several javadocs say "if you want to groupSort by relevance use Sort.RELEVANCE" (FirstPassGroupingCollector.java:71, :85, BlockGroupingCollector.java:203, TopGroups.java:363); the package docs say sharding is not directly supported; and LUCENE-6528 (2015, "Not A Problem") says NaN scores from a FIELD_SCORE field sort are expected.

Why it fails (every reviewer rated it weak): those javadoc lines are about the group sort and about passing a non-null sort (LUCENE-6900), not about object identity; TopGroups.merge itself uses equals and treats the sort as relevance, so the module disagrees with itself; the sequential maxScore NaN on main involves no sharding, with setIncludeMaxScore(true) requested; LUCENE-6528 explains why the hits carry NaN, while the defect is that the merge orders by those NaN scores and takes maxScore from them; and @lucene.experimental marks an unstable API, not permission to return wrong results.

What does not fail is compatibility: new Sort(SortField.FIELD_SCORE) is a field sort by construction, GroupDocs says the hits "may be FieldDoc instances if the withinGroupSort sorted by fields" (GroupDocs.java:29-30), and callers may rely on getting FieldDocs. That bears on the shape of the fix (A returns ScoreDocs, as 6.x did; a type-aware merge, option C, keeps FieldDocs), not on whether the wrong page and the NaN maxScore are intended.

Suggested fix

Recommended A: check equality in TopGroupsCollector, like the rest of the module

Check withinGroupSort.equals(Sort.RELEVANCE) at TopGroupsCollector.java:129, like the rest of the module: one line. The test and the whole grouping module (61 tests) pass with assertions on and off, and the static checks pass. The one API-visible change found: such groups then return ScoreDocs with real scores, as the constant does, instead of FieldDocs with NaN.

-      if (withinGroupSort == Sort.RELEVANCE) {
+      if (withinGroupSort.equals(Sort.RELEVANCE)) {
All options and trade-offs4 options ยท 567 words

One fix was built and validated in a scratch worktree at ea57360912ed, removed afterwards; the other options were not run.

A: check equality in TopGroupsCollector, like the rest of the module (validated by the checks) (recommended)

In TopGroupsCollector.TopDocsReducer, replace withinGroupSort == Sort.RELEVANCE with withinGroupSort.equals(Sort.RELEVANCE). An equal copy then takes the same score-collector path as the constant, which is what 6.x did.

-      if (withinGroupSort == Sort.RELEVANCE) {
+      if (withinGroupSort.equals(Sort.RELEVANCE)) {

Trade-offs

  • Hit type changes. Hits of an equal copy become plain ScoreDocs with real scores instead of FieldDocs with score NaN (probe: stock FieldDoc doc=1 score=NaN maxScore=NaN, fixed ScoreDoc doc=1 score=5.0 maxScore=5.0, the same as Sort.RELEVANCE). A caller that casts them to FieldDoc or reads fields[0] would break; GroupDocs documents FieldDoc hits only "if the withinGroupSort sorted by fields" (GroupDocs.java:29-30). The hits then come from TopScoreDocCollector instead of TopFieldCollector.
  • Cost. Not benchmarked: one Sort.equals call per collector construction, and an equal copy then uses the constant's collector.
  • Scope. It fixes what Lucene's own collectors produce. TopGroups.merge still treats any input whose docSort equals Sort.RELEVANCE as score-sorted, so FieldDocs with score NaN from another producer (a custom second-pass collector) would still merge in slice order; option C covers that.
  • Related open PR. #16569 edits the same constructor and keeps ==; the repros still fail with it. A 3-way merge of the fixed file with the PR's head on their shared base is clean (0 conflicts).

What the checks validated

  • Gradle, scratch worktree at ea57360912ed, OpenJDK 25.0.3: the test fails 3 of 5 on stock (assertions on and off) and passes 5 of 5 with the fix.
  • :lucene:grouping:test with the fix and the test: 15 classes, 61 tests (56 upstream + 5 new), 0 failures, with assertions on, off, and seed 5A17C0DE.
  • :lucene:grouping:check -x test passes (google-java-format, ecjLint, forbiddenApis) after tidy re-wrapped two javadoc paragraphs of the test.
  • Standalone harness with the fix shadowed: the test passes 5 of 5 with assertions on and off (seeds DEADBEEF and 7A11C0DE); the independent reproducer's 17 tests, the refute reviewer's tests and 56 upstream grouping test methods pass; the security probe shows 0 pages out of score order and no NaN maxScore.

B: normalize equal sorts to the constant at the entry points (not run)

GroupingSearch.setSortWithinGroup, the TopGroupsCollector constructor and TopGroupsCollectorManager replace a sort equal to Sort.RELEVANCE with the constant.

Trade-offs

Same effect for collection, but more places to change, and it keeps the identity check that the rest of the module avoids.

C: make TopGroups.merge follow the hits instead of the sort (not run)

Merge with TopFieldDocs whenever the hits are FieldDocs, and keep the per-shard maxScore.

Trade-offs

Keeps the FieldDoc hit type for callers that rely on it and also fixes merged results from any other producer, but touches the merge for every caller and leaves the collector's identity check in place. Switching the merge to == instead would be wrong: it would break callers that collect with the constant and merge with new Sort(), since the hits would then be treated as FieldDocs. A is the minimal change; C is a real alternative, or an addition to A.

D: document "pass the Sort.RELEVANCE instance" (rejected)

Say in the javadoc that only the constant means relevance.

Trade-offs

Makes Sort.equals meaningless for this API and leaves every existing caller wrong.

How it was found

Code reading. An AI tool auditing the grouping module noticed that the second pass checks == Sort.RELEVANCE while the merge checks equals, and confirmed it with a probe. Adding new Sort(SortField.FIELD_SCORE) to the unit's bounded-exhaustive grouping check then showed wrong pages and a NaN maxScore at 2 documents.

The method in full134 words

Method: code reading (the hunt's non-formal tier), a hunt unit (grouping), 2026-10-26.

  • The finder, an AI tool, compared the relevance check in TopGroupsCollector (==) with the one in TopGroups.merge (equals) and wrote a scratch probe that showed the wrong page.
  • It then added a third within-group sort, new Sort(SortField.FIELD_SCORE), to the unit's bounded-exhaustive harness, which compares sequential, concurrent and hand-sharded grouping with a reference over every small index. Wrong pages (distributed and sequential-versus-concurrent kinds) and wrong maxScore appeared already at 2 documents, one per slice or shard. With the constant and with real field sorts the same harness found no such mismatch in larger runs.
  • So the hypothesis came from reading; the harness confirmed it. A public-API JUnit test on the stock jars followed, then the independent checks below.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): 157 searches over GitHub issues and PRs, downstream repositories, four mailing lists, the LUCENE and SOLR JIRA archives, the web and the full commit history, plus the files of all 413 open PRs; 17 hits read. Open PR #16569 edits the adjacent lines but keeps the identity check.

Searches4 searches

The closest items are the change that introduced the check (#8752, LUCENE-7701), the change that introduced the NaN maxScore (#15879, whose discussion is about performance only), the same one-line equals change made in other code in 2015-2016 (#7958 / LUCENE-6900, SOLR-9783), an analogous collection-versus-merge mismatch in Solr's distributed group.query (SOLR-5046, different code), and other, fixed NaN-maxScore bugs (#10039 / LUCENE-8996, #15872 / #15873). GitHub search caps at 50 results per query (none returned more than 17); GitHub Discussions, chat channels and private security reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. migrated JIRA)2026-10-2613 scripted prior-report search queries and 80 GraphQL searches, 14 of them over Elasticsearch, OpenSearch and Solr (TopGroupsCollector, withinGroupSort, sortWithinGroup, Sort.RELEVANCE, FIELD_SCORE, new Sort(), TopGroups.merge NaN, maxScore NaN, executor and slice phrasings)no report; 17 related hits read
Open PRs and recent activity2026-10-26changed files of all 413 open PRs; 491 issues and PRs updated since 2026-09-01; TopGroupsCollector.java on main, branch_10x, branch_10_6 and branch_10_5#16569 and #16548 touch the file and keep the identity check; all four branches still have it
Mailing lists and JIRA2026-10-2628 queries over dev@, java-user@, issues@ and commits@; 8 LUCENE and 7 SOLR JIRA queriesno report
Downstream code, commits and the web2026-10-26the 14 downstream searches above; 13 commit-message patterns over the full history plus the upstream commits after the snapshot; 8 web queries0 downstream hits for the identifiers; no fix commit; no web report

History

Introduced in 7.0.0 by LUCENE-7701 (2017), which swapped an equals check for ==. It grew wider in 8.0.0, 10.5.0 (NaN maxScore, collector managers) and on main (GroupingSearch). Run on 13 releases and main: the three 6.x releases pass, the 10 from 7.0.0 to 10.5.2 and main fail; all 65 tags in that range have == (read). Not fixed upstream.

Versions tested11 rows

Introduced by caf329717ef (LUCENE-7701 "Refactor grouping collectors", #8752; a JIRA patch by romseygeek with a +1 from martijnvg; authored 2017-03-28, committed 2017-04-10; first release 7.0.0). git blame on main points line 129 at 331ccf39106 (LUCENE-8405), which only rewrote the ternary as an if/else. Later changes widened what users see, none of them wrong with the constant: LUCENE-8412 (55bfadbce11, 8.0.0: field-collected hits always NaN), #15879 (b90b9dec25c, 10.5.0: merged maxScore from the first hit), #15603 and #15574 (10.5.0: collector managers that reduce through TopGroups.merge), #16247 (3afffeef5a8, released in 10.6.0: GroupingSearch on the managers). branch_10_6, branch_10x and branch_10_5 still have the check (read).

Executed: the releases in the table. Not executed: 6.0.0-6.4.2, 6.5.1, 6.6.0, 6.6.2-6.6.5, 7.0.1-7.7.2, 8.1.0-8.11.3, 9.1.0-9.12.2 (9.1.0 ran only the 9.x variant), 10.1.0-10.3.2 and 10.5.1. By code reading, every 6.x tag tests equals and every tag from 7.0.0 to 10.5.2 (65) has the identity test exactly once (8.8.2-8.11.4 are untagged in the history clone).

Versions and branches executed, with results
Version or commitVerdictResult
6.5.0, 6.6.1, 6.6.6 not affectedhistory test passes 7 of 7 (equals check)
7.0.0, 7.7.3 scores off onlyhistory test fails 1 of 7: hand-sharded merge with getScores=false and getMaxScores=false
8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.4.0 affectedhistory test fails 2 of 4: wrong page on a hand-sharded merge, maxScore right
9.1.0 9.x variantthe hand-sharded 9.x variant fails 1 of 2 (run by the fact check)
10.5.0, 10.5.2 affectedhistory test fails 3 of 4 (wrong page, maxScore NaN); the test fails 1 of 5 (collector managers)
main ea57360912ed affectedhistory test 3 of 4 fail; the test 3 of 5 fail (GroupingSearch with an executor, managers, sequential maxScore)
6.6.6 with == one-line swapfails like 7.0.0
7.0.0, 8.0.0, 9.12.3, 10.5.2 with equals one-line swapevery test passes, including maxScore on 10.5.2
main + the fix not affectedthe test passes; the grouping module passes (61 tests)
the other tags, 6.0.0-6.6.5 read, not runequals check, as in the executed 6.x releases
the other tags, 7.0.1-10.5.1 read, not runidentity check, as in the executed releases around them

Independent checks

An independent reproducer (17 own tests, 8 fail), an OpenAI GPT-5.6 refute reviewer that ran its own tests, a GPT-5.6 'works as designed' reviewer (reading only), a devil's advocate and an adjudication all say real. A security probe found no effect beyond the ranking inside groups.

Checks and verdicts307 words

All checks ran on main ea57360912ed unless noted, with fixed seeds recorded in their result files.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateThe test fails 3 of 5 on main (assertions on and off), 1 of 5 on 10.5.0 and 10.5.2; the 9.x variant fails on 9.12.3; the one-line fix passes 5 of 5; the fix of the sibling tie-break bug LFV-238 leaves 3 of 5 failing.
Refute review (OpenAI GPT-5.6) real (high), intended argument weakIts own two-segment, two-slice test fails 2 of 3 with assertions on and off and 6 of 9 over 3 iterations of another seed; a maxScore test fails; with the fix its tests and 56 upstream grouping test methods pass.
Independent reproducer realOwn tests with exact scores: 17 run, 8 fail on main (assertions on and off), 9 controls pass; 10.5.2 4 of 17, 9.12.3 1 of 4; with the fix 17 of 17 pass.
'works as designed' review (OpenAI GPT-5.6) real (high), reading onlyCould not run Java; found no identity precondition in the javadocs; a separate run re-ran the test: 3 of 5 fail.
Devil's advocate realFound that the == check replaced an equals check in a refactor whose migration note promised the old behaviour; re-ran the test with assertions on and off (3 of 5 fail).
Adjudication confirmed (high)Re-ran the independent reproduction's tests (seed 1A2B3C4D: 17 run, 8 failed; 17 of 17 pass with the fix) and the test with assertions off (3 of 5 fail).
Security probe none219,792 checks per run over 1,392 grouped searches (restrictions, deletions, 7 searcher layouts: 1, 2, 8 and 32 slices, an 8-slice searcher with a query cache, two hand-sharded merges): 0 violations, while the bug fired in the same runs.

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

Finding record

LFV-232, snapshot of 2026-11-06

Registry title
Grouping with a within-group sort that equals Sort.RELEVANCE but is a different object (new Sort(), new Sort(SortField.FIELD_SCORE)) returns the wrong documents per group when results from slices or shards are merged, and since 10.5.0 a NaN GroupDocs.maxScore
Area
grouping / two-pass grouping (TopGroupsCollector second pass, TopGroups.merge, TopGroupsCollectorManager, GroupingSearch)
Priority
Medium, rank 47: A within-group sort that equals but is not the Sort.RELEVANCE constant (for example a request-built new Sort(SortField.FIELD_SCORE)) silently returns the wrong documents per group whenever slices or shards are merged (hand-sharded TopGroups.merge since 8.0.0, the collector managers with an executor since 10.5.0), plus a NaN maxScore since 10.5.0, a natural trigger for direct grouping users, although the default constant is correct and Solr, Elasticsearch and OpenSearch do not reach it.
Impact class
silent wrong results
Impact note
Applications that pass a within-group sort that equals Sort.RELEVANCE but is not the constant itself (new Sort() or new Sort(SortField.FIELD_SCORE), for example built from a 'sort by score' request) get the wrong documents inside each group whenever 2 or more collectors are merged: a hand-sharded TopGroups.merge (every release since 8.0.0; in 7.x only with scores and max scores off), the grouping collector managers on an IndexSearcher with an executor and 2 or more slices (10.5.0 on; default slicing gives that above 5 segments or 250k documents), and GroupingSearch itself with such an executor (main and, by reading, 10.6.0); since 10.5.0 GroupDocs.maxScore is also NaN with max scores requested, on main even for a sequential GroupingSearch; the default Sort.RELEVANCE constant is correct on every path, nothing throws, -ea and -da behave the same, and it happens at any index size; Solr normalizes 'score desc' to the constant and Elasticsearch and OpenSearch do not use TopGroupsCollector (code reading).
Summary
You run two-pass grouping (GroupingSearch, or the grouping collectors and collector managers) with setSortWithinGroup(new Sort(SortField.FIELD_SCORE)) or new Sort(), both of which equal Sort.RELEVANCE, and expect the same result as with the Sort.RELEVANCE constant: the top-scoring documents of each group, and GroupDocs.maxScore equal to the best score. Instead TopGroupsCollector tests 'withinGroupSort == Sort.RELEVANCE' (same object), so it collects each group with the field-sort collector, whose hits carry score NaN, while TopGroups.merge tests equals and merges those hits by their NaN scores. When results from 2 or more slices or shards are merged, each group's page then comes out slice by slice or shard by shard (the finder's test returns a score-1 document instead of the score-4 one), and since 10.5.0 the merged maxScore is NaN, on main even for a sequential GroupingSearch.
Introduced
caf329717ef643b2a5a8b7ef0bfb7bd5e096c849 (LUCENE-7701 'Refactor grouping collectors', a JIRA patch by romseygeek with a +1 from martijnvg, migrated as apache/lucene#8752, no GitHub PR; master only, first released in 7.0.0): it replaced the old second-pass collector's 'withinGroupSort.equals(Sort.RELEVANCE)' with 'withinGroupSort == Sort.RELEVANCE' in the new TopGroupsCollector, without comment [read: git log -S in the full-history clone, git tag --contains; exec: one-line swaps on release jars, see affected_versions]. git blame on main points line 129 at 331ccf39106 (LUCENE-8405), which only turned the ternary into an if/else and kept the test. Later changes widened what users see, none is wrong with the constant: 55bfadbce11 (LUCENE-8412, apache/lucene#9459, 8.0.0: field-collected hits always score NaN), #15879 (b90b9dec25c, 10.5.0: merged maxScore taken from scoreDocs[0].score), #15603 and #15574 (8e443458c2f, 5c701b3f553, 10.5.0: collector managers that reduce through TopGroups.merge) and #16247 (3afffeef5a8 on main, d60e550b47f on branch_10_6, released in 10.6.0: GroupingSearch runs on the managers) (2017-03-28 (author date of caf329717ef; committed to master 2017-04-10))
First affected version
7.0.0 [exec] (2017-09-08; there only for a hand-sharded TopGroups.merge with getScores=false and getMaxScores=false); 8.0.0 [exec] is the first release in which the default flags show it; 10.5.0 [exec] is the first with the NaN maxScore and the collector-manager path; last unaffected release 6.6.6 [exec] (6.6.1 is the last release before 7.0.0)
Affected versions
7.0.0-7.7.3: only a hand-sharded TopGroups.merge with getScores=false and getMaxScores=false [exec 7.0.0 -ea and -da, 7.7.3: history test fails 1 of 7]; 8.0.0-10.4.0: every hand-sharded TopGroups.merge returns the wrong documents, maxScore correct [exec 8.0.0, 8.11.4, 9.0.0, 9.12.3 -ea and -da, 10.0.0, 10.4.0: 2 of 4 fail; the finder's and the independent reproduction's 9.x shard-merge tests fail on 9.12.3]; 10.5.0-10.5.2: also the collector managers (wrong documents with an executor, NaN maxScore even with one slice) and a NaN merged maxScore [exec 10.5.0, 10.5.2 -ea and -da: 3 of 4 fail; finder 1 of 5, the independent reproduction 4 of 17 on 10.5.2]; main ea57360912ed: also GroupingSearch (wrong documents with an executor, NaN maxScore sequentially) [exec -ea and -da: finder 3 of 5, the independent reproduction 8 of 17, history test 3 of 4]; branch_10_6 (10.6.0, released 2026-10-29), branch_10x and branch_10_5 still have the identity test at line 129 [read through the upstream contents API on 2026-10-26], so 10.6.0 is very likely affected (not executed); not executed: 7.1.0-7.7.2, 8.1.0-8.11.3, 9.1.0-9.12.2, 10.1.0-10.3.2 and 10.5.1, each between executed releases with the same behaviour, and every one of them tagged in the history clone has the identity test [read] (8.8.2-8.11.3 are untagged there); unaffected: 5.5.0-6.6.6 (equals test on every tag) [read; exec 6.5.0, 6.6.1, 6.6.6 -ea and -da pass 7 of 7]; one-line swaps flip the result both ways [exec: 6.6.6 with the identity test fails like 7.0.0; 7.0.0, 8.0.0, 9.12.3 and 10.5.2 with equals pass every test]; not fixed in any release or on 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 agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new: 162 logged read-only queries, 57 through a scripted prior-report search (13 GitHub issue/PR queries on apache/lucene, 28 mailing-list queries over dev@, java-user@, issues@ and commits@, 8 LUCENE JIRA-archive and 8 web queries), 80 GitHub GraphQL searches including 14 over Elasticsearch, OpenSearch and Solr (0 hits for TopGroupsCollector, withinGroupSort and TopGroups.merge NaN), 7 SOLR JIRA queries, 13 commit-message patterns over the full history plus the upstream commits after the snapshot, and scans of the changed files of all 413 open apache/lucene PRs and of the 491 issues and PRs updated since 2026-09-01; 17 hits read, none the same bug); not fixed upstream (main, branch_10x, branch_10_6 and branch_10_5 still have 'withinGroupSort == Sort.RELEVANCE' at TopGroupsCollector.java:129); the open PRs on the same file keep the test: #16569 ('Omit computing max score when sorting by score primarily in TopGroupsCollector', stale, no reviews) edits the adjacent non-RELEVANCE branch and, shadowed over main, leaves every failure in place, and #16548 (totalHitsThresholdPerGroup) does not touch it; closest items, not reports: LUCENE-7701 / #8752 (introduced the identity test), #15879 (introduced the NaN maxScore symptom), LUCENE-6900 / #7958 and SOLR-9783 (the same one-line equals change made in Solr code in 2015-2016), SOLR-5046 (an analogous collection-versus-merge relevance mismatch in Solr's distributed group.query, open since 2013, different code)
Lucene commit tested
main ea57360912ed (commit of 2026-10-24; tested 2026-10-26): the test through gradle in a scratch worktree (assertions on and off) and through a standalone JUnit harness (seeds DEADBEEF and 7A11C0DE, assertions on and off); the Maven Central jars of 10.5.0 and 10.5.2 (the same test) and 9.1.0 and 9.12.3 (a 9.x shard-merge variant); a two-shard history test on 6.5.0, 6.6.1, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.4.0, 10.5.0, 10.5.2 and main, plus one-line swaps on 6.6.6, 7.0.0, 8.0.0, 9.12.3 and 10.5.2; the fix in the scratch worktree (the test, the whole grouping module with assertions on and off and a second seed, :lucene:grouping:check -x test), removed afterwards, and shadowed in the standalone harness (the test, assertions on and off, two seeds).

Lucene and upstream sources

Finding record
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroupsCollector.java:127-147 (TopDocsReducer; identity test at :129, TopScoreDocCollector branch :130-136, TopFieldCollector plus MaxScoreCollector branch :137-146)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:183 (sortByRelevance = docSort.equals(Sort.RELEVANCE)), :239-245 (TopDocs.merge by score, maxScore from scoreDocs[0].score), :405 (groupSort equals); TopGroupsCollectorManager.java:112-116 (reduce always calls TopGroups.merge); GroupingSearch.java:56 (includeMaxScore defaults to true), :197-211 (second pass through TopGroupsCollectorManager)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/AllGroupHeadsCollector.java:63, AllGroupHeadsCollectorManager.java:146, BlockGroupingCollector.java:275 and :285 (every other relevance test in the module uses equals); GroupDocs.java:27 ('Max score in this group')
  • lucene/core/src/java/org/apache/lucene/search/Sort.java:26-29 (class javadoc: an empty constructor yields a relevance sort), :37-41 (RELEVANCE = new Sort()), :50-56 (Sort() 'Sorts by computed relevance', delegates to Sort(SortField.FIELD_SCORE))
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroupsCollector.java:122 (TopDocsReducer), :127-148 (its constructor; identity test :129), :177-199 (getTopGroups: per-group maxScore)
  • Lucene main ea57360912ed: lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:112-135 (merge javadoc), :183, :218-220, :231-233, :241, :244-245
  • Lucene main ea57360912ed: lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroupsCollectorManager.java:112-123; GroupingSearch.java:52, :56, :197-212
  • Lucene main ea57360912ed: lucene/grouping/src/java/org/apache/lucene/search/grouping/AllGroupHeadsCollector.java:63, AllGroupHeadsCollectorManager.java:146, BlockGroupingCollector.java:275, :285, TopGroups.java:405 (equals)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/Sort.java:27-29, :41, :49-56, :114-119; FieldValueHitQueue.java:36-38; TopDocs.java:44-45, :110-120; IndexSearcher.java:106-108, :226-231, :323-324, :350-379, :545-548
  • Lucene main ea57360912ed grep over lucene/ for '== Sort.<CONSTANT>': TopGroupsCollector.java:129 and the test TestSearchAfter.java:236 only
Who is affected
  • Lucene main ea57360912ed: lucene/grouping/src/java/org/apache/lucene/search/grouping/package-info.java:76-77; GroupingSearch.java:52, :296-305
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/Sort.java:27-29, :41, :49-56, :114-119
  • Lucene main ea57360912ed: lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:296-305; GroupDocs.java:27; TopGroups.java:112-115, :183, :363; FirstPassGroupingCollector.java:71, :85; BlockGroupingCollector.java:203; package-info.java:76-77
  • Lucene main ea57360912ed: lucene/grouping/src/test/org/apache/lucene/search/grouping/TestAllGroupHeadsCollector.java:363-371, :504, :511-514, :533, :542-548; AllGroupHeadsCollectorManager.java:146; TestDistinctValuesCollector.java:147, :153; TestGrouping.java:524-542; grep of setSortWithinGroup / TopGroupsCollectorManager in lucene/grouping/src/test
Suggested fix
  • Lucene main ea57360912ed: lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupDocs.java:29-30

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

Repro files

About the diagrams

The diagram shows the repro's group on two slices, the two relevance checks (== in the collector, equals in the merge) and the merged page with each sort. Its numbers come from the diagram probe run on ea57360912ed. In the "actual" rows the returned hits carry score NaN, so their tiles show each hit's sort value (its score) instead of a score.

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.