All findings · Lucene finding · LFV-816 · groupingsearch-does-not-rewrite-sorts

GroupingSearch never rewrites its group sort or within-group sort against the searcher, unlike IndexSearcher#search

GroupingSearch#search gets the searcher but, unlike IndexSearcher#search(query, n, sort), never rewrites its group and within-group sorts against it. Sorts that need the searcher then throw, and a legacy QueryValueSource sort on a ClassicSimilarity searcher silently lists a group as doc0 (0.147), doc1 (0.125) instead of doc1 (1.0), doc0 (0.894).

Summary

Priority
Medium, rank 218GroupingSearch has had the same missing Sort rewrite since 7.2 (a loud failure, silent only on the rare legacy values-source bridge), so it gets the same Medium tier as LFV-815 and LFV-814.
Impact
silent wrong results
Since
7.2.0 (LUCENE-7736, 2017), when IndexSearcher began rewriting Sortsintroduced 2017-11-15 · about 8 years
Affected
every release from 7.2.0 to 10.6.0, and main
Status
documented
Found by
a bounded-exhaustive comparison of Sort consumers on tiny indexes (semi-formal)

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

GroupingSearch#search(searcher, query, ...) hands its group sort and within-group sort to the grouping collectors without Sort#rewrite(searcher), the step IndexSearcher#search(query, n, sort) takes first. Sorts whose fields need the searcher then throw, or, for a legacy QueryValueSource view, score with the default Similarity.

Background in three steps

  1. A Sort can hold fields that need the searcher before they can sort, such as a function sort from ValueSource#getSortField or a query-score sort from DoubleValuesSource.fromQuery. Sort#rewrite(searcher) binds them to it.
  2. Since 7.2.0, IndexSearcher#search(query, n, sort) calls sort.rewrite(this) itself, so callers can pass such a Sort as it is.
  3. GroupingSearch orders the groups by a group sort and the documents inside each group by a within-group sort, and runs its collectors on the searcher passed to search.
Diagram of the bug: IndexSearcher rewrites the Sort against itself before sorting (IndexSearcher.java:730), while GroupingSearch#search rewrites only its groupEndDocs query (line 223) and hands groupSort and sortWithinGroup to its collectors as given (lines 148, 160, 201-202, 228-233). On a ClassicSimilarity searcher with three documents, the documents of one group sorted by the score of body:a come back as doc0 0.101, doc1 0.088, doc2 0.088 instead of doc1 1.0, doc2 1.0, doc0 0.894; groups sorted by the same Sort put g0 first instead of g1; ValueSource#getSortField and DoubleValuesSource.fromQuery sorts throw. Fix: rewrite both Sorts against the searcher at the start of groupByFieldOrFunction and groupByDocBlock.
A three-document version of the example: inside one group IndexSearcher#search orders doc1 (1.0), doc2 (1.0), doc0 (0.894), while GroupingSearch returns doc0 (0.101), doc1 (0.088), doc2 (0.088); sorted by the same Sort, the groups come back g0 first instead of g1, and the fix rewrites both Sorts. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You index two documents in one group g: doc 0 = a a a a a a a a x x, doc 1 = a. The searcher uses ClassicSimilarity, and you sort the documents inside each group by the score of body:a: new Sort(new QueryValueSource(body:a, 0).asDoubleValuesSource().getSortField(true)).
  2. You expect new GroupingSearch("group").setSortWithinGroup(sort) to list the group as searcher.search(matchAll, 10, sort) does for the same Sort: doc1 (1.0), then doc0 (0.894). IndexSearcher gets there by calling sort.rewrite(this) first.
  3. Instead the group comes back as doc0 (0.147), doc1 (0.125): the scores of the default BM25 Similarity, in the opposite order. GroupingSearch never rewrites its Sorts, so the score source has no searcher, and QueryValueSource scores body:a with a fresh default IndexSearcher.
  4. Most sorts that need the searcher fail loudly instead, and only in GroupingSearch: a ValueSource#getSortField sort throws "SortField needs to be rewritten ...", a DoubleValuesSource.fromQuery sort "This DoubleValuesSource must be rewritten". Group order and group heads go wrong the same way.

Who is affected

GroupingSearch users who sort groups or group documents by a function or a query score. Usually it throws on the first grouped search although IndexSearcher accepts the sort; the silent wrong order needs a legacy QueryValueSource sort and a non-default Similarity. Field sorts and relevance are unaffected. Any index size, since 7.2.0.

Mechanism, terms and numbers493 words

GroupingSearch takes a group sort and a within-group sort and runs its collectors on the searcher passed to search(searcher, query, ...). Since 7.2.0, IndexSearcher#search(query, n, sort) binds a Sort to the searcher with sort.rewrite(this) before sorting; GroupingSearch never does, although it rewrites its own groupEndDocs query against that searcher. A sort that works in IndexSearcher#search therefore throws in GroupingSearch ("SortField needs to be rewritten ..." for a ValueSource#getSortField sort, "This DoubleValuesSource must be rewritten" for a DoubleValuesSource.fromQuery sort), and a sort on a legacy QueryValueSource through asDoubleValuesSource() silently scores with the default Similarity: on a ClassicSimilarity searcher a two-document group comes back as doc0=0.147, doc1=0.125 (default-BM25 scores) instead of doc1=1.0, doc0=0.894. Group order and group heads go wrong the same way; the set of matching documents never changes. Every release from 7.2.0 to 10.6.0 and main is affected. Rewriting both Sorts at the start of the two grouping methods (4 added lines) fixes every case; all 56 existing grouping tests stay green.

