All findings · Lucene finding · LFV-690 · diversified-collector-missing-key-counts-as-key-zero

DiversifiedTopDocsCollector counts a document without a key as key 0, so a real key-0 hit is dropped

DiversifiedTopDocsCollector caps the hits per source, but a document that has no value in the key field is silently counted as key 0. With 3 songs (no artist, ABBA, Zappa) and at most 1 hit per artist you get 2: the song without an artist takes ABBA's only slot.

Summary

Priority
Medium, rank 179Documents with no key quietly take key 0's maxHitsPerKey slots so real hits drop out of the top hits on ordinary data, but only in the experimental misc DiversifiedTopDocsCollector that must be subclassed.
Impact
silent wrong results
Since
7.0.0 (LUCENE-7407, 2016); numeric keys since 5.1.0introduced 2016-09-21 · over 10 years
Affected
7.0.0 to 10.5.2 and main; the manager since 10.5.0
Status
documented
Found by
code reading, confirmed by a bounded-exhaustive check against a reference

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

DiversifiedTopDocsCollector returns the top hits with at most maxHitsPerKey per source, such as a seller or an artist. A subclass supplies each document's key through getKeys(), a doc-values view; a document that lacks the key field has no value there.

Diagram of the DiversifiedTopDocsCollector missing-key bug with maxHitsPerKey 1: each artist has a one-slot queue keyed by the artist's ordinal. A song without an artist is given key 0, the ordinal of the alphabetically first artist, so it lands in that artist's queue and the artist's real song is dropped from the top hits, although the result has room for it; totalHits stays correct. With the fix the song without an artist gets its own group and every song is returned.
Songs collected with at most 1 hit per artist: the song without an artist is given key 0, the first artist's ordinal, lands in that artist's single slot and pushes the artist's real song out; with the fix it gets a group of its own. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Subclass DiversifiedTopDocsCollector with the artist's term ordinal as the key, as Lucene's own test does, and index 3 songs: one without an artist, one by ABBA (ordinal 0) and one by Zappa (ordinal 1).
  2. Search all 3 through DiversifiedTopDocsCollectorManager with numHits 3 and maxHitsPerKey 1. You expect all 3 songs: each artist has 1 hit, and the song without an artist is not by ABBA.
  3. Instead you get 2 hits, the song without an artist and Zappa; ABBA's only song is gone. totalHits is 3 and nothing fails, with assertions on or off.
  4. Why: when the key source has no value for a document, the collector sets its key to 0. That is ABBA's ordinal, so both songs compete for ABBA's single slot, and the manager's merge counts by the same key.
  5. Numeric keys collide too: a product without a seller value next to one with seller 0 gives 1 hit instead of 2. Every release since 7.0.0 has it, numeric keys since 5.1.0.

Who is affected

Applications that subclass the opt-in, experimental DiversifiedTopDocsCollector, directly or through its manager, where some matching documents lack the key field. With ordinal keys every keyless document collides with the first value; with numeric keys it needs a real key 0. Two documents are enough, and the drop is silent.

Mechanism, terms and numbers670 words

DiversifiedTopDocsCollector (lucene/misc, @lucene.experimental) returns the top hits "ensuring no more than maxHitsPerKey results from a common source"; a subclass supplies each document's key through getKeys(), and DiversifiedTopDocsCollectorManager (10.5.0) re-applies the cap when it merges slices. When the key source has no value for a document (advanceExact returns false), insert() sets the key to 0 (DiversifiedTopDocsCollector.java:146-151), and the manager's reduce counts by that key (DiversifiedTopDocsCollectorManager.java:77-89). A keyless document therefore shares one quota with the real key-0 documents, and the lower-ranked of them is dropped although the cap does not require it. With ordinal keys, the pattern of Lucene's own test, key 0 is the first value in sort order: 3 songs (no artist, ABBA, Zappa) with numHits 3 and maxHitsPerKey 1 return [no-artist, zappa]. No error is raised and totalHits stays correct. The else value = 0 branch came with LUCENE-7407 (7.0.0), which also silently changed the test's ordinal pattern from "missing is its own group" to "missing is the first artist"; with a plain numeric key source the collision is as old as the class (5.1.0). Giving keyless documents their own group in the collector and in the manager fixes it; documenting the behaviour is the alternative.

