Each slice of the index picks its own best groups and reports the best score among them. The merge keeps the largest of those numbers before it decides which groups to return, so a group you never see can set TopGroups.maxScore.
- Block grouping. When each group's documents are indexed together as one block (
IndexWriter.addDocuments),GroupingSearch(Query groupEndDocs)groups the hits in one pass. The result,TopGroups, holds a page of groups and a top-levelmaxScore. - Slices. An
IndexSearchercreated with an executor splits the index into slices (by default at most 5 segments each, and a slice closes once it passes 250,000 documents), runs one collector per slice and merges their results. Since 10.5.0 block grouping takes this path, throughBlockGroupingCollectorManager. TopGroups.maxScoreis documented as the highest score across all hits. Block grouping before 10.5.0, and two-pass grouping, report the best score among the returned groups.
v = 1 but keeps the larger number, 2.0. Select the still to open it at full size. Generated with AI, checked against the code.- You index six one-document groups as six segments, with a numeric field
v= 1, 3, 4, 5, 6, 2 that is also each document's score. Block grouping (GroupingSearch(Query groupEndDocs)) sorts groups byvascending and returns the top 1 group:v = 1, score 1.0. - You run it on
new IndexSearcher(reader)and onnew IndexSearcher(reader, executor). The default slicing (at most 5 segments per slice; a slice also closes once it passes 250,000 documents) gives the second searcher two slices:v= 1, 3, 4, 5, 6 andv= 2. - You expect the same
TopGroups.maxScoreboth times: 1.0, the returned group's best score, as the executed releases before 10.5.0 (10.4.0 and 9.12.3) report (or 6.0, the javadoc's "highest score across all hits"). - Instead the sliced search reports 2.0. Each slice keeps its own top group and reports its score (1.0 and 2.0), and the merge keeps the larger number although it returns only
v = 1. Same group, same documents, no warning.
Applications that use block grouping (GroupingSearch(Query groupEndDocs) or BlockGroupingCollectorManager, both experimental) with a field group sort and read TopGroups.maxScore. It takes an executor whose default slicing gives more than one slice (more than 5 segments, or segments past the 250,000-documents-per-slice limit), or groupOffset > 0. Groups, documents and hit counts stay correct; the wrong value looks plausible.
Mechanism, terms and numbers758 words
Since 10.5.0, GroupingSearch block grouping (new GroupingSearch(Query groupEndDocs)) runs through BlockGroupingCollectorManager, so on an IndexSearcher with an executor every slice collects its own top groupOffset + topNGroups groups and TopGroups.mergeBlockGroups merges them. Each slice reports a maxScore over the groups it kept, and for a field group sort the merge takes the maximum of those values before its k-way merge decides which groups are returned. A group that one slice kept but the merge drops can therefore set the top-level TopGroups.maxScore, and the value changes with how segments fall into slices.
In the minimal test the same query on the same six-segment index reports 1.0 sequentially and 2.0 with the default two slices, for the same single returned group (best score 1.0); the best hit overall scores 6.0. The executed releases before 10.5.0 (10.4.0 and 9.12.3) report one value with or without an executor, and two-pass grouping takes the maximum over the returned groups. Only this one field is wrong: the groups, their documents and order, each GroupDocs.maxScore and the hit counts are correct. Same root cause, secondary: with groupOffset > 0, block grouping now also counts the skipped groups, even without an executor. All of this is at withinGroupOffset 0; with withinGroupOffset > 0 the sibling bug LFV-230 can also lower a group's value.
// BlockGroupingCollectorManager.java:141-144: every slice keeps groupOffset + topNGroups groups
return new BlockGroupingCollector(groupSort, groupOffset + topNGroups, needsScores, lastDocPerGroup);
// BlockGroupingCollectorManager.java:148-159: each slice reports all its kept groups (offset 0), then the merge
collector.getTopGroups(withinGroupSort, 0, withinGroupOffset, maxDocsPerGroup); // :151-152
return TopGroups.mergeBlockGroups(shardGroupsList, groupSort, groupOffset, topNGroups, withinGroupSort);
// TopGroups.java:404-437, mergeBlockGroups
for (...) { // :407-417, fill the queue
if (!groupSortByRelevance) {
totalMaxScore = nonNANmax(totalMaxScore, topGroups.maxScore); // :412-414, every slice's value
} ...
}
while (!queue.isEmpty()) { // :425-446, k-way merge
...
if (count++ >= groupOffset) { groupDocsList.add(currentGroupDocs); ... } // :431-437, the page
}- Per slice. Each slice's collector keeps its top
groupOffset + topNGroupsgroups, andreduceasks for all of them (offset 0). The slice'sTopGroups.maxScoreis the maximum over those groups (BlockGroupingCollector.java:339-341), including groups that the merge later drops or skips. - Merge. For a field group sort,
mergeBlockGroupstakes the maximum of every slice'smaxScorewhile it fills the queue, before the k-way merge picks the page. - Result. A group that some slice kept but the merge does not return can set the value. Which groups a slice keeps depends on which segments share a slice; the default slicing gives more than one slice once there are more than 5 segments (
IndexSearcher.java:108,MAX_SEGMENTS_PER_SLICE = 5) or segments that pass the 250,000-documents-per-slice limit (:106,:361-363,:374-378; two segments of 250,001 documents give 1.0 vs 2.0 the same way). Several slices only enable the trigger: the value is wrong when some slice keeps a group that is not returned and scores higher than every returned group. - Contrast. The two-pass merge,
TopGroups.merge, takes the maximum over the merged groups (TopGroups.java:290). Before 10.5.0,GroupingSearchran oneBlockGroupingCollectorthroughIndexSearcher.search(Query, Collector), which visits segments in sequence even with an executor.
The merge formula would be exact if every slice reported a true "highest score across all hits" (TopGroups.java:59); its only non-test in-tree call path, BlockGroupingCollectorManager, feeds it BlockGroupingCollector results, which report the maximum over the groups each slice kept. The defect is that mismatch.
| segments (v) | groups kept (top 1) | slice TopGroups.maxScore | |
|---|---|---|---|
| slice 1 | 1, 3, 4, 5, 6 | v = 1 | 1.0 |
| slice 2 | 2 | v = 2 | 2.0 |
| merged | returns v = 1 only | 2.0 |
The sequential search (one slice) reports 1.0; the best hit overall scores 6.0. Any split into two or more slices fails the same way, because the slice without v = 1 contributes its own top group's score, at least 2.0.
Because each slice reports with offset 0, the groups before the requested page count even with a single slice. Single-document groups P (sort rank 1, score 5), Q (rank 2, score 1) and R (rank 3, score 9), groupOffset = 1, top 1 returns Q: main and 10.5.2 report 5.0, from the skipped group P; 10.4.0 and 9.12.3 report 1.0. Taken alone this moves towards the javadoc's "across all hits", so it is a behaviour change rather than a second bug.
Not affected: a relevance group sort at groupOffset 0, where the slice holding the top group also holds the best hit (TopGroups.java:419-421).
Exposure and likelihood247 words
- Who. Applications that index groups as document blocks and use block grouping,
GroupingSearch(Query groupEndDocs)orBlockGroupingCollectorManagerdirectly (both@lucene.experimental), with a field group sort, and read the top-levelTopGroups.maxScore, for example to normalise scores or to apply a relative score cut-off.- With an executor: whenever the default slicing gives more than one slice (more than 5 segments, or segments that pass the 250,000-documents-per-slice limit; two segments of 250,001 documents are enough), and a slice keeps a higher-scoring group that is not on the page; there is no setting to change. A thread pool is not needed: a same-thread executor (
Runnable::run) takes the same path. - Without an executor: only with
groupOffset > 0(the secondary symptom).
- With an executor: whenever the default slicing gives more than one slice (more than 5 segments, or segments that pass the 250,000-documents-per-slice limit; two segments of 250,001 documents are enough), and a slice keeps a higher-scoring group that is not on the page; there is no setting to change. A thread pool is not needed: a same-thread executor (
- Not affected. The groups, their documents and order,
GroupDocs.maxScore,totalHitCountandtotalGroupedHitCount; two-pass grouping (by field or function); a relevance group sort atgroupOffset0. - Silent and unstable. The value is a real score of a matching document, no error is raised, and it can change whenever segments are flushed or merged, so the same query on the same documents can give different numbers over time.
- No scale needed. Six one-document segments show it.
- How likely: rare. A code search of the default branches of Solr, Elasticsearch and OpenSearch found no use of block grouping's collector, manager or merge; Lucene itself never reads the field to make a decision; it has shipped since 10.5.0 (June 2026), and no user report was found in the recorded searches.
One JUnit test on the public API (grouping and test-framework modules): six one-document segments, GroupingSearch(Query groupEndDocs) with a group sort on v, top 1 group, run once without and once with a same-thread executor. It asserts that TopGroups.maxScore is the same both times.
/*
* 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.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.NoMergePolicy;
import org.apache.lucene.index.Term;
import org.apache.lucene.queries.function.FunctionScoreQuery;
import org.apache.lucene.search.DoubleValuesSource;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestBlockGroupingMaxScoreSlicing extends LuceneTestCase {
public void testMaxScoreDoesNotDependOnSlicing() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
IndexWriterConfig iwc = new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE);
try (IndexWriter w = new IndexWriter(dir, iwc)) {
for (int v : new int[] {1, 3, 4, 5, 6, 2}) {
Document doc = new Document();
doc.add(new NumericDocValuesField("v", v));
doc.add(new StringField("groupEnd", "x", Field.Store.NO));
w.addDocument(doc);
w.flush();
}
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
GroupingSearch gs = new GroupingSearch(new TermQuery(new Term("groupEnd", "x")));
gs.setGroupSort(new Sort(new SortField("v", SortField.Type.INT)));
DoubleValuesSource score = DoubleValuesSource.fromIntField("v");
Query query = new FunctionScoreQuery(MatchAllDocsQuery.INSTANCE, score);
float sequential = gs.search(new IndexSearcher(reader), query, 0, 1).maxScore;
float sliced = gs.search(new IndexSearcher(reader, Runnable::run), query, 0, 1).maxScore;
// expected 1.0 (top group v=1) with 6 segments in 2 slices too; actual 2.0 (group v=2)
assertEquals("TopGroups.maxScore, 1 slice vs 2 slices", sequential, sliced, 0f);
}
}
}
}
cd $LUCENE && ./gradlew :lucene:grouping:test --tests TestBlockGroupingMaxScoreSlicing -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
TopGroups.maxScoreis 1.0 with one slice and with two slices: the only returned group,v = 1, scores 1.0.- Observed
TopGroups.maxScore, 1 slice vs 2 slices expected:<1.0> but was:<2.0>, with assertions on and off.
More files, environment, full output208 words
Deterministic, single thread (Runnable::run is the executor: the defect is the per-slice merge, not concurrency), no random() in the test body; seeds DEADBEEF and 5EED1234 give the same values with assertions on and off. The scores come from FunctionScoreQuery over DoubleValuesSource.fromIntField("v"), so each document's score is its sort value and every number is exact.
It asserts only that the value does not depend on the slicing, so it is fix-neutral: it passes with fix A (both searchers 1.0) and with fix B (both 6.0), and is the regression test for either. It does not show the secondary groupOffset > 0 symptom: with v as both sort key and score, skipped groups always score lower.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with either fix: passes. Also fails on the 10.6.0 and 10.5.0 release jars; passes on 10.4.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; nothing platform-specific is involved) |
cp $REPRO/TestBlockGroupingMaxScoreSlicing.java $LUCENE/lucene/grouping/src/test/org/apache/lucene/search/grouping/
cd $LUCENE && ./gradlew :lucene:grouping:test --tests TestBlockGroupingMaxScoreSlicing -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false
TestBlockGroupingMaxScoreSlicing > testMaxScoreDoesNotDependOnSlicing FAILED
java.lang.AssertionError: TopGroups.maxScore, 1 slice vs 2 slices expected:<1.0> but was:<2.0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:E32C53693FF4A61F]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:577)
at org.apache.lucene.search.grouping.TestBlockGroupingMaxScoreSlicing.testMaxScoreDoesNotDependOnSlicing(TestBlockGroupingMaxScoreSlicing.java:62)
1 test completed, 1 failed- Releases before 10.5.0 and two-pass grouping give one value per query and index; 2.0 is neither the returned groups' best (1.0) nor the javadoc's "highest score across all hits" (6.0).
- #15936 added the merge to parallelise block grouping; its review chose the cross-slice maximum without considering groups the merge drops.
- No grouping test checks block grouping's
TopGroups.maxScore.
The full argument240 words
- One query, one index, one value. Every existing definition gives a single value: the javadoc's "Highest score across all hits, or Float.NaN if scores were not computed" (
TopGroups.java:59; 6.0 in the repro), or the maximum over the returned groups, which two-passTopGroups.mergecomputes (TopGroups.java:290) and block grouping reported in 10.4.0 and 9.12.3 (1.0). None allows a number that changes with the executor's slicing (2.0). - #15936 was a parallelisation change.
lucene/CHANGES.txt:1009, under 10.5.0: "GITHUB#15936: Introduce BlockGroupingCollectorManager to parallelize search when using BlockGroupingCollector." Nothing records a change tomaxScore. - Intent in the review. On #15936 javanna asked (2026-05-19) "is the maxScore only read once from the first group?", and gaobinlong replied (2026-05-25) "This is really a bug, we need to compute maxScore by iterating on all TopGroups, only for the case of sort by relevance, we can get the maxScore directly from the head of the queue after initialized." That fixed an earlier first-group-only value; nobody said that a slice's
maxScorealso covers groups the merge drops, or that the value may change with slicing. - Grouping maxScore errors have been fixed as bugs before: LUCENE-8996 (#10039, distributed maxScore NaN) and #15872/#15873 (NaN maxScore).
TestTopGroups.java:207-215asserts the exact mergedmaxScoreofmerge(). - Why tests missed it. No grouping test asserts block grouping's
TopGroups.maxScore; #15936's tests do not read it, andTestGrouping's per-group check is a commented-out// TODO // assertEquals(expectedGroup.maxScore, actualGroup.maxScore);(TestGrouping.java:1632-1633).
The devil's-advocate check212 words
The strongest case for "intended" (the intended-behaviour check and the cross-model reviewer rated it moderate): the cross-slice maximum was chosen on purpose, and it matches the javadoc's "across all hits". If every slice reported a true all-hits maximum the merge would be exactly right, and the reviewer's direct-input test shows that stock mergeBlockGroups keeps such a value. The javadoc has never held literally for block or two-pass grouping, so maxScore is loosely defined; no test asserts it; the classes are @lucene.experimental; and the finder's first test overrode slices().
Why it fails: the premise is false for the merge's only non-test in-tree caller, BlockGroupingCollectorManager, whose slices report the maximum over an arbitrary, slicing-dependent set of kept groups; the result (2.0) is neither all hits (6.0) nor the page (1.0). "Loosely defined" still means one value per query and index. "Experimental" is about API stability; no javadoc promises that the value is the same for every slicing either, so the fair reading is a likely correctness defect whose intended meaning upstream must pick (the cross-model reviewer's point). The minimal test uses the default slicing, and its scores and sort values are distinct, so ties play no part. Verdict: not intended, a defect; which consistent meaning the fix restores is upstream's call.
Recommended Take maxScore from the returned groups inside the k-way merge
Take TopGroups.maxScore from the groups the merge actually returns, as TopGroups.merge does and as block grouping did before 10.5.0 (+2/-8 lines in TopGroups.mergeBlockGroups). Both searchers then report 1.0, and the repro and all 57 grouping tests pass. Alternative: track an all-hits maximum in the collector (javadoc-literal: 6.0).
while (!queue.isEmpty()) {
...
if (count++ >= groupOffset) {
groupDocsList.add(currentGroupDocs);
// like merge(): the maximum over the groups that are returned
totalMaxScore = nonNANmax(totalMaxScore, currentGroupDocs.maxScore());
totalGroupedHitCount += (int) currentGroupDocs.totalHits().value();
...All options and trade-offs2 options · 641 words
Both options were applied to a scratch worktree of 645d4fe14a20 and run with gradle (OpenJDK 25.0.3, 2026-10-31). Which one is right is upstream's call: A restores what the executed releases before 10.5.0 (10.4.0, 9.12.3) and two-pass grouping report; B makes the javadoc's "highest score across all hits" literally true. No javadoc says which meaning grouped results should have, so the choice is open.
What each searcher reports on the minimal test's index (a printing probe on the same index and query, not a repro):
| build | slices | returned group | GroupDocs.maxScore | TopGroups.maxScore |
|---|---|---|---|---|
stock 645d4fe14a20 | 1 | v = 1 | 1.0 | 1.0 |
stock 645d4fe14a20 | 2 | v = 1 | 1.0 | 2.0 |
| fix A | 1 / 2 | v = 1 | 1.0 | 1.0 / 1.0 |
| fix B | 1 / 2 | v = 1 | 1.0 | 6.0 / 6.0 |
The best hit overall scores 6.0.
Take maxScore from the returned groups inside the k-way merge (validated by the checks) (recommended)
+2/-8 lines in TopGroups.mergeBlockGroups: drop the pre-merge aggregation (groupSortByRelevance, the nonNANmax over every slice's maxScore, the relevance branch) and take the maximum while the k-way merge fills the page.
while (!queue.isEmpty()) {
...
if (count++ >= groupOffset) {
groupDocsList.add(currentGroupDocs);
// like merge(): the maximum over the groups that are returned
totalMaxScore = nonNANmax(totalMaxScore, currentGroupDocs.maxScore());
totalGroupedHitCount += (int) currentGroupDocs.totalHits().value();
...Trade-offs
- Matches
TopGroups.mergeand the pre-10.5 value with and without an executor, and also undoes thegroupOffset > 0change (1.0 again in the P/Q/R case, as on 10.4.0). A relevance group sort at offset 0 keeps its value. - Does not make the javadoc's "Highest score across all hits" true (no release did); upstream may want to reword it for grouped results (not drafted).
mergeBlockGroupsis public: a direct caller whose inputs already carry all-hits maxima would now get the page maximum (the cross-model reviewer's direct-input test: expected 25.0, got 1.0). No in-tree caller passes such inputs.- It reads
GroupDocs.maxScore, so withwithinGroupOffset > 0and relevance order it inherits the per-group value of the sibling bug LFV-230 until that is fixed (the stock code does too).
What the checks validated
- Stock
645d4fe14a20: the minimal test fails with assertions on and off. - With A:
./gradlew :lucene:grouping:test(the whole module, the minimal test included) passes: 15 classes, 57 tests, 0 failures, seed DEADBEEF with assertions on and seed C0FFEE with assertions off; this includesTestBlockGrouping,TestGroupingSearch,TestGroupingandTestTopGroups. The probe gives 1.0 for both searchers. - Earlier, on
ea57360912ed: the finder's, the independent reproduction's, the intended-behaviour check's and the cross-model reviewer's own tests pass with A.
+4/-3 lines in BlockGroupingCollector: a field updated where each hit's score is recorded, and the per-slice maxScore set from it. The existing merge then takes the maximum of true all-hits maxima and is exact.
private float maxScoreAllHits = Float.NaN; // new field
// collect(): where each hit's score is recorded
pendingSubScores[subDocUpto] = scorer.score();
maxScoreAllHits = TopGroups.nonNANmax(maxScoreAllHits, pendingSubScores[subDocUpto]);
// getTopGroups(): replaces "if (groupSortByRelevance) maxScore = groups[0].maxScore();"
maxScore = needsScores ? maxScoreAllHits : Float.NaN;Trade-offs
- Makes
TopGroups.maxScoreliterally "highest score across all hits" and leavesmergeBlockGroupsunchanged for direct callers. - Behaviour change for every block-grouping user, sequential ones included: the minimal test's value goes from 1.0 (every executed release, sequentially) to 6.0, a score from a group that is not on the page, and the P/Q/R
groupOffset = 1case to 9.0. Applications that normalise bymaxScoresee different numbers after upgrading. - Independent of the per-group sibling bug. One extra
maxper collected hit when scores are needed; not benchmarked.
What the checks validated
- With B: the whole grouping module passes: 15 classes, 57 tests, 0 failures, seed DEADBEEF with assertions on and seed C0FFEE with assertions off. The probe gives 6.0 for both searchers. Upstream's suites had not been run with B before.
Downstream: a code search of the default branches of Solr, Elasticsearch and OpenSearch found no use of block grouping, so the reach is applications that call it directly.
A bounded-exhaustive harness ran every grouping configuration over all tiny indexes and compared sequential with concurrent search, and both with a reference written from the javadoc. It flagged a different TopGroups.maxScore for the same returned group; the failing case was then minimised by hand into a public-API JUnit.
The method in full220 words
Method: semi-formal (bounded-exhaustive translation validation: the real jar is checked against exact semantics on every input up to a small scope; no model checker, SMT solver or proof is involved).
- A hunt unit (grouping) built tiny indexes with groups in {A, B, none} and values {0, 1, 2} used as score and sort key, tried every split into segments and 7 slicings, and every group sort x within-group sort x group page x document page. It compared each result with an exact reference written from the javadoc, with sequential search and with a distributed merge.
- The first signal, on the stock main jar: kind
BSEQ_VS_CONC:TG_MAXSCORE_RETin scope block3 (same returned group, sequential maxScore 1.0, concurrent 2.0, distinct sort values, so not a tie effect). A focused probe then showed the concurrent value matches neither the returned groups nor all hits. - Scope block5 (19,683 indexes, 13,988,052 checks, 29 s) reports that kind 155,919 times (raw counts are not split by root cause). With the unit's combined fix sketches shadowed, the sequential-vs-concurrent, reference and distributed maxScore kinds disappear in block5 and block6.
- The finder wrote a public-API JUnit (three one-document blocks in two segments, one slice per segment); the independent reproduction and the intended-behaviour check showed the default slicing suffices; it was then minimised by hand into the one-test repro above (2026-10-29).
No prior report found: GitHub issues and PRs (including the migrated JIRA archive), commits, mailing lists, downstream projects and the web, searched on 2026-10-26 and re-checked on 2026-10-31. The closest item is the #15936 review thread, which chose the cross-slice maximum without considering unreturned groups.
Searches2 searches
- Closest item, not this bug: the review thread of #15936 (2026-05-19/25). It fixed a different, earlier first-group-only defect in the same method and produced today's code; nobody there discusses groups the merge drops,
groupOffsetor slicing, and the PR's tests do not assertTopGroups.maxScore. The review thread is the closest record of what the code is meant to compute. - Related, different bugs: #15942 (cause of the sibling LFV-230), #15879 and open #16569 (per-group maxScore on the two-pass paths), #15872/#15873 (two-pass NaN maxScore, fixed), #15995 (TopGroupsCollectorManager ignored withinGroupOffset, fixed), LUCENE-8996/#10039 (shard-merge NaN maxScore, 2019), #14855 (function grouping with more than one slice). No open PR touches
mergeBlockGroupsorBlockGroupingCollectorManager. - Limits: GitHub search is token-based; the 2026-10-26 scan of recent issues read titles and bodies, not comments; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs, commits and code (apache/lucene incl. the JIRA archive, downstream projects), the dev, java-user, issues and commits lists, JIRA, web | 2026-10-26 | 207 recorded queries and API reads with code, symptom and history terms; direct reads of 13 PRs and 7 issues with comments and reviews, 5 cross-reference timelines, the four files' history on main, branch_10x, branch_10_6 and branch_10_5, the changed files of all 413 open PRs, and all 1,189 issues and PRs updated since 2026-04-01 | no report; 21 related hits, all a different matter |
| GitHub issues and PRs, upstream main commits | 2026-10-31 | searches for mergeBlockGroups, BlockGroupingCollectorManager, "TopGroups maxScore" and "block grouping maxScore"; commits to the four grouping files on main since 2026-10-20 | only #16302, #16305 and #15936 (none a report) and LUCENE-4077 (2012, unrelated); no new commit to the files |
Introduced by #15936 (d27d080d1013, merged 2026-06-15; 10.x line 2e00e95ace6) and first released in 10.5.0. 10.5.0 to 10.6.0 and main fail (executed); 10.4.0 and 9.12.3 pass. Reverting only #15936's GroupingSearch change makes both symptoms disappear.
Versions tested6 rows
Introduced by d27d080d1013 (apache/lucene#15936, "Introduce BlockGroupingCollectorManager", by gaobinlong, approved and merged by javanna on 2026-06-15) and the same change on the 10.x line, 2e00e95ace6. git log --all -S 'mergeBlockGroups' returns only these two commits (one per line), and git blame attributes the defect path to it: TopGroups.java:404-421, BlockGroupingCollectorManager.java:141-160, and in GroupingSearch.groupByDocBlock the manager construction and searcher.search(query, bcm) (GroupingSearch.java:226-237; the method's older lines come from earlier commits). The per-slice value it aggregates is older (LUCENE-4077, 2012; reorganised by #15942) and correct for a single collector.
Causal check: 10.5.0 and main with only #15936's GroupingSearch hunk reverted pass, for both symptoms, with #15942 kept (on 645d4fe14a20 the offset test and the independent reproduction's default-slicing test pass with the revert, while stock fails 5.0 vs 1.0). 2e00e95ace6 is an ancestor of the 10.5.0, 10.5.1 and 10.5.2 tags and not of 10.4.0. Upstream main 2f424fd428da (2026-10-30) has the same code.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails with assertions on and off (gradle and standalone runner); passes with fix A or fix B |
| 10.6.0 | affected | the minimal test fails (release jars, SHA-1 checked) |
| 10.5.2, 10.5.1 | affected | the hunt's tests fail (10.5.2 also with assertions off): 1.0 vs 5.0 |
| 10.5.0 | first release | the minimal test and the hunt's tests fail; the groupOffset = 1 case gives 5.0 |
| 10.4.0 | not affected | the minimal test passes; one value with and without an executor, and 1.0 for the groupOffset = 1 case |
| 9.12.3 | not affected | the version-neutral tests pass (sequential 1.0, concurrent 1.0; groupOffset = 1: 1.0) |
An independent reproducer, a devil's-advocate verifier, an adjudication, an executed cross-model review (OpenAI GPT-5.6) and a security probe all confirmed it; a read-only GPT-5.6 refute review could not run code and called its own verdict procedural. This doc re-ran the repro and validated both fixes with gradle.
Checks and verdicts496 words
The only cross-model review that ran code is the intended-lens one; the refute lens was read-only. All other execution evidence comes from the hunt's own AI tools and separate runs.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit, bounded-exhaustive grouping harness, then JUnit) | candidate | First signal sequential 1.0 vs concurrent 2.0 in scope block3; the JUnit fails on main (assertions on and off), 10.5.0 and 10.5.2 and passes with either fix sketch. |
| Cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (high) | Its own five-group test fails on main and 10.5.2 (sequential 7, concurrent 20, all-hit maximum 50) and passes on 10.4.0 and with fix A; fix A passes TestBlockGrouping, TestGroupingSearch and TestGrouping's testRandom; its direct-input test shows fix A drops an all-hits maximum (25.0 vs 1.0). |
| Independent reproducer | real | Own 5-test suite fails 3 of 5 on main (on and off), 10.5.0 and 10.5.2 (1.0 vs 5.0 with the default slicing); version-neutral tests pass on 10.4.0 and 9.12.3; groupOffset = 1 gives 5.0 vs 1.0; both fixes pass 5 of 5. |
| Refute lens (OpenAI GPT-5.6, read-only) | not real, by procedure (low) | The reviewer could not run code and wrote "This is not a substantive refutation: static evidence strongly favors a real defect". The cross-model review AI verifier then wrote and ran its own test (the reviewer did not): it fails on main with assertions on and off (seq 3.0, conc 7.0, all-hit maximum 10.0) and both controls pass. |
| Devil's advocate | real (moderate-high) | Its default-slicing test fails on main and 10.5.2 (10.5.2: the follow-up's re-run of the intended-behaviour check's test) and passes on 10.4.0; no intended-behaviour argument survived; the fix direction stays open. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's suite with fresh seeds, assertions on and off: 3 of 5 fail, both controls pass. |
| Security assessment | none | 21,600 sequential/concurrent search pairs with a tenant filter and deleted blocks: 3,366 differed, none reported a score from another tenant or a deleted document; a control without the filter fails as it should. |
| Current-main re-run (2026-10-26) | still reproduces | The finder's test on a9779ef3c193 fails with a byte-identical message. |
| Minimal repro (2026-10-29) | verified | Fails on 645d4fe14a20 with assertions on and off, seeds DEADBEEF and 5EED1234; passes with fix A and with fix B. |
| Fact checks (2026-10-31) | real | Re-ran the repro (gradle and standalone runner, assertions on and off), both fixes, and the 10.6.0, 10.5.0 and 10.4.0 jars. A new probe with two segments of 250,001 documents gives 2 default slices and 1.0 vs 2.0 (fix A: 1.0 / 1.0), so large segments trigger it too. On 645d4fe14a20 reverting only #15936's GroupingSearch hunk makes both symptoms pass. Wording fixes are listed. |
| This doc (2026-10-31) | real | Gradle in a scratch worktree: stock fails (on and off); fix A and fix B each pass the whole grouping module twice (57 tests); 10.6.0 and 10.5.0 fail, 10.4.0 passes; a value probe on stock, A and B. |
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.
- PNG 529 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,449 words
- Registry title
- With an executor, block grouping's TopGroups.maxScore includes groups that are not returned, so it changes with the index slicing (and with groupOffset > 0 it counts the skipped groups); regression in 10.5.0
- Area
- grouping / block grouping (TopGroups.mergeBlockGroups and BlockGroupingCollectorManager.reduce; GroupingSearch(Query groupEndDocs) on a searcher with an executor)
- Priority
- Low, rank 763: Since 10.5.0 block grouping with a field group sort on a multi-slice searcher takes TopGroups.maxScore over groups that are not returned, so the value changes with the slicing, but only that top-level score field is wrong, nothing in Lucene reads it, and the credibility audit downgraded the row.
- Impact class
- silent wrong results
- Impact note
- Hits applications on 10.5.0-10.5.2 and main that use block grouping (GroupingSearch(Query groupEndDocs) or BlockGroupingCollectorManager, both @lucene.experimental) with a field group sort and read the top-level TopGroups.maxScore: on an IndexSearcher with an executor that yields more than one slice (the default slicing does once the index has more than 5 segments) the value can come from a group that is not returned and changes with how segments fall into slices, and with groupOffset > 0 it also counts the skipped groups even without an executor; the returned groups, their documents and order, GroupDocs.maxScore and the hit counts stay correct, a relevance group sort at groupOffset 0 is not affected, and no scale is needed.
- Summary
- You index each group as a document block and run block grouping (GroupingSearch(Query groupEndDocs)) with a field group sort on an IndexSearcher that has an executor. You expect TopGroups.maxScore to be the same number whether the search runs sequentially or concurrently: sequential block grouping and two-pass term grouping report the best score of the returned groups, and the javadoc says 'Highest score across all hits'. Since 10.5.0 the concurrent value is the maximum over every group that any slice kept, including groups that are not returned, so it changes with the slicing (sequential 1.0, concurrent 5.0 from an unreturned group, best hit 9.0 in the repro), while the returned groups and documents are correct.
- Introduced
- d27d080d1013fe86519a9843b221bada6c9466e1 (main) and the same change on the 10.x release line, 2e00e95ace619c697b499f6b3a55eb8476acf061: GITHUB#15936 'Introduce BlockGroupingCollectorManager' by gaobinlong, approved and merged by javanna [read: git log -S 'mergeBlockGroups' has this single hit and git blame in the history clone attributes every line on the defect path (TopGroups.java:404-421, BlockGroupingCollectorManager.java:141-160, GroupingSearch.groupByDocBlock) to it; exec: 10.4.0 passes, 10.5.0 fails, and 10.5.0 and main with only #15936's GroupingSearch hunk reverted pass]. The per-slice maxScore it aggregates is older (LUCENE-4077, 2012, reorganised by #15942) and correct for a single collector; #15942 is not a cause (the revert runs keep it and pass). (2026-06-15 (merged on main 09:29:25Z; the 10.x-line commit the same day))
- First affected version
- 10.5.0 [exec] (release tag commit f6eaee8148b7, commit date 2026-06-19; 2e00e95ace6 is an ancestor of the 10.5.0, 10.5.1 and 10.5.2 tags and not of 10.4.0); 10.4.0 is the last release without it [exec]
- Affected versions
- 10.5.0, 10.5.1 and 10.5.2 (every 10.5.x release) and main ea57360912ed [exec]: the finder's JUnit fails on all four (10.5.2 and main also with -da); the independent reproduction's suite fails 3 of 5 on main (-ea and -da), 10.5.0 and 10.5.2; the version-neutral default-slicing GroupingSearch repro fails on 10.5.0, 10.5.2 and main; the intended-behaviour check's default-slicing repro fails on main (-ea and -da) and 10.5.2. branch_10_6 (10.6.0, released 2026-10-29) has method-for-method identical code [read, GitHub API, not executed]. Not affected [exec]: 10.4.0 and 9.12.3 (version-neutral repros; the manager and mergeBlockGroups do not exist there, and GroupingSearch block grouping ran one sequential collector). Older releases are unaffected by construction but were not executed. All runs on aarch64 with JDK 25.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-26, read-only; follow-up prior-report record verdict new: 207 recorded queries and API reads, among them scripted prior-report search term sets A (code terms), B (symptom terms) and C (history terms) over apache/lucene issues and PRs (36 queries), commits (6), downstream repositories (4), the dev@, java-user@, issues@ and commits@ lists (100), the JIRA archive (20) and the web (25 of 26 planned), plus direct GitHub API reads of 13 PRs and 7 issues with their comments and reviews, 5 cross-reference timelines, the commit history of the four files on main, branch_10x, branch_10_6 and branch_10_5, all 413 open PRs by changed file and all 1,189 issues and PRs updated since 2026-04-01; 21 hits read with relevance; the finder's earlier 10-query run and the intended-behaviour check's own searches found nothing either). Closest item, not this bug: the review thread of #15936 (2026-05-19/25), where a reviewer asked whether maxScore was read only from the first group and the author changed it to the current maximum over every shard's maxScore; nobody there discusses that a slice's maxScore also covers groups the merge drops, groupOffset or slicing, and the PR's tests do not assert TopGroups.maxScore. Related, different bugs: #15942 (cause of the sibling within-group-offset bug), #15879 and open #16569 (per-group maxScore on the two-pass paths), #15872/#15873 (two-pass NaN maxScore, fixed), #15995 (TopGroupsCollectorManager ignored withinGroupOffset, fixed), LUCENE-8996/#10039 (shard-merge NaN maxScore, 2019), #14855 (exceptions with function grouping and more than one slice). Not fixed upstream: no commit on main, branch_10x, branch_10_6 or branch_10_5 changes the maxScore computation in mergeBlockGroups or BlockGroupingCollectorManager after #15936, and none of the 8 open PRs touching lucene/grouping touches either. Residual gaps: token-based GitHub search, the scan of recent issues and PRs reads titles and bodies but not comments, a contended GitHub search quota reduced term set C's GitHub pass (covered by the direct reads), and private security reports are not visible.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off through gradle in a scratch worktree (2026-10-31) and through a standalone runner (2026-10-29, seeds DEADBEEF and 5EED1234). With either fix (A: maximum over the returned groups; B: maximum over all hits) it passes, together with the whole grouping module (15 classes, 57 tests, assertions on and off). Release jars (SHA-1-checked): 10.6.0 and 10.5.0 fail, 10.4.0 passes (2026-10-31); the hunt's longer tests fail on 10.5.0, 10.5.1, 10.5.2 and mainea57360912edanda9779ef3c193, and the version-neutral ones pass on 10.4.0 and 9.12.3. The four grouping files are byte-identical betweenea57360912edand645d4fe14a20, and upstream main2f424fd428da(2026-10-30) has had no commit to them since 2026-10-20 [read].
- Finding record
lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:38 (at ea57360912ed) (@lucene.experimental), 59 ('Highest score across all hits, or Float.NaN if scores were not computed'), 290 (merge(): maximum over the merged groups), 404-421 (mergeBlockGroups: nonNANmax of every slice's maxScore for a field group sort, the top slice's maxScore for Sort.RELEVANCE), 423-446 (k-way merge that picks the returned groups)lucene/grouping/src/java/org/apache/lucene/search/grouping/BlockGroupingCollectorManager.java:68 (at ea57360912ed) (@lucene.experimental), 141-144 (newCollector keeps groupOffset + topNGroups groups per slice), 148-160 (reduce: getTopGroups(withinGroupSort, 0, ...) per slice, then mergeBlockGroups); BlockGroupingCollector.java:274, 336-346 (per-collector maxScore over the groups it kept); GroupingSearch.java:44 (@lucene.experimental), 221-237 (groupByDocBlock builds a BlockGroupingCollectorManager and calls searcher.search(query, bcm)); lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:108 (MAX_SEGMENTS_PER_SLICE = 5); lucene/CHANGES.txt:976 (GITHUB#15936 under 10.5.0)lucene/grouping/src/test/org/apache/lucene/search/grouping/TestGrouping.java:1632-1633 (at ea57360912ed) (commented-out maxScore comparison)
- The finding in 30 seconds
lucene/grouping/src/java/org/apache/lucene/search/grouping/BlockGroupingCollectorManager.java:141-144 (at 645d4fe14a20) (newCollector keeps groupOffset + topNGroups groups), :148-159 (reduce: getTopGroups(withinGroupSort, 0, ...) then mergeBlockGroups)lucene/grouping/src/java/org/apache/lucene/search/grouping/BlockGroupingCollector.java:339-341 (at 645d4fe14a20) (per-slice maxScore over the kept groups), :344-346 (relevance group sort)lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:59 (at 645d4fe14a20) (maxScore javadoc), :290 (merge(): maximum over merged groups), :404-421 (mergeBlockGroups pre-merge maximum), :425-446 (k-way merge, page at :431-437)lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:221-237 (at 645d4fe14a20) (groupByDocBlock builds a BlockGroupingCollectorManager)lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:106 (at 645d4fe14a20) (MAX_DOCS_PER_SLICE = 250_000), :108 (MAX_SEGMENTS_PER_SLICE = 5), :323-324, :357-380 (default slicing; a segment above the limit gets its own slice :361-363, a slice closes past the limit :374-378)
- Who is affected
lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:44 (at 645d4fe14a20) and BlockGroupingCollectorManager.java:68 (@lucene.experimental)
- Why it looks unintended
lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroups.java:59, (at 645d4fe14a20) :290lucene/CHANGES.txt:1009 (at 645d4fe14a20) (under 'Lucene 10.5.0' at :898)lucene/grouping/src/test/org/apache/lucene/search/grouping/TestGrouping.java:1632-1633 (at 645d4fe14a20) (commented-out maxScore check); TestTopGroups.java:207-215
- Prior reports
this doc's re-check, 2026-10-31 (gh search issues --include-prs; gh api commits?path=...&since=2026-10-20)
- TestBlockGroupingMaxScoreSlicing.java (one test, public API):
repro/LFV-239/src/org/apache/lucene/search/grouping/TestBlockGroupingMaxScoreSlicing.java(66 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and an executed probe of the real jars on the same index as the minimal test (per-slice values, the merged value, the sequential value, and both fix sketches).
assets/LFV-239/bug.png(529 KB)