The mechanism (main 5adff78bb168)

  1. IndexSearcher binds the Sort. searchAfter, which search(Query, int, Sort) uses, does final Sort rewrittenSort = sort.rewrite(this); and builds its TopFieldCollectorManager from it (IndexSearcher.java:730-733). Sort.rewrite rewrites each field and returns this when nothing changes (Sort.java:88-99). It is the only Sort.rewrite call in the non-test sources under lucene/.
  2. GroupingSearch does not. groupByFieldOrFunction passes the field groupSort to FirstPassGroupingCollectorManager (GroupingSearch.java:148), sortWithinGroup to AllGroupHeadsCollectorManager (:160) and both to TopGroupsCollectorManager (:201-202). groupByDocBlock rewrites its groupEndDocs query with searcher.rewrite(this.groupEndDocs) (:223) and then passes both Sorts as given to BlockGroupingCollectorManager (:228, :233). The collector managers take only a Sort and never see the searcher.
  3. Loud path. The collectors build comparators with SortField.getComparator (FirstPassGroupingCollector.java:116, AllGroupHeadsCollector.java:241, BlockGroupingCollector.java:237). A Type.REWRITEABLE field, which ValueSource#getSortField returns (ValueSource.java:369-384), throws "SortField needs to be rewritten through Sort.rewrite(..) and SortField.rewrite(..)" (SortField.java:555-557); a DoubleValuesSource.fromQuery source throws "This DoubleValuesSource must be rewritten" until it is rewritten (DoubleValuesSource.java:768).
  4. Silent path. ValueSource#asDoubleValuesSource() returns a wrapper with no searcher (ValueSource.java:184-185); its values get a function context whose searcher is null (:198-200), and only rewrite(searcher) returns a wrapper that carries the searcher (:244-245). QueryValueSource then finds no searcher and scores its query with new IndexSearcher(ReaderUtil.getTopLevelContext(readerContext)) (QueryValueSource.java:116-123): the same reader, the default Similarity.
  5. What does not change. The collectors only see documents the caller's query matched on the caller's searcher (GroupingSearch.java:173, :175, :211, :237): the matching documents and groups are the same; only order, sort values and group heads differ, or the request throws.

The numbers of the example

On ClassicSimilarity, body:a scores sqrt(tf)/sqrt(length) with idf 1 (both documents match): doc 1 gets sqrt(1)/sqrt(1) = 1.0 and doc 0 gets sqrt(8)/sqrt(10) = 0.894. A plain IndexSearcher over the same reader (default BM25) scores them 0.14679137 and 0.12456752, and those are exactly the sort values GroupingSearch returns.

Exposure and likelihood201 words
  • Who is hit. Applications that group with GroupingSearch (field, function or doc-block grouping) and sort groups or the documents inside a group by a queries-module function sort (ValueSource#getSortField), a query-score sort (DoubleValuesSource.fromQuery) or an IndexReaderFunctions sort that needs the searcher, such as docFreq or sumTotalTermFreq (all loud), or by a legacy QueryValueSource through asDoubleValuesSource() (silent when the Similarity is not the default).
  • Default vs opt-in. Sort.RELEVANCE (the default) and field sorts are unaffected. The silent form also needs a searcher Similarity other than the default BM25: ClassicSimilarity, BooleanSimilarity and BM25 with other parameters were all executed. No codec, analyzer or index setting is involved; assertions on or off.
  • Scale. Any index size (two documents are enough); deterministic: every grouped search with such a sort.
  • Silent vs loud. The loud forms fail on the first grouped search with a message that names the rewrite. The silent form returns plausible but wrong orderings, sort values and group heads with no signal.
  • How likely. Rare: upstream grouping tests and docs use only field sorts and relevance, and nobody has reported it since 7.2.0 (2017).
  • Workaround. setGroupSort(sort.rewrite(searcher)) and setSortWithinGroup(sort.rewrite(searcher)), once per searcher: on the example it gives doc1=1.0, doc0=0.894.

Minimal repro

One JUnit test, one method, public API: two documents in one group, a ClassicSimilarity searcher and one sort by the score of body:a. It compares GroupingSearch's order inside the group with IndexSearcher#search for the same Sort. It fails with assertions on and off and passes with the fix.

TestGroupingSearchSortRewrite.java (one test method)84 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 org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.SortedDocValuesField;
import org.apache.lucene.document.TextField;
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.queries.function.valuesource.QueryValueSource;
import org.apache.lucene.search.FieldDoc;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.similarities.ClassicSimilarity;
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;

/**
 * GroupingSearch must order the documents of a group like IndexSearcher#search(Query, int, Sort)
 * orders them for the same Sort on the same searcher. IndexSearcher rewrites the Sort against
 * itself; GroupingSearch passes it on as given.
 */
public class TestGroupingSearchSortRewrite extends LuceneTestCase {

  public void testSortWithinGroupMatchesIndexSearcher() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(new StandardAnalyzer()))) {
        for (String body : new String[] {"a a a a a a a a x x", "a"}) { // doc 0, doc 1
          Document doc = new Document();
          doc.add(new TextField("body", body, Field.Store.NO));
          doc.add(new SortedDocValuesField("group", new BytesRef("g")));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        searcher.setSimilarity(new ClassicSimilarity());
        // sort by the score of body:a, best first
        QueryValueSource score = new QueryValueSource(new TermQuery(new Term("body", "a")), 0f);
        Sort sort = new Sort(score.asDoubleValuesSource().getSortField(true));

        TopGroups<BytesRef> grouped =
            new GroupingSearch("group")
                .setSortWithinGroup(sort)
                .setGroupDocsLimit(10)
                .search(searcher, new MatchAllDocsQuery(), 0, 10);
        ScoreDoc[] sorted = searcher.search(new MatchAllDocsQuery(), 10, sort).scoreDocs;

        assertEquals(describe(sorted), describe(grouped.groups[0].scoreDocs()));
      }
    }
  }

  private static String describe(ScoreDoc[] hits) {
    StringBuilder sb = new StringBuilder();
    for (ScoreDoc hit : hits) {
      sb.append("doc").append(hit.doc).append('=').append(((FieldDoc) hit).fields[0]).append(' ');
    }
    return sb.toString().trim();
  }
}
Command
./gradlew :lucene:grouping:test --tests org.apache.lucene.search.grouping.TestGroupingSearchSortRewrite -Ptests.seed=DEADBEEF
Expected
doc1=1.0 doc0=0.894: the order and sort values of searcher.search(new MatchAllDocsQuery(), 10, sort) on the same searcher.
Observed
doc0=0.147 doc1=0.125 (expected:<doc[1=1.0 doc0=0.8944271802902222]> but was:<doc[0=0.14679136872291565 doc1=0.12456752359867096]>), with assertions on and off.

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