Terms

  • Diversified collector: DiversifiedTopDocsCollector (lucene/misc, @lucene.experimental, abstract) "controls diversity in results by ensuring no more than maxHitsPerKey results from a common source are collected" (DiversifiedTopDocsCollector.java:37-39); its javadoc example is "the top 20 hit records of that year - with a max of 3 Beatles hits" (:52-53).
  • Keys: the subclass implements getKeys(LeafReaderContext), "a source of values used for grouping keys" (:115-116), as a NumericDocValues. For string keys, upstream's test returns the term ordinal of a SortedDocValues field (TestDiversifiedTopDocsCollector.java:167-204); ordinal 0 is the first value in sort order.
  • No value: advanceExact(doc) returns "whether target has a value" (DocValuesIterator.java:24-33), and longValue() "is illegal to call ... after advanceExact(int) returned false" (NumericDocValues.java:32-37).
  • Manager: DiversifiedTopDocsCollectorManager (#16216, 10.5.0) collects per slice and merges, "re-applying the diversity constraint globally" (DiversifiedTopDocsCollectorManager.java:53-58).

The bug

  1. Key lookup (DiversifiedTopDocsCollector.java:144-151): if (keys.advanceExact(leafDocID)) value = keys.longValue(); else value = 0; then addition.key = value.
  2. Per-key queue (:153-171): the queue is looked up by that key, so a keyless document and the real key-0 documents fill one queue of size maxHitsPerKey. When it is full, the lower-ranked document is rejected (:166-171) or evicted from the global queue (:179-186).
  3. Manager merge (DiversifiedTopDocsCollectorManager.java:77-89): reduce keeps at most maxHitsPerKey hits per ScoreDocKey.key; a keyless hit carries key 0, so the merge conflates them again even when the two documents were collected in different slices (executed: 2 segments, 2 slices, 1 of 2 hits).
  4. Who is dropped: the lower-ranked of the two (score, then the higher doc id). In the repro all scores tie and the keyless document is doc 0, so the real key-0 document goes; with the scores reversed the keyless one goes. Two keyless documents also share one quota, and the public ScoreDocKey.getKey() reports 0L for a keyless hit.

The numbers (main, assertions on and off)

documents, all matchingnumHits / maxHitsPerKeyexpectedreturned
no-artist, ABBA (ordinal 0), Zappa (ordinal 1)3 / 1all 3[no-artist, zappa]
no-seller, seller 02 / 1both[no-seller]
control: no-seller, seller 12 / 1bothboth
control: seller 0, seller 02 / 1oneone

totalHits is always correct. A bounded-exhaustive comparison of the collector and the manager (every slicing in which each segment is whole or cut once into two intra-segment partitions) with an independent reference found no other deviation within its bounds: 0 mismatches in 974,644,128 exhaustive checks (up to 5 documents, 1-3 segments, keys {0, 1, 2, missing}) and 48,000,000 random ones when the reference also counts a missing key as key 0; with "missing is its own group" the smallest mismatch has 2 documents. This is bounded evidence, not a proof.

Exposure and likelihood174 words
  • Who is exposed. Users of DiversifiedTopDocsCollector (opt-in, @lucene.experimental, lucene/misc, abstract: it must be subclassed) or DiversifiedTopDocsCollectorManager (10.5.0) whose key field is optional, such as products without a retailer or songs without an artist. The class's own examples are retailers and artists (DiversifiedTopDocsCollector.java:41-53); its javadoc does not discuss missing values.
  • Ordinal keys always collide. Ordinal 0 is the first value in sort order, so any keyless document takes the quota of the alphabetically first source. Numeric keys collide only when 0 is a real id.
  • No scale, no special setup. 2 documents and a plain new IndexWriterConfig(). The test framework randomizes the codec, directory and searcher (the recorded runs got CheapBastard and Asserting(Lucene104)); every recorded run, with 4 seeds and assertions on and off, gave the same result.
  • Silent. No exception and a correct totalHits; one source just gets fewer slots than the cap allows.
  • How likely. Rare: keyless matching documents must compete with a real key-0 document for the top hits. No report was found (see prior reports).

Minimal repro

The test shows that DiversifiedTopDocsCollector (through DiversifiedTopDocsCollectorManager) puts a document whose key field has no value into key 0's group, so it uses up key 0's maxHitsPerKey quota.

TestDiversifiedMissingKey.java (JUnit test)70 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.misc.search;

import java.io.IOException;
import java.io.UncheckedIOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.DocValues;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.index.NumericDocValues;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDiversifiedMissingKey extends LuceneTestCase {

  public void testDocWithoutKeyDoesNotUseKeyZeroQuota() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      w.addDocument(new Document());
      Document seller0 = new Document();
      seller0.add(new NumericDocValuesField("seller", 0));
      w.addDocument(seller0);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        TopDocs td =
            new IndexSearcher(reader)
                .search(
                    MatchAllDocsQuery.INSTANCE,
                    new DiversifiedTopDocsCollectorManager<DiversifiedTopDocsCollector>(2, 1) {
                      @Override
                      public DiversifiedTopDocsCollector newCollector() {
                        return new DiversifiedTopDocsCollector(2, 1) {
                          @Override
                          protected NumericDocValues getKeys(LeafReaderContext context) {
                            try {
                              return DocValues.getNumeric(context.reader(), "seller");
                            } catch (IOException e) {
                              throw new UncheckedIOException(e);
                            }
                          }
                        };
                      }
                    });
        // expected 2 hits (one per group); actual 1: the keyless doc is put in key 0's group
        assertEquals("hits", 2, td.scoreDocs.length);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-690/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/misc/build/libs/lucene-misc-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

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

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.misc.search.TestDiversifiedMissingKey
Expected vs actual
Expected 2 hits for one keyless document and one seller-0 document with numHits 2 and maxHitsPerKey 1; actual 1 (hits expected:<2> but was:<1>), with -ea and -da.

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

More files, environment, full output34 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: hits expected:<2> but was:<1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.misc.search.TestDiversifiedMissingKey.testDocWithoutKeyDoesNotUseKeyZeroQuota(TestDiversifiedMissingKey.java:66)

Why it looks unintended

  • No javadoc, CHANGES entry, test or commit says what key a keyless document gets; upstream's test gives every song an artist.
  • Since 7.0 a sparse key source can only report "no value"; the collector then invents key 0.
  • Upstream's own ordinal pattern changed silently in 7.0: on 6.6.6 a song without an artist kept its own group.
The full argument313 words
  • No policy anywhere. The class promises only an upper bound (DiversifiedTopDocsCollector.java:37-39), which holds. CHANGES says "collecting no more than a given number of results under a choice of key" (LUCENE-6066, lucene/CHANGES.txt:10929-10931) and "enable concurrent search" (#16216, :1015-1017). Upstream's test adds one reused artist field to every document (TestDiversifiedTopDocsCollector.java:435-459).
  • The key is invented. getKeys() is "a source of values used for grouping keys" (:115), but for a keyless document the source cannot report a value (NumericDocValues.java:32-37); the 0 comes from the collector.
  • A silent change in 7.0. LUCENE-7407 (f7aa200d406) ported addition.key = keys.get(addition.doc - docBase); to the iterator API with an explicit else value = 0, and in the same commit ported the test's ordinal adapter from return sdv.getOrd(context.docBase + docID); ("missing values are indicated by -1") to an iterator whose longValue() returns ordValue(). The adapter's advanceExact came with LUCENE-7462 (9aca4c9d560) and the collector's with 99af830223a, all in 7.0.0. Executed: on 5.1.0 and 6.6.6 that adapter gives the keyless song key -1 and all 3 hits; from 7.0.0 on, key 0 and ABBA is lost. None of LUCENE-7407's 42 comments mentions this collector.
  • Upstream's two adapters disagree. The test picks randomly between "Slower key lookup but 100% accurate" (ordinals) and "Faster key lookup but with potential for collisions" (hashes) (TestDiversifiedTopDocsCollector.java:380-385). With a song without an artist plus an ABBA song, the accurate one returns 1 hit, the hashed one 2 (executed).
  • Grouping keeps them apart by default. The class javadoc compares itself with GroupingSearch (:50-51). There a document without a value gets a null group of its own (TermGroupSelector.java:70-74, :91-96; FirstPassGroupingCollector.java:220-234), and excluding such documents is an option: "Whether to ignore documents that don't have the group field instead of putting them in a null group" (GroupingSearch.java:393-394).
The devil's-advocate check253 words

The strongest "intended" case (rated strong by a cross-model review, moderate by the other and by the devil's advocate): the literal bound holds; getKeys() is a subclass hook, so the subclass owns the policy; 0 is what a missing number read as for years (before 7.0 the standard doc-values implementations returned 0 from get(), executed on 5.1.0 and 6.6.6, although the 6.6.6 javadoc does not promise it; numeric sorts default a missing value to 0, LongComparator.java:37); the class is experimental; a dense key source that reports a sentinel such as Long.MIN_VALUE for keyless documents avoids it (both reviews' control tests pass); the manager asserts sdk.key != null (DiversifiedTopDocsCollectorManager.java:80); and a change alters results that have been stable for years.

Why it falls short: it shows the behaviour is unspecified rather than chosen. Nothing documents the 0 or makes it configurable, unlike sort missing values; since 7.0 advanceExact == false is the only way a sparse source can say "no key", and the collector overrides it; upstream's own "100% accurate" pattern flipped behaviour in a mechanical port; and the grouping module keeps such documents apart by default. The workaround needs knowledge nothing documents. The rebuttal is clearest for ordinal keys; for plain numeric keys, where 0 has been the result since 5.1.0, the intended case is stronger (a fact-check reviewer rated it strong there and weak for ordinal keys). Verdict: a defect, low tier (adjudication: confirmed, medium); a javadoc-only answer stays a legitimate maintainer choice, in particular for numeric keys.

Suggested fix

Recommended Give keyless documents their own group (collector and manager)

Give documents without a key their own group, in the collector (its own per-key queue) and in the manager's merge (its own counter): +24/-16 in two files. The repro and the whole lucene/misc module (319 tests) pass. It changes results for code that relied on key 0, and ScoreDocKey.getKey() returns null for such hits.

All options and trade-offs3 options ยท 844 words

Both classes need the change: the manager's merge conflates a keyless and a key-0 hit across slices on its own. A partial fix fails (read, not run): with only the collector changed, the unchanged merge hits assert sdk.key != null with assertions on and unboxes a null key (a NullPointerException) with them off; with only the manager changed, the collector has already stored 0. The alternatives are equally reasonable maintainer choices: leave keyless documents uncapped, or document today's behaviour together with the dense-sentinel workaround, which avoids any behaviour change. Until a fix ships, applications can wrap their key source so that every document reports a key, with a sentinel that no real key uses, such as Long.MIN_VALUE, for documents without one.

Give keyless documents their own group (collector and manager) (validated by the checks) (recommended)

In DiversifiedTopDocsCollector.insert, set ScoreDocKey.key to null when advanceExact is false and keep those hits in a dedicated missingKeyQueue (used where perKeyQueues is read, written and cleared, also in perKeyGroupRemove); in DiversifiedTopDocsCollectorManager.reduce, count them in a separate missingKeyCount (the assert sdk.key != null goes). +24/-16 in two files.

     int leafDocID = addition.doc - docBase;
-    long value;
-    if (keys.advanceExact(leafDocID)) {
-      value = keys.longValue();
-    } else {
-      value = 0;
-    }
-    addition.key = value;
+    addition.key = keys.advanceExact(leafDocID) ? keys.longValue() : null;
 
-    // For this to work the choice of key class needs to implement
-    // hashcode and equals.
-    PriorityQueue<ScoreDocKey> thisKeyQ = perKeyQueues.get(addition.key);
+    PriorityQueue<ScoreDocKey> thisKeyQ =
+        addition.key == null ? missingKeyQueue : perKeyQueues.get(addition.key);

Trade-offs

  • Behaviour change for code that relied on "missing = key 0", in particular numeric key sources, where it has been the behaviour since 5.1.0: worth a CHANGES entry.
  • ScoreDocKey.getKey() is public and returns Long; for a keyless hit it returns null instead of 0L, so a caller that unboxes it would throw. Mention it in the CHANGES/MIGRATE note, or keep getKey() at 0 with a separate flag.
  • Subclass-visible changes beyond getKey(): the protected insert(...) can return a ScoreDocKey whose key is null, the protected maxNumPerKey also sizes the missing-key queue, and ScoreDocKey.toString() prints key:null. No test covers these directly.
  • Cost: competitive keyless hits get their own bounded queue (recycled like the others) instead of sharing key 0's, and insert, perKeyGroupRemove and reduce gain a null check. Not measured.
  • Keyless documents are still capped together (maxHitsPerKey of them in total), a policy choice.

What the checks validated

  • Gradle, scratch worktree of a9779ef3c193, JDK 25: stock + the new test fails 2 of 4 with assertions on and off; with the fix the new test and TestDiversifiedTopDocsCollector (2 suites, 8 tests) pass with assertions on, off and with nightly tests; the whole lucene/misc module, 24 suites, 319 tests (9 skipped), 0 failures with assertions on and off; :lucene:misc:check -x test (ecjLint, forbiddenApis, checkGoogleJavaFormat) passes.
  • Standalone runner: the new test 4 of 4 with -ea and -da.
  • After the test's comment-only javadoc fix, in a fresh scratch worktree: checkGoogleJavaFormat passes, the 2 suites pass with the fix with assertions on and off, :lucene:misc:check -x test passes, and the standalone runner gives 4 of 4.
  • Earlier: the original repro 2 of 2, upstream TestDiversifiedTopDocsCollector 4 of 4, the cross-model reviewers' expected-behaviour suites, and the bounded-exhaustive harness against the own-group reference, 0 mismatches in 24,515,232 checks with assertions on.

Leave keyless documents uncapped (not built)

Skip the per-key queue when advanceExact is false (and skip perKeyGroupRemove for such hits); in reduce, always keep them.

Trade-offs

Treats a document without a source as from no source at all, but keyless documents can then fill every slot, which defeats the diversity the class exists for when many documents lack the field. Same getKey() and CHANGES considerations as the recommended option.

Document today's behaviour and the sentinel workaround (not built)

Add to the class javadoc and getKeys(): a document for which the key source has no value is grouped under key 0; to keep such documents apart, return a key source whose advanceExact is always true and whose longValue() returns a sentinel such as Long.MIN_VALUE for them. A variant: a protected hook or constructor argument for the missing key, defaulting to 0.

@Override
public boolean advanceExact(int target) throws IOException {
  doc = target;
  has = in.advanceExact(target);
  return true; // every document has a key
}

@Override
public long longValue() throws IOException {
  return has ? in.longValue() : Long.MIN_VALUE; // keyless: own key
}

Trade-offs

No behaviour change, the safest for compatibility; but the ordinal trap stays (a keyless document silently takes the first value's quota) and every user must know to wrap the source. The workaround itself was executed: 2 of 2 hits on main. Its caveats: the sentinel must be a value no real key uses; the snippet shows only the two methods the collector calls, a real wrapper also implements docID(), nextDoc(), advance() and cost() and is not a general dense NumericDocValues (the executed one throws from nextDoc and advance); each lookup gains a branch.

Downstream note

Elasticsearch's diversified_sampler aggregation builds on this collector with key adapters that report a missing field the same way, so there documents without the field would share key 0's or the first term's quota (read in Elasticsearch's source, not run). OpenSearch, a fork of Elasticsearch, documents the same aggregation; its source was not read.

How it was found

A reading of the collector's code flagged the else value = 0 branch. A bounded-exhaustive check then compared the collector and its manager, under every slicing with each segment whole or cut once, with an independent reference on every small index: this grouping was the only deviation found, and the smallest failing case has 2 documents.

The method in full228 words

Method: code reading, then bounded-exhaustive translation validation on the stock main jars.

  • The hunt plan read the lead on main: keys.advanceExact(leafDocID) false gives value = 0, so a document with no key shares the per-key cap with documents whose key really is 0 (rated "likely Low, and possibly intended").
  • The hunt then built an independent reference (sort by score descending, then doc id; at most maxHitsPerKey per key; the first numHits; totalHits = all matches) and compared it with the real collector and with the manager through IndexSearcher under every slicing in which each segment is whole or cut once into two intra-segment partitions: every index of up to 5 documents in 1-3 segments with scores {1, 2, 3}, keys {0, 1, 2, missing}, numHits 1-4 and maxHitsPerKey 1-3 (974,644,128 checks with assertions off, 22,115,232 with them on for up to 4 documents) plus 48,000,000 random checks with up to 8 documents.
  • With the reference's missing-key policy set to "counts as key 0" there were 0 mismatches; with "its own group" the smallest mismatch has 2 documents. A mutant reference was told apart, and two implementation mutants (a <= cap in the manager, remove() without downHeap) were killed, so the check is not vacuous.
  • An AI finder then reduced it to a public-API JUnit, and independent AI tools reproduced, reviewed and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): three scripted prior-report search runs over GitHub, the mailing lists, JIRA, downstream projects and the web, 23 manual GitHub searches, the files of all 409 open PRs and the 42 comments of LUCENE-7407. The closest items introduced the collector, its manager or the else value = 0 branch.

Searches5 searches

No issue, PR, JIRA ticket, list thread, commit or downstream report discusses a document without a key sharing key 0's quota. The else value = 0 branch is unchanged on upstream main a95c754ee003 (2026-10-26), and no open PR touches either file.

Closest, not this bug: LUCENE-6066 / #7128 (introduced the collector; its key discussion never covers documents without a key), #16216 (added the manager; no review comments), LUCENE-7407 (the iterator port that wrote the branch; no comment about this collector), #14475 (open, a 50-file doc-values refactoring whose only Diversified* file is the test, where it changes the adapters mechanically), #7691 / LUCENE-6633 (recommends the collector), apache/solr#4792 (an open draft, "explore integrating Lucene's DiversifiedTopDocsCollector"; its adapter would inherit the behaviour) and a 2024 java-user@ question saying the collector lacks documentation.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, dev@ / java-user@ / issues@ / commits@ lists, JIRA archive, web2026-10-27three runs of a scripted prior-report search: 45 queries, 51 queries with an expanded spec (6 of them web), and a web run of 9 queriesonly LUCENE-6066 (#7128), #16216 and unrelated items
GitHub (apache/lucene)2026-10-2723 manual searches (both class names, maxHitsPerKey, diversified + missing / key 0 / ordinal / getKeys / advanceExact, the test, module:misc); the history of both files#7128, #16216, #7691, #9835, #240, #12892, #15493, #15331; none about this
Open PRs2026-10-27the changed files of all 409 open apache/lucene PRsnone touches either file; #14475 (50 files) touches only the test among the Diversified* files
Elasticsearch, OpenSearch, Solr2026-10-277, 4 and 1 GitHub queries, plus one across all repositoriesno report; their adapters would inherit the behaviour (read, not run)
JIRA, mailing lists2026-10-275 JQL queries and the 42 comments of LUCENE-7407; 8 manual list queries, 4 java-user@ threads read in fullnothing about documents without a key

History

The else value = 0 branch came with LUCENE-7407 (f7aa200d406, 2016), first released in 7.0.0; the manager (#16216) added a merge that counts by the same key in 10.5.0. Numeric keys already collided on 5.1.0 through the old get() returning 0. Executed on 5.1.0, 6.6.6, seven releases 7.0.0-10.5.2 and main.

Versions tested5 rows

Introduced by LUCENE-7407 "switch doc values usage to an iterator API" (@mikemccand, f7aa200d406, 2016-09-21; a JIRA patch committed directly, GitHub mirror #8460), which replaced addition.key = keys.get(addition.doc - docBase); with an iterator lookup and an explicit else value = 0; 99af830223a ("switch to advanceExact", 2017-04-02, also in 7.0.0) kept the branch. First release 7.0.0 (git tag --contains). The class itself came with LUCENE-6066 (#7128; ef3a99b385f on trunk, first released in 6.0.0, and ac75a8bae1d on branch_5x, both 2015-02-12; the class first shipped in 5.1.0 through ac75a8bae1d). The standard random-access NumericDocValues.get() implementations of that era returned 0 for a missing value (implementation behaviour, not a documented contract), so a plain numeric key source collided from the start, while upstream's ordinal pattern (getOrd = -1) did not. The manager came with #16216 (@javanna, 0a598f6b3b5 on main, ced96e18c79 on branch_10x, 2026-06-15, first release 10.5.0). Not fixed upstream (main a95c754ee003).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's test 2 of 4 fail (gradle and the standalone runner, assertions on and off); collector and manager drivers drop the hit
10.5.2 affectedthe brief's test 2 of 4 fail (assertions on and off); collector and manager drivers drop the hit
7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0 affectedera-specific drivers: numeric and ordinal key sources both drop the hit; controls pass
5.1.0, 6.6.6 numeric keys onlya plain numeric key source drops the hit; upstream's getOrd ordinal pattern keeps all 3 (key -1)
main + fix not affectedthe repro passes; the whole lucene/misc module, 319 tests, 0 failed

Independent checks

An independent reproducer, a devil's advocate and reviews by another model family reproduced it with their own tests and agree on the cause and the fix; the reviews call code fix versus javadoc a judgment call. The adjudication confirmed it (medium), and a registry re-run and an audit reproduced it.

Checks and verdicts357 words

Each check ran its own tests on the stock jars with assertions on and off. The cross-model reviews ran on the previous main ea57360912ed, whose lucene/misc search sources are identical.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateBounded-exhaustive check isolates missing = key 0 as the only deviation; 2-test public-API JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3 (9.x port); passes with the fix.
cross-model review, refute lens uncertain (high)An independent reviewer from another model family (OpenAI GPT-5.6) reproduced it with its own test (1 of 2 hits, several seeds, -ea and -da), agreed with the root cause and the fix, and rated the intended-behaviour argument strong (the sentinel workaround).
Independent reproducer realOwn collector test fails 3 of 5 on main (-ea, -da), 10.5.2 and 9.12.3 with 2 controls passing; manager test fails 30 of 30 iterations; history on 6.6.6 and 7.0.0.
Cross-model review, intended lens uncertain (high)OpenAI GPT-5.6: its expected-semantics suite fails 2 of 2 (-ea, -da, 10.5.2) and passes with the fix; intended argument moderate; a contract judgment call, not works-as-designed.
Devil's advocate real (medium)The best intended case shows "unspecified", not "chosen"; 2 of 6 fail on main, 10.5.2 and 9.12.3; the manager merge drops a hit across two slices; upstream's two adapters disagree; history on 5.1.0 and 6.6.6.
adjudication confirmed (medium)Re-ran the independent reproduction's tests with a new seed: collector 3 of 5 and manager 1 of 1 fail with -ea and -da.
registry re-run and audit keepThe original repro fails 2 of 2 with -ea and -da and passes 2 of 2 with the fix.
fact checks of this doc fixedAn independent fact check re-ran the repro (new seeds, gradle and the standalone runner) and the fix, and a reviewer from another model family checked the text: the bug holds; 9 errors, 8 overclaims and 5 missing caveats were fixed (a history detail, a misquote, the test's adapter, the harness's slicings, the test environment, downstream sources).

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

Finding record

LFV-690, snapshot of 2026-11-06

Registry title
DiversifiedTopDocsCollector and its manager put every document without a key into key 0's group, so a keyless document uses up key 0's maxHitsPerKey quota and real key-0 hits are silently dropped from the top hits
Area
misc / search: DiversifiedTopDocsCollector and DiversifiedTopDocsCollectorManager (per-key diversity cap)
Priority
Medium, rank 179: Documents with no key quietly take key 0's maxHitsPerKey slots so real hits drop out of the top hits on ordinary data, but only in the experimental misc DiversifiedTopDocsCollector that must be subclassed.
Impact class
silent wrong results
Impact note
Hits applications that subclass the opt-in, @lucene.experimental DiversifiedTopDocsCollector (lucene/misc; also through DiversifiedTopDocsCollectorManager since 10.5.0) with a key source in which some matching documents have no value: each keyless document takes one of key 0's maxHitsPerKey slots (with ordinal keys, as in upstream's own test, key 0 is the first value in sort order), so the lower-ranked of the keyless and key-0 documents is dropped although the cap does not require it (1 hit instead of 2 with numHits 2 and maxHitsPerKey 1), deterministically with -ea and -da, the default codec and searcher and no scale, with no error and a correct totalHits.
Summary
You subclass DiversifiedTopDocsCollector to get the top hits with at most maxHitsPerKey hits per source, and some matching documents have no value in the key field; you expect such a document not to use up the quota of any real source. Instead insert() gives it key 0 whenever getKeys().advanceExact() returns false (DiversifiedTopDocsCollector.java:146-151), and the manager's reduce counts by that same key (DiversifiedTopDocsCollectorManager.java:77-89), so with one keyless and one key-0 document, numHits 2 and maxHitsPerKey 1, only one hit comes back. With ordinal keys, the pattern of upstream's own test, key 0 is the first value in sort order, so a song without an artist takes the slot of the alphabetically first artist.
Introduced
f7aa200d406dbd05a35d6116198302d90b92cb29 (LUCENE-7407, GitHub mirror #8460, by mikemccand; a JIRA patch committed directly, no GitHub PR): the move of doc values to an iterator API wrote 'else value = 0' for a document without a key in DiversifiedTopDocsCollector.insert, and ported upstream's ordinal test adapter from getOrd (-1 when missing) to ordValue behind advanceExact; 99af830223a ('switch to advanceExact', 2017-04-02, also in 7.0.0) kept the branch; DiversifiedTopDocsCollectorManager (#16216, 0a598f6b3b5 on main / ced96e18c79 on branch_10x, 2026-06-15) added a reduce that counts by the same key. With a plain numeric doc-values key source the collision is as old as the class (LUCENE-6066 / #7128, ef3a99b385f, 2015-02-12), through the pre-7.0 convention that NumericDocValues.get() returned 0 for a missing value [read: git blame and git show in the full-history clone; executed on 5.1.0 and 6.6.6] (2016-09-21 (f7aa200d406); 2017-04-02 (99af830223a keeps the branch); 2026-06-15 (manager, #16216); the numeric-key collision dates from the class itself, 2015-02-12 (ef3a99b385f, 5.x backport ac75a8bae1d))
First affected version
7.0.0 [exec] for the collector-assigned key 0 (numeric and ordinal key sources both collide). With a plain numeric doc-values key source already 5.1.0 [exec], the class's first release, where the random-access get() returned 0 for a missing value; upstream's ordinal-key pattern is not affected before 7.0.0 (getOrd = -1, its own group; executed on 5.1.0 and 6.6.6). The manager path from 10.5.0 [read: git tag --contains ced96e18c79; executed on 10.5.2].
Affected versions
Every release from 7.0.0 to 10.5.2 and main a9779ef3c193 [exec on 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main with era-specific drivers, numeric and ordinal key sources; the finder's JUnit fails 2 of 2 on main (-ea and -da), 10.5.2 and 9.12.3; other point releases by reading, the lines are unchanged since 99af830223a]; the manager on 10.5.x and main [exec on 10.5.2 and main]; with a plain numeric key source also 5.1.0 to 6.6.6 [exec on 5.1.0 and 6.6.6]. Not fixed: upstream main a95c754ee003 (2026-10-26) still has the else-0 branch and no open PR touches either file [read].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that stayed undecided, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (searched 2026-10-27, read-only; the prior-report record, verdict new): three scripted prior-report searches (the finder's 45 queries, the follow-up's 51 queries with an expanded spec, and 6 web queries; GitHub issues and PRs, commits, downstream repositories, the dev, java-user, issues and commits lists, the JIRA archive, the web), 23 manual GitHub searches on apache/lucene, the file lists of all 409 open apache/lucene PRs, the history of both files, Elasticsearch, OpenSearch and Solr searches, 5 JQL queries plus the 42 comments of LUCENE-7407, and 8 manual list queries. No issue, PR, JIRA ticket, list post or downstream report discusses a document without a key sharing key 0's quota. Related only: LUCENE-6066 / #7128 (introduced the collector; its key discussion never covers documents without a key), #16216 (added the manager; no review comments), LUCENE-7407 (the iterator port; no comment mentions this collector), #14475 (open; changes only the test's adapter), #7691 / LUCENE-6633 (recommends the collector), apache/solr#4792 (open, exploratory; its adapter reports a missing field through advanceExact == false, so it would inherit the behaviour [read]) and a 2024 java-user question about missing documentation. Not fixed: upstream main a95c754ee003 (2026-10-26) still has the else-0 branch. Gaps: GitHub's search rate limit returned one 403 and the web-search tool throttled twice (all retried); private reports are not searchable.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 release jars (assertions on and off). Era-specific drivers ran on 5.1.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2. The fix was validated in the scratch worktree: the new test and TestDiversifiedTopDocsCollector with assertions on and off and with nightly tests, the whole lucene/misc module (319 tests) with assertions on and off, and :lucene:misc:check -x test.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/search/DiversifiedTopDocsCollector.java:37-39 (class contract 'no more than maxHitsPerKey results from a common source'), :52-53 ('the top 20 hit records of that year - with a max of 3 Beatles hits'), :115-116 (getKeys 'Get a source of values used for grouping keys'), :146-151 (advanceExact false gives value = 0; addition.key = value), :155-165 (per-key queue looked up by that key); DiversifiedTopDocsCollectorManager.java:77-89 (reduce counts per ScoreDocKey.key in a LongIntHashMap; assert sdk.key != null at :80); both files identical in Lucene main ea57360912ed
  • a Lucene clone: git tag --contains ced96e18c79 (lowest releases/lucene/10.5.0), git merge-base --is-ancestor (0a598f6b3b5 in origin/main, ced96e18c79 in origin/branch_10x); lucene/CHANGES.txt:849 (GITHUB#16216)
The finding in 30 seconds
  • lucene/misc/src/java/org/apache/lucene/misc/search/DiversifiedTopDocsCollector.java:37-39, (at a9779ef3c193) :52-53, :115-116, :144-151, :153-171, :179-186, :252-254
  • lucene/misc/src/java/org/apache/lucene/misc/search/DiversifiedTopDocsCollectorManager.java:53-58, (at a9779ef3c193) :77-89
  • lucene/misc/src/test/org/apache/lucene/misc/search/TestDiversifiedTopDocsCollector.java:167-204 (at a9779ef3c193) (ordinal adapter)
  • lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:24-33; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/NumericDocValues.java:32-37
Who is affected
  • lucene/misc/src/java/org/apache/lucene/misc/search/DiversifiedTopDocsCollector.java:41-53, (at a9779ef3c193) :64-67 (@lucene.experimental, abstract)
Why it looks unintended
  • lucene/misc/src/java/org/apache/lucene/misc/search/DiversifiedTopDocsCollector.java:37-39, (at a9779ef3c193) :50-51, :115
  • lucene/misc/src/java/org/apache/lucene/misc/search/DiversifiedTopDocsCollectorManager.java:80 (at a9779ef3c193)
  • lucene/misc/src/test/org/apache/lucene/misc/search/TestDiversifiedTopDocsCollector.java:380-385, (at a9779ef3c193) :435-459
  • lucene/CHANGES.txt:1015-1017, (at a9779ef3c193) :10929-10931; lucene/core/src/java/org/apache/lucene/index/NumericDocValues.java:32-37; lucene/core/src/java/org/apache/lucene/search/comparators/LongComparator.java:37; lucene/grouping/src/java/org/apache/lucene/search/grouping/TermGroupSelector.java:70-74, :91-96, FirstPassGroupingCollector.java:220-234, GroupingSearch.java:393-394
  • a Lucene clone: releases/lucene-solr/6.6.6 NumericDocValues.java:29-34 (get javadoc), SortedDocValues.java:40-46 (getOrd, missing = -1); git tag --contains 9aca4c9d560, 99af830223a (7.0.0 lowest)
Suggested fix
  • lucene/misc/src/java/org/apache/lucene/misc/search/DiversifiedTopDocsCollector.java:104 (at a9779ef3c193) (maxNumPerKey), :132 (protected insert), :144-151, :252-254 (getKey), :257-259 (toString); DiversifiedTopDocsCollectorManager.java:80-81 (assert, getOrDefault)

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

Repro files

About the diagrams

A step-by-step picture of the repro's ordinal case, made in parallel with this doc from an executed probe on main a9779ef3c193 and the code; the PNG is the final summary card. The alt text describes the intended content.

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.