More files, environment, full output287 words

The test sits in org.apache.lucene.search.grouping only for locality; it uses only public API. ByteBuffersDirectory, core StandardAnalyzer, the default IndexWriterConfig, two documents (one segment), a plain IndexSearcher with ClassicSimilarity, nothing random: deterministic, single thread. It runs GroupingSearch before IndexSearcher#search, so no version can hand the searcher from one to the other through the shared Sort object. The same test is the suggested regression test.

On 9.1 to 9.12, GroupDocs has public fields: write grouped.groups[0].scoreDocs instead of scoreDocs() (one line).

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestGroupingSearchSortRewrite.java $LUCENE/lucene/grouping/src/test/org/apache/lucene/search/grouping/
cd $LUCENE
./gradlew :lucene:grouping:test --tests org.apache.lucene.search.grouping.TestGroupingSearchSortRewrite -Ptests.seed=DEADBEEF
# production setting (assertions off): add -Ptests.asserts=false

Observed output

# gradle, main 5adff78bb168, seed DEADBEEF, assertions on (the same with -Ptests.asserts=false)
TestGroupingSearchSortRewrite > testSortWithinGroupMatchesIndexSearcher FAILED
    org.junit.ComparisonFailure: expected:<doc[1=1.0 doc0=0.8944271802902222]> but was:<doc[0=0.14679136872291565 doc1=0.12456752359867096]>
        at org.apache.lucene.search.grouping.TestGroupingSearchSortRewrite.testSortWithinGroupMatchesIndexSearcher(TestGroupingSearchSortRewrite.java:72)
1 test completed, 1 failed
BUILD FAILED

# with the recommended fix, assertions on and off
:lucene:grouping:test (SUCCESS): 1 test
BUILD SUCCESSFUL

# JUnit runner on the released jars (seed DEADBEEF, -ea): the same message on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0

# exploration probe on the same two documents (main 5adff78bb168)
PROBE classic scores of body:a: doc1=1.0 doc0=0.8944272
PROBE default(BM25) scores of body:a: doc0=0.14679137 doc1=0.12456752
PROBE GroupingSearch within-group, raw sort: doc0=0.14679136872291565 doc1=0.12456752359867096
PROBE GroupingSearch within-group, sort.rewrite(searcher): doc1=1.0 doc0=0.8944271802902222
PROBE IndexSearcher#search(q, 10, sort): doc1=1.0 doc0=0.8944271802902222
PROBE IndexSearcher, ValueSource#getSortField: doc1=1.0 doc0=0.8944271802902222
PROBE GroupingSearch, ValueSource#getSortField: java.lang.IllegalStateException: SortField needs to be rewritten through Sort.rewrite(..) and SortField.rewrite(..)
PROBE IndexSearcher, DoubleValuesSource.fromQuery: doc1=1.0 doc0=0.8944271802902222
PROBE GroupingSearch, DoubleValuesSource.fromQuery: java.lang.UnsupportedOperationException: This DoubleValuesSource must be rewritten
PROBE control, FIELD_DOC within group: doc0=0 doc1=1
PROBE control, default-Similarity searcher, raw sort: doc0=0.14679136872291565 doc1=0.12456752359867096 vs IndexSearcher doc0=0.14679136872291565 doc1=0.12456752359867096

Why it looks unintended

  • GroupingSearch#search is handed the searcher and rewrites its own groupEndDocs query with it (GroupingSearch.java:223), but not its Sorts.
  • Since 7.2.0 the class that holds the searcher rewrites the Sort (IndexSearcher.java:730); a consumer missed then was fixed as a bug.
  • No javadoc asks callers to pre-rewrite, and an upstream test expects agreement with IndexSearcher#search for the same Sort.
The full argument261 words
  • The class that holds the searcher rewrites. LUCENE-7736 (7.2.0) moved the rewrite into IndexSearcher (IndexSearcher.java:730); its author wrote that Sort.rewrite() "wasn't being called by IndexSearcher or SortRescorer" (#8787, 2017-04-10). A consumer left behind then was fixed as a bug: "LUCENE-7736: CoveringQuery needs to rewrite its ValuesSource." (6b621ea659b on branch_7x and 3f0b240853d on master, 2017-11-22). Earlier, Solr fixed another consumer that holds the searcher in the same way: SOLR-6118, "expand.sort bug for function queries; needed to sort.rewrite(searcher)" (6ffdda4545e, 2014).
  • GroupingSearch holds the searcher. search(searcher, query, ...) is given the IndexSearcher, and groupByDocBlock rewrites its groupEndDocs query against it (GroupingSearch.java:223). Only the Sorts are left out.
  • No precondition is documented. The class javadoc (:41-45), search (:115-129), setGroupSort ("Specifies how groups are sorted", :285-294) and setSortWithinGroup (:296-305) say nothing about pre-rewriting; neither do the package docs, MIGRATE.md or CHANGES.txt.
  • An upstream test expects agreement. BaseGroupSelectorTestCase#testSortGroups sets the same Sort on GroupingSearch and on searcher.search(topLevel, 1, sort) and asserts (assertArrayEquals, :118) what its code comment states: "The top group should have sort values equal to the sort values of the top doc of a top-level search sorted by the same Sort" (BaseGroupSelectorTestCase.java:83-121, comment :112-114); it uses field sorts only.
  • No consistent semantics explains the current answers. One Sort object gives one order through IndexSearcher#search and another through GroupingSearch; a sort IndexSearcher accepts throws in GroupingSearch; and in the silent case the grouped order depends on a Similarity the application never chose.
The devil's-advocate check171 words

The strongest case for "works as designed": callers rewrite Sorts. That was the rule when GroupingSearch was written (LUCENE-3778, 2012, when IndexSearcher did not rewrite either); the REWRITEABLE javadoc and the error message tell you to call Sort.rewrite; upstream TestFunctionQuerySort still calls sf.rewrite(searcher) itself (TestFunctionQuerySort.java:65); Solr's grouping weights its sorts before building the collectors; the low-level collector managers have no searcher; #5619 describes caller-side rewriting as the status quo; and GroupingSearch is @lucene.experimental.

Why it fails: unlike the collector managers, GroupingSearch#search is handed the searcher and already rewrites its own query with it; since 7.2.0 the Lucene class that holds the searcher rewrites, and a missed consumer was fixed as a bug; no documentation states the precondition; and a silent fallback to a different Similarity is not a plausible design. The argument does fairly make the loud forms an API-consistency defect rather than corruption. Two devil's-advocate reviews (one by an independent reviewer from another model family, OpenAI GPT-5.6) rated it moderate and not persuasive; the adjudication agreed.

Suggested fix

Recommended A. Rewrite both Sorts at the start of the two grouping methods (validated)

Rewrite both Sorts against the searcher at the start of groupByFieldOrFunction and groupByDocBlock, into local variables (4 added lines in GroupingSearch.java), so a reused GroupingSearch keeps its configured Sorts. The repro passes with assertions on and off, all 57 grouping-module tests pass, and the loud sorts stop throwing.

// groupByFieldOrFunction, before the first collector manager; groupByDocBlock, after endDocsQuery
final Sort groupSort = this.groupSort.rewrite(searcher);
final Sort sortWithinGroup = this.sortWithinGroup.rewrite(searcher);
All options and trade-offs4 options ยท 517 words

Four options. A was built and validated on main 5adff78bb168; B to D were not built. Workaround until a fix ships: setGroupSort(sort.rewrite(searcher)) and setSortWithinGroup(sort.rewrite(searcher)) once per searcher.

A. Rewrite both Sorts at the start of the two grouping methods (validated) (recommended)

In groupByFieldOrFunction, before the collector managers are built, and in groupByDocBlock, after the groupEndDocs rewrite, add two local variables that shadow the fields. The rest of both methods is unchanged. One file, +4 lines.

// groupByFieldOrFunction, before the first collector manager; groupByDocBlock, after endDocsQuery
final Sort groupSort = this.groupSort.rewrite(searcher);
final Sort sortWithinGroup = this.sortWithinGroup.rewrite(searcher);

Trade-offs

  • Cost: Sort.rewrite returns this when no field changes, so field sorts and relevance get no new objects; a function sort is rewritten once per search, as in IndexSearcher#search. Already-rewritten Sorts are unaffected (a second rewrite is harmless).
  • The returned TopGroups#groupSort / withinGroupSort then hold the rewritten SortFields, as TopFieldDocs#fields does after IndexSearcher#search.
  • GroupingSearch then shares the path IndexSearcher uses for these sorts, with its two defects listed on this site: LFV-770 (a rewritten toSortableLongDoubleValuesSource() sorts on (long) casts) and LFV-567 (a ValueSource sort context shared across the slices of an executor search). Fix LFV-770 with or before this.
  • Subclasses that override the protected grouping methods do not get the rewrite.

What the checks validated

  • Gradle in a scratch worktree of 5adff78bb168, OpenJDK 25.0.3, seed DEADBEEF: stock, the repro fails with assertions on and off; with the fix it passes, on and off.
  • Every test of the grouping module passes with the fix and the repro in place: 15 classes, 57 tests (56 existing + the repro), 0 failures.
  • checkGoogleJavaFormat, ecjLint, forbiddenApisMain and forbiddenApisTest of lucene/grouping pass.
  • With the patched class compiled over the stock jars, the probe's ValueSource#getSortField and fromQuery sorts no longer throw, and the default-Similarity control is unchanged. A second probe on the same two documents, run separately with the fix applied, shows both sorts then return doc1=1.0, doc0=0.8944271802902222 in GroupingSearch, the same as IndexSearcher#search; on the stock jars both throw.
  • On a9779ef3c193 and ea57360912ed the same 4 lines passed the finding's 3 tests, the independent reproducer's 6 tests and the cross-model reviewers' own tests.

B. Document that callers must pass rewritten Sorts

Javadoc on setGroupSort and setSortWithinGroup: "rewrite the Sort against the searcher first".

Trade-offs

No code change, but it keeps the silent trap, ties a configured GroupingSearch to one searcher, and leaves it inconsistent with IndexSearcher#search.

C. Make QueryValueSource fail instead of falling back to a default searcher

Throw when the function context has no searcher, instead of creating a default-Similarity IndexSearcher (QueryValueSource.java:116-123).

Trade-offs

Turns the silent form into a loud one everywhere, not only in GroupingSearch, but changes behaviour for callers that rely on the fallback and does nothing for the loud forms. A separate question for maintainers.

D. Rewrite inside the grouping collector managers

The generic direction of #5619: every consumer rewrites.

Trade-offs

The managers take only a Sort and never see a searcher, so this needs an API change in every grouping manager; much broader than A.

Downstream note

A product that used the group order or group head of a query-function sort as a selection rule would pick a different, still matching document; no such use is known.

How it was found

Semi-formal. A bytecode scan of the stock jars found the grouping collectors' sort-comparator calls with no Sort rewrite on the path from GroupingSearch#search. A bounded-exhaustive comparison then ran 23 Sort consumers with the raw Sort and with it pre-rewritten, over every index of 1-3 tiny documents, 3 Similarities and 18 value sources; GroupingSearch differed, IndexSearcher never did.

The method in full214 words

Method: semi-formal (bounded-exhaustive translation validation) of the APIs that consume a Sort or a values source.

  • A ClassFile scan of the stock jars listed the grouping collectors' getComparator sites (FirstPassGroupingCollector:116, the TopGroups/SearchGroup comparators, AllGroupHeadsCollector:241, BlockGroupingCollector:237) with no Sort rewrite on the path from GroupingSearch#search.
  • A test program on the stock main jars (assertions off) then enumerated every index of 1-3 documents over a 12-value per-document alphabet, in all 1-2 segment splits and deletion masks (42,648 index configurations), x 3 searcher Similarities (BM25, Classic, Boolean) x the 282 applicable pairs of 18 values sources and 23 consumer APIs (some consumers take only double or only long sources): 36,080,208 runs of a consumer fed the raw Sort or source, compared with the same consumer fed it pre-rewritten against the searcher.
  • The GroupingSearch group-sort and within-group consumers were silent (different results, no error) under Classic and Boolean similarity for every legacy query() function source (group heads: under Boolean), and loud (an exception) for fromQuery sources and for the IndexReaderFunctions sources that need the searcher (docFreq, sumTotalTermFreq; termFreq gave the same result either way). The IndexSearcher#search control was never silent.
  • The case was reduced to a 3-test public-API JUnit test (2026-10-27), which failed with assertions on and off.

Prior reports, history and checks

Prior reports

New: no Lucene issue, PR, JIRA entry, list thread or commit, and nothing outside Lucene, describes this defect (searched 2026-10-27/28, re-checked 2026-11-03 and 2026-11-04). Related: #5619 asks generically that users not have to call Sort.rewrite(); SOLR-2642 fixed a similar collector error in Solr's own grouping code in 2011.

Searches3 searches

No report of this defect was found (re-checked 2026-11-03 in two independent passes, and again on 2026-11-04). Related prior items, none of them this defect:

  • #5619 (LUCENE-4553, dsmiley, open since 2012, no comments), "Don't require users to call Sort.rewrite()": a generic request ("user code never calls Query.rewrite(), Lucene will call that one. Shouldn't Sort/SortField rewrite() work the same way?"). It names no consumer and no symptom, and predates IndexSearcher's own rewrite. This defect is GroupingSearch's specific omission, which became an inconsistency only when IndexSearcher started rewriting in 7.2.0, and its silent wrong order.
  • SOLR-2642 (2011, fixed in Solr 3.4), "Sorting by function fails when using result grouping": Solr's own grouping code built a first-pass grouping collector from unweighted function sorts and failed with "Unweighted use of sort". Solr fixed its caller by weighting the sorts first. GroupingSearch was written later (LUCENE-3778, 2012) as separate code; the report has no silent variant. Different code, different defect.
  • #8787 (LUCENE-7736, 2017): design context; it added the IndexSearcher Sort rewrite and names IndexSearcher and SortRescorer, not grouping.
  • SOLR-6118 (2014, 6ffdda4545e), "expand.sort bug for function queries; needed to sort.rewrite(searcher)": Solr's ExpandComponent passed a function sort to its collector without rewriting it and was fixed by adding sort.rewrite(searcher). A different consumer in a different code base, so not this defect; it shows that a consumer holding the searcher that skips the rewrite was treated as a bug.
  • Set aside: #4851 (adds GroupingSearch), #8668, #7958, #8922 (a 2017 review remark on the caller-rewrites convention), #10491, #10060 and SOLR-6203 (other grouping defects or design work).

Same missing rewrite, other fix sites, in this list: LFV-814 (FacetsCollectorManager and DrillSideways sorted search) and LFV-815 (SortRescorer).

Limits: text search cannot rule out a report that describes only the symptom (a wrong grouped order with a function sort) without naming the API; private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits (incl. the migrated JIRA archive); dev@, java-user@, issues@, commits@; JIRA; downstream repositories; web2026-10-27/28313 logged read-only queries (three runs of the prior-report tool, 70 manual GitHub searches, web searches), the changed files of all 410 open PRs, 49 related threads read in fullonly the related items; #16847 and #14996 change GroupingSearch.java for group-head bitsets, #16857 changes Sort/SortField without a consumer-side rewrite
Re-check: the same sources2026-11-03two independent passes over the earlier hitsthe items found earlier are related but about a different defect
apache/lucene, lists, JIRA, downstream, web2026-11-04the prior-report tool again (51 queries incl. 4 for items created since 2026-10-26; web queries run by hand), GitHub searches for items updated since 2026-10-27, the commit history of GroupingSearch.java on upstream main217 hits; nothing new; upstream main f72004515ec2 still has the code (last change 3afffeef5a8d, 2026-07-08, unrelated)

History

Introduced in 7.2.0 by LUCENE-7736 (03afeb7766a, committed 2017-11-15), which made IndexSearcher#search rewrite Sorts but did not touch GroupingSearch, which has used its Sorts as given since 2012. 7.1.0 is consistent; every release run from 7.2.0 to 10.6.0 and main diverges. Not fixed upstream.

Versions tested8 rows

Introduced by LUCENE-7736, "Expose some IndexReader stats via DoubleValuesSources" (commit subject "IndexReaderValues"; apache/lucene#8787; a JIRA patch, no PR; author romseygeek, authored 2017-03-28, committed 2017-11-15): 03afeb7766a on branch_7x (first released in 7.2.0) and 2a4dd499bbd on master (8.0.0). It added sort.rewrite(this) to IndexSearcher (7.1.0's IndexSearcher has none, 7.2.0's has it at line 545) and made DoubleValuesSource sort fields and the asDoubleValuesSource() view get their searcher only through rewrite. GroupingSearch was not changed.

The defect site is older. GroupingSearch has passed its Sorts as given since LUCENE-3778 (89b55566d83, 2012-03-22, first in 4.0.0-alpha), when every consumer, IndexSearcher included, needed a caller-rewritten Sort. Its only rewrite ever is of the groupEndDocs query.

Masking caveat for 7.2.0 to 7.7.1 and 8.0.0. There a Sort that an earlier IndexSearcher#search had rewritten kept that searcher (removed by LUCENE-8726 in 7.7.2 and 8.1.0), which hides the group-order failure when the same Sort object is reused; with separately built Sorts it fails there too. The minimal repro runs GroupingSearch first.

Not fixed upstream: GroupingSearch.java was last changed by 3afffeef5a8d (#16247, 2026-07-08, unrelated); upstream main f72004515ec2 (2026-11-03) has the code of 5adff78bb168.

Versions and branches executed, with results
Version or commitVerdictResult
6.6.6, 7.0.0, 7.1.0 not affectedconsistent: IndexSearcher did not rewrite Sorts either, so both APIs ignore the searcher's Similarity and IndexSearcher itself rejects the ValueSource sort
7.2.0, 7.7.1, 8.0.0 affectedwithin-group order wrong and the ValueSource group sort throws only in GroupingSearch; group order wrong with separately built Sorts
7.7.2, 7.7.3, 8.1.0, 8.11.4, 9.0.0 affectedall three history tests fail
9.1.0, 9.12.3 affectedthe minimal repro fails (one-line scoreDocs field change)
10.0.0, 10.5.2, 10.6.0 affectedthe minimal repro fails, same message
main 5adff78bb168 affectedthe minimal repro fails, assertions on and off (gradle and JUnit runner); the 3-test and 6-test JUnits fail 3 of 3 and 5 of 6
main 5adff78bb168 + fix A fixthe repro passes on and off; 57 of 57 grouping-module tests pass
7.2.0 + fix A commit-level checkwith the fix applied to the 7.2.0 GroupingSearch, all history tests pass

Independent checks

An independent reproducer, executing reviews by a model from another family (OpenAI GPT-5.6) and a devil's advocate each tested it and rated it real; an adjudication confirmed it with high confidence. This doc's runs reproduce it on 5adff78bb168, 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0 and validate the fix with gradle.

Checks and verdicts363 words

The reviews, verifiers and judge ran on main a9779ef3c193 or ea57360912ed, whose GroupingSearch.java is identical to 5adff78bb168's. A re-verification on e1faeba08bde (2026-11-02) and this doc's runs on 5adff78bb168 reproduced it. Every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder foundBounded-exhaustive comparison, then a 3-test public-API JUnit: fails 3 of 3 on main (assertions on and off), 10.5.2 and 9.12.3; passes with the fix. Re-run on 5adff78bb168: 3 of 3 fail.
refute review (OpenAI GPT-5.6, executing) real (high)Own 4-test JUnit on a BooleanSimilarity searcher: the silent within-group mismatch and the loud fromQuery failure fail on main and 10.5.2, its 2 controls pass; with the fix 4 of 4 pass and 15 upstream grouping tests pass.
independent reproducer realOwn 6-test JUnit on a BM25(k1=1.2, b=0) searcher with a self-checked oracle: 5 of 6 fail on main, 10.5.2 and 9.12.3 (within-group order, top group, group head, REWRITEABLE group sort, doc-block fromQuery); the default-Similarity control passes; a mechanism check shows each returned value is a fresh default searcher's score. Re-run on 5adff78bb168: 5 of 6 fail.
intended-behaviour review (OpenAI GPT-5.6, executing) real (high)Steelmanned the caller-rewrites convention and rated it moderate and failing; own 2-test JUnit fails 2 of 2 on main, 10.5.2 and 9.12.3, passes with the fix.
devil's-advocate verifier real (medium-high)Built the five-point case for "intended" and rebutted it; 4 intent probes pass: the caller workaround works, the default Similarity hides the silent case, a double rewrite is harmless, fromQuery throws only in GroupingSearch.
adjudication confirmed (high)Re-ran the reproducer's JUnit with a new seed: 5 of 6 fail with assertions on and off, the control passes.
re-verification on a newer main still reproducesOn e1faeba08bde (2026-11-02): 3 of 3 fail with assertions on and off; the fix applies unchanged and passes, with 24 upstream grouping tests; 10.6.0 fails 3 of 3.
this doc's runs reproduced, fix validatedThe one-method repro fails on 5adff78bb168 (gradle and JUnit runner, on and off) and on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0; with the fix it passes and 57 of 57 grouping tests pass.

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

Finding record

LFV-816, snapshot of 2026-11-06

Registry title
GroupingSearch#search never rewrites its group sort or within-group sort against the searcher, unlike IndexSearcher#search(Query, int, Sort): sorts that need a rewrite throw, and a legacy query() function sort silently orders groups, group documents and group heads with the default Similarity
Area
grouping / GroupingSearch (convenience API)
Priority
Medium, rank 218: GroupingSearch has had the same missing Sort rewrite since 7.2 (a loud failure, silent only on the rare legacy values-source bridge), so it gets the same Medium tier as LFV-815 and LFV-814.
Impact class
silent wrong results
Impact note
Applications that group with GroupingSearch and sort the groups or the documents inside a group by a Sort that needs the searcher are hit at any index size, in every release from 7.2.0 to 10.6.0: in the narrow silent case (a legacy QueryValueSource viewed through asDoubleValuesSource() on a searcher with a non-default Similarity such as ClassicSimilarity or BooleanSimilarity) the group order, group sort values, within-group order and all-group-heads follow default-BM25 scores with no error, and in the loud cases (ValueSource#getSortField, DoubleValuesSource.fromQuery and IndexReaderFunctions sorts) every grouped search throws a 'needs to be rewritten' / 'must be rewritten' error although IndexSearcher#search accepts the same Sort; field sorts, Sort.RELEVANCE and default-Similarity searchers are unaffected, and callers can work around it with setGroupSort(sort.rewrite(searcher)) and setSortWithinGroup(sort.rewrite(searcher)) per searcher.
Summary
You group with GroupingSearch and set a group sort or within-group sort that depends on the query or the searcher, for example a sort on new QueryValueSource(q, 0).asDoubleValuesSource(), and expect the groups and their documents in the order IndexSearcher#search(query, n, sort) gives for the same Sort on the same searcher, because IndexSearcher rewrites the Sort and GroupingSearch#search is handed that searcher. Instead GroupingSearch passes both Sorts to its collectors as given (GroupingSearch.java:148, 160, 201-202, 228-233): on a ClassicSimilarity searcher the documents of one group come back as doc0=0.101, doc1=0.088, doc2=0.088 (default-BM25 scores) instead of doc1=1.0, doc2=1.0, doc0=0.894, the first group is g0 instead of g1, and ValueSource sort fields or fromQuery sorts throw. Rewriting both Sorts against the searcher at the start of groupByFieldOrFunction and groupByDocBlock fixes every case.
Introduced
03afeb7766a39996de3c85e8a6ab24d2a352dd1c on branch_7x (the copy first released, in 7.2.0); master equivalent 2a4dd499bbd2f5ed1f481e17eeaee4f32b927185. LUCENE-7736 'Expose some IndexReader stats via DoubleValuesSources' (apache/lucene#8787; JIRA patch, no GitHub PR; author romseygeek) made IndexSearcher#search(Query, int, Sort) call sort.rewrite(this) (IndexSearcher.java:730 on main) and gave DoubleValuesSource sort fields and ValueSource#asDoubleValuesSource() views their searcher only through rewrite, but did not touch GroupingSearch. A consumer left behind, not a line added to GroupingSearch: its Sort-as-given code dates from LUCENE-3778 (89b55566d83, 2012-03-22, first released in 4.0.0-alpha), when every consumer, IndexSearcher included, needed a caller-rewritten Sort; GroupingSearch's only rewrite in its whole history is of the groupEndDocs query (LUCENE-8242) [exec: release-level bisection, 7.1.0 consistent and 7.2.0 divergent, including with separately built Sort objects, plus the candidate fix applied to the 7.2.0 GroupingSearch, which passes all 4 history tests; no build at the parent commit] (2017-11-15 (LUCENE-7736 committed on master and branch_7x; author date 2017-03-28); first released in 7.2.0 (tag 2017-12-19). GroupingSearch itself: 2012-03-22 (LUCENE-3778))
First affected version
7.2.0 [exec, -ea]: the within-group order diverges from IndexSearcher#search and a ValueSource group sort throws only in GroupingSearch; the group-order test passes there only because WrappedDoubleValuesSource kept the searcher of an earlier IndexSearcher#search in the shared Sort object (removed by LUCENE-8726 in 7.7.2 and 8.1.0), and with separately built Sort objects (TestGroupingSearchFreshSortProbe) it fails on 7.2.0, 7.7.1, 8.0.0 and 8.1.0. 7.1.0 is the last unaffected release [exec]: both APIs agree there, as on 6.6.6 and 7.0.0, because IndexSearcher did not rewrite Sorts either (it rejects the ValueSource sort itself); GroupingSearch does not exist before 4.0.0-alpha.
Affected versions
every release from 7.2.0 to 10.6.0 and main a9779ef3c193 (unreleased 11.0); the released 10.6.0 [exec, 2026-11-04: the one-method repro fails on its jars with -ea and -da]; not fixed upstream (upstream main 20ced71c1e6 has a byte-identical GroupingSearch.java; last upstream change 3afffeef5a8, author and committer date 2026-07-08, #16247, unrelated; checked 2026-10-28). [exec] the finder's JUnit (with API-adapted copies for 9.1-9.12, 8.x/9.0.0 and 6.6.6-7.7.3) fails 3 of 3 on main (-ea and -da), 10.5.2, 10.0.0, 9.12.3, 9.0.0, 8.11.4, 8.1.0, 7.7.3 and 7.7.2, and 2 of 3 on 8.0.0, 7.7.1 and 7.2.0 (group-order test masked, see first_affected_version); the fresh-Sort probe fails on 7.2.0, 7.7.1, 8.0.0 and 8.1.0 and passes on 7.1.0; the independent reproduction's JUnit fails 5 of 6 on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's re-run of the finder's JUnit fails 3 of 3 on main (-ea and -da) and 10.5.2; the cross-model reviewers' own JUnits fail on main (ea57360912ed and a9779ef3c193), 10.5.2 and an API-adapted 9.12.3. [read] the releases between the executed ones, from code identity (IndexSearcher's sort.rewrite(this) unchanged since LUCENE-7736; GroupingSearch never rewrites a Sort). All runs JDK 25 on Linux aarch64.
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
semi-formal: exhaustive check
Upstream and prior reports
related items only, no report of this defect (searched 2026-10-27 and 2026-10-28, re-checked 2026-11-03, read-only): nobody has reported the exception or the silent wrong order through GroupingSearch, and nothing upstream fixes it or is in flight. Related, other defects: #5619 (LUCENE-4553, open since 2012, dsmiley, 0 comments) 'Don't require users to call Sort.rewrite()', a generic request that covers the fix but names no consumer or symptom and predates IndexSearcher's own Sort rewrite; SOLR-2642 (2011, fixed in Solr 3.4) reported the same loud failure ('Unweighted use of sort' from SortField.getComparator <- AbstractFirstPassGroupingCollector.<init>) from Solr's grouping and fixed it in Solr by weighting the sorts before building the collectors; GroupingSearch (LUCENE-3778, 2012) was written without that step. Context, not reports: #8787 (LUCENE-7736), #4851 (LUCENE-3778), #8668 (LUCENE-7617), #7958 (LUCENE-6900), #8922 (LUCENE-7871 review comment on the caller-rewrites convention), #10491 (LUCENE-9451); #10060 (LUCENE-9017) and SOLR-6203 are unrelated grouping bugs. Searched: 313 queries (scripted prior-report searches over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, plus own GitHub searches); the changed files of all 410 open apache/lucene PRs (only #16847 and #14996 change GroupingSearch.java, both for group-head bitsets; #16857 changes Sort.java/SortField.java but adds no consumer-side rewrite); the 53 PRs updated since 2026-10-26 (none touches lucene/grouping or QueryValueSource); upstream commit author and committer dates on GroupingSearch.java (latest 3afffeef5a8, 2026-07-08, unrelated); upstream main 20ced71c1e6 (2026-10-27) unchanged at the fix site
Lucene commit tested
main 5adff78bb168 (2026-11-02): the one-method repro through gradle in a scratch worktree, stock and with the fix, assertions on and off, plus every test of the grouping module with the fix; through the standalone JUnit runner on the stock jars of main (-ea, -da) and of 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0 (2026-11-04). Earlier checks ran on main a9779ef3c193 and ea57360912ed (identical GroupingSearch.java) and on 6.6.6 to 10.5.2 for the version history; a re-verification on e1faeba08bde and 10.6.0 on 2026-11-02.

Lucene and upstream sources

Finding record
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:44 (@lucene.experimental), :148, :160, :201-202 (field/function path: groupSort and sortWithinGroup passed as given), :223 (searcher.rewrite of groupEndDocs), :228-233 (doc-block path), :286-305 (setGroupSort / setSortWithinGroup javadoc, no pre-rewrite requirement)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:730 (sort.rewrite(this))
  • lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:34-36 (client rewrite protocol), :768 ('This DoubleValuesSource must be rewritten')
  • lucene/core/src/java/org/apache/lucene/search/SortField.java:113-117 (Type.REWRITEABLE), :526-528 ('SortField needs to be rewritten')
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/QueryValueSource.java:116-123 (no searcher in the context: a fresh default-Similarity IndexSearcher over the same reader)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:730-733; (at 5adff78bb168) Sort.java:88-99; SortField.java:113-117, 555-557; DoubleValuesSource.java:34-36, 741, 768
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:141-238 (at 5adff78bb168) (148, 160, 173, 175, 201-202, 211, 223, 228, 233, 237)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/FirstPassGroupingCollector.java:116; (at 5adff78bb168) AllGroupHeadsCollector.java:241; BlockGroupingCollector.java:237
  • lucene/queries/src/java/org/apache/lucene/queries/function/ValueSource.java:74-77, (at 5adff78bb168) 184-185, 198-200, 244-245, 369-384; valuesource/QueryValueSource.java:116-123
  • grep -rnE '[sS]ort[A-Za-z]*\.rewrite\(' lucene/*/src/java at 5adff78bb168 (only IndexSearcher.java:730)
Who is affected
  • lucene/grouping/src/test (at 5adff78bb168) (no getSortField, fromQuery or QueryValueSource)
Why it looks unintended
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:41-45, (at 5adff78bb168) 115-129, 223, 285-305
  • lucene/grouping/src/test/org/apache/lucene/search/grouping/BaseGroupSelectorTestCase.java:83-121 (at 5adff78bb168) (comment :112-114, assertArrayEquals :118-119)
  • lucene/queries/src/test/org/apache/lucene/queries/function/TestFunctionQuerySort.java:65 (at 5adff78bb168)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/Sort.java:88-99; (at 5adff78bb168) lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroupsCollector.java:212-214; lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/QueryValueSource.java:116-123
Prior reports
  • GitHub API (read-only, 2026-11-04): issues/5619; commits?path=lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java; search/issues updated:>2026-10-27
History
  • a Lucene clone tags releases/lucene-solr/7.1.0 and 7.2.0: IndexSearcher.java (sort.rewrite at 7.2.0 line 545 only)
  • GitHub API (read-only, 2026-11-04): commits?path=lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java (latest 3afffeef5a8d); main head f72004515ec2

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

Repro files

About the diagrams

Made in parallel with this doc from runs on main 5adff78bb168. It walks through the three-document test reduced from the finding: the two documents of the minimal repro in groups g0 and g1, plus a copy of doc 1 in g0. The minimal repro keeps two documents in one group, which changes the BM25 values (0.147 and 0.125 instead of 0.101 and 0.088), not the failure. The PNG is the summary still.

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.