All findings · Lucene finding · LFV-102 · checkhits-maxscores-approximation-lockstep

Test framework: CheckHits.checkTopScores can falsely fail on correct minimum-should-match queries

The test-framework helper CheckHits.checkTopScores rejects a correct query: on two docs, (f:a f:b f:c)~2 gives the same hit and score in both score modes, yet it throws expected:<1> but was:<0>. It demands that both modes' scorers propose the same candidate docs, which no documented contract promises.

Summary

Priority
Low, rank 1223CheckHits.checkTopScores falsely fails on correct minimum-should-match disjunctions, a test-framework-only problem that no current caller exercises.
Impact
minor
Since
8.8.0 (LUCENE-9346, 2021-01-11); the assertion is from 8.0.0introduced 2021-01-11 · about 5 years
Affected
8.8.0 to 10.6.0 and main; tests only (test framework)
Status
documented
Found by
non-formal: differential testing against an exact brute-force 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

In short: the checker walks the two modes' candidate lists side by side and requires them to stop on the same docs (CheckHits.java:751, again at :838). Lucene only promises that each list contains all the matches, and with minimum-should-match above 1 the top-k list can hold an extra doc that does not match.

Background: score modes and candidate lists

  1. A query can be scored in COMPLETE mode (score every match) or in TOP_SCORES mode (skip docs that cannot reach the top k, using per-block score bounds). Both must return the same top hits.
  2. Many scorers work in two phases: a cheap approximation proposes candidate docs, and matches() confirms each one. The approximation only has to be a superset of the matches.
  3. CheckHits.checkTopScores is a public test-framework helper that runs a query in both modes and checks the top hits, the scores and the score bounds.
  4. For a BooleanQuery with minimumNumberShouldMatch above 1, both modes use WANDScorer. It may set aside up to msm - 1 clauses (and, in TOP_SCORES, clauses whose summed score bound is below the competitive threshold): a doc that only those clauses contain is not a candidate.
Diagram of the bug: an index with doc 0 containing c and doc 1 containing a and b, queried with (a b c) and minimum-should-match 2. The COMPLETE scorer's candidate list holds doc 1 only; the TOP_SCORES scorer's candidate list holds doc 0, which matches() rejects, and doc 1. Both return the same single hit, doc 1. CheckHits.checkTopScores walks the two candidate lists side by side, finds doc 1 on one side and doc 0 on the other, and fails with expected 1 but was 0.
Two docs, (a b c)~2: COMPLETE proposes only doc 1; TOP_SCORES proposes doc 0 (rejected by matches()) and then doc 1. The checker compares the two candidate lists doc by doc and fails at doc 0, although both return the same hit. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. In a test, index two docs, doc 0 = c and doc 1 = a b, and query (f:a f:b f:c) with setMinimumNumberShouldMatch(2). Only doc 1 matches, with score 0.5545178.
  2. Call CheckHits.checkTopScores(random(), query, searcher). Its first stage compares an exhaustive (COMPLETE) and a top-k (TOP_SCORES) search: both return doc 1 with 0.5545178, so you expect a pass.
  3. Its second stage walks both scorers' candidate lists side by side and requires them to stop on the same docs. COMPLETE proposes [1]; TOP_SCORES proposes [0, 1], and its matches() correctly rejects doc 0, which holds only c.
  4. You get AssertionError: expected:<1> but was:<0> at CheckHits.java:751, with assertions on and off, although a candidate list only has to contain all the matches and both do.

Who is affected

Tests only, in Lucene or downstream: any checkTopScores call on a BooleanQuery with minimumNumberShouldMatch above 1 can fail falsely on a suitable index, with ordinary settings and assertions on or off. Search results agreed in every recorded run. None of the 24 upstream call sites uses minimum-should-match, so Lucene's build stays green. Present since 8.8.0.

Mechanism, terms and numbers779 words

CheckHits.checkTopScores is the public test-framework helper that runs a query in COMPLETE and in TOP_SCORES mode and checks that the top hits agree and that the score bounds are valid. Its second part, doCheckMaxScores, walks both scorers' two-phase approximations (their candidate lists) side by side and asserts after each step that they are on the same doc. No documented contract promises that: an approximation only has to be a superset of the matching documents. With minimumNumberShouldMatch above 1, both scorers the checker builds are WANDScorers, which choose candidates differently per mode, so the TOP_SCORES list can contain a doc that the COMPLETE list skips and that matches() then rejects.

On two docs (doc 0 = c, doc 1 = a b), (f:a f:b f:c)~2 matches only doc 1, with score 0.5545178 in both modes; the COMPLETE candidates are [1], the TOP_SCORES candidates [0, 1], and the checker fails with expected:<1> but was:<0>. The assertion dates from 2018 (8.0.0) and has been reachable since 8.8.0. Only tests are affected, and none of Lucene's own calls of the checker uses minimum-should-match. We think the fix belongs in CheckHits; a maintainer may instead read the lockstep as an intended efficiency check (see the counter-case below).

The code (main 645d4fe14a20, CheckHits.java)

// first loop of doCheckMaxScores: "Check boundaries and max scores when iterating all matches" (:717)
TwoPhaseIterator twoPhase1 = s1.twoPhaseIterator();                      // :737  s1: COMPLETE scorer
TwoPhaseIterator twoPhase2 = s2.twoPhaseIterator();                      // :738  s2: TOP_SCORES scorer
DocIdSetIterator approx1 = twoPhase1 == null ? s1.iterator() : twoPhase1.approximation();
DocIdSetIterator approx2 = twoPhase2 == null ? s2.iterator() : twoPhase2.approximation();
...
for (int doc2 = approx2.nextDoc(); ; doc2 = approx2.nextDoc()) {         // :744
  int doc1;
  for (doc1 = approx1.nextDoc(); doc1 < doc2; doc1 = approx1.nextDoc()) {  // COMPLETE candidates that
    if (twoPhase1 == null || twoPhase1.matches()) {                        // TOP_SCORES skipped must not
      assertTrue(s1.score() < minScore);                                   // be competitive
    }
  }
  assertEquals(doc1, doc2);                                               // :751
  • What it requires. TOP_SCORES may skip COMPLETE candidates, but may never stop on a doc that COMPLETE skipped. The advance loop (:777+) repeats the pattern: approximations at :797-800, catch-up at :833, assertEquals(doc1, doc2) at :838.
  • What Lucene promises. TwoPhaseIterator#approximation (TwoPhaseIterator.java:88-90): "The returned DocIdSetIterator is a superset of the matching documents, and each match needs to be confirmed with matches()". Nothing ties the approximations of two score modes together, and checkTopScores (:690) has no javadoc.

Why the two modes propose different docs

  • BooleanScorerSupplier.java:589-590 returns a WANDScorer for any minShouldMatch > 1, in both modes. That is the scorer path doCheckMaxScores uses (CheckHits.java:719-727); nothing here is claimed about the bulk scorers IndexSearcher.search may use.
  • WANDScorer parks clauses in its "tail" (WANDScorer.java:601): up to minShouldMatch - 1 of them and, in TOP_SCORES, also clauses whose summed score bound is below the competitive threshold (0 when this repro fails). A doc that only parked clauses contain is never a candidate. The candidate is the top of the "head" (:300-319).
  • COMPLETE has no score bounds (scaling factor 0, :195), so it parks clauses by cost only (:674-687). In the 2-doc index it parks c; a and b sit on doc 1, so its candidates are [1].
  • TOP_SCORES, on entering a new block (:307-312), recomputes the score bounds and moves parked clauses back while their summed bound is at least the competitive threshold (:479-485). The threshold starts at 0 (:163) and this loop has no minimum-should-match term, so the parked clause, c, comes back and doc 0 becomes a candidate. matches() rejects it (freq + tailSize < minShouldMatch, :342-344).
  • A probe on the 2-doc index (exploration only; it calls setTopLevelScoringClause() in both modes and never setMinCompetitiveScore) shows exactly this: COMPLETE head docs [1, 1] with one parked clause, TOP_SCORES head docs [0, 1, 1] with none; all three clauses have the same max score, 0.6931472. The diagram's probe, which builds both scorers exactly as doCheckMaxScores does, prints the same candidate lists.

Not one cause in WANDScorer

Making the tail loop at WANDScorer.java:481 respect minimum-should-match makes the 2-doc test pass. But the same change still fails two larger inputs of an independent check on 645d4fe14a20: a 300-doc pattern (expected:<111> but was:<110>) and a boosted (a b c^10)~2 (expected:<7> but was:<6>). That verifier's reading: TOP_SCORES parks clauses by score bound and COMPLETE by cost, so the two lists differ for other reasons too. So the tested variant does not restore the lockstep for the larger inputs; no other scorer-side change was tried, so one is not ruled out.

Exposure and likelihood251 words
  • Who. Test code, in Lucene or downstream: the test framework "can also be used for testing third-party code that uses the Lucene API" (lucene/test-framework/src/java/overview.html:24-25). The trigger is an ordinary BooleanQuery of TermQuery SHOULD clauses with setMinimumNumberShouldMatch(2).
  • Ordinary settings. Default IndexWriterConfig and similarity, a plain IndexSearcher, and Lucene's standard codec pinned with TestUtil.getDefaultCodec(); assertions on or off. With the pin removed, the test failed 10 of 10 seeds under LuceneTestCase's randomized codecs (fact check), and an independent run with newDirectory / newSearcher failed 20 of 20.
  • Not every index. It needs a doc that one mode proposes and the other does not: small or sparse indexes such as the 2-doc one trigger it; a dense layout in the study's security probe passed.
  • Effect. A false AssertionError, which also hides what the rest of checkTopScores would check for that query (score equality, max-score bounds). No effect on search results in any recorded run: 20 probe searches on a 200,000-doc segment, with deleted docs and FILTER, MUST_NOT and nested restrictions, returned exactly the COMPLETE hits in TOP_SCORES mode, and the repro's and verifiers' inputs agree too. That is evidence on these inputs, not a proof for every query.
  • Why nobody has hit it. None of the 24 upstream checkTopScores call sites (8 classes in lucene/core tests) sets minimumNumberShouldMatch; TestWANDScorer has minimum-should-match tests but does not pass them to the checker. The study's GitHub code searches of the Elasticsearch, OpenSearch and Solr default branches found no caller.

Minimal repro

One JUnit test, one method, two documents, deterministic, single thread, public API plus CheckHits. The codec is pinned to Lucene's standard codec because LuceneTestCase randomizes it; the pin keeps every run on one codec (unpinned, the test still failed 10 of 10 seeds in the fact check). It goes in the lucene/core tests next to TestWANDScorer.

TestCheckTopScoresMinShouldMatch.java57 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;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.search.CheckHits;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestCheckTopScoresMinShouldMatch extends LuceneTestCase {

  public void testMinShouldMatch() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (String text : new String[] {"c", "a b"}) {
          Document doc = new Document();
          doc.add(new TextField("f", text, Field.Store.NO));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query query =
            new BooleanQuery.Builder()
                .add(new TermQuery(new Term("f", "a")), BooleanClause.Occur.SHOULD)
                .add(new TermQuery(new Term("f", "b")), BooleanClause.Occur.SHOULD)
                .add(new TermQuery(new Term("f", "c")), BooleanClause.Occur.SHOULD)
                .setMinimumNumberShouldMatch(2)
                .build();
        // expected: pass (doc 1 is the only match, same score in both modes); actual: fails
        CheckHits.checkTopScores(random(), query, new IndexSearcher(reader));
      }
    }
  }
}
Command
cp $REPRO/TestCheckTopScoresMinShouldMatch.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestCheckTopScoresMinShouldMatch -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
Expected
checkTopScores passes: doc 1 is the only match, with score 0.5545178 in both modes.
Observed
AssertionError: expected:<1> but was:<0> at CheckHits.java:751, with assertions on and off.

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

More files, environment, full output259 words
  • Inputs. ByteBuffersDirectory, default IndexWriterConfig (standard analyzer, BM25) with TestUtil.getDefaultCodec(), a plain IndexSearcher: nothing else is randomized. The random() passed to checkTopScores is not reached before the failing assertion (seeds DEADBEEF and 5EED1234 fail the same way).
  • Assertions. Pass -Ptests.asserts=true or false explicitly; it fails the same way in both modes.
  • Releases. The same file runs unchanged on the releases tried: it fails on 10.1.0, 10.5.2 and 10.6.0 and passes on 9.11.1, 9.12.3 and 10.0.0; on those three the false failure needs a larger input, such as the study's 300-doc pattern or a boosted (a b c^10)~2 (see the history below). It was not run before 9.11.1.
  • As the regression test: this same method, for example added to TestWANDScorer (same package).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.1.0, 10.5.2 and 10.6.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestCheckTopScoresMinShouldMatch.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestCheckTopScoresMinShouldMatch -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestCheckTopScoresMinShouldMatch > testMinShouldMatch FAILED
    java.lang.AssertionError: expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:240808E5B9A8FCC1]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.junit.Assert.assertEquals(Assert.java:633)
        at org.apache.lucene.tests.search.CheckHits.doCheckMaxScores(CheckHits.java:751)
        at org.apache.lucene.tests.search.CheckHits.checkTopScores(CheckHits.java:697)
        at org.apache.lucene.search.TestCheckTopScoresMinShouldMatch.testMinShouldMatch(TestCheckTopScoresMinShouldMatch.java:53)
1 test completed, 1 failed
# -Ptests.asserts=false: the same AssertionError at CheckHits.java:751
# with fix A or fix B: BUILD SUCCESSFUL, 1 test, assertions on and off

# exploration probe on the same index (not part of the repro)
COMPLETE   WANDScorer approximation: [1 (match, score 0.5545178)]
TOP_SCORES WANDScorer approximation: [0 (no match), 1 (match, score 0.5545178)]
IndexSearcher.search: 1 hit, doc 1, score 0.5545178

Why it looks unintended

  • TwoPhaseIterator#approximation promises only "a superset of the matching documents"; checkTopScores has no javadoc or precondition, and its loop says it checks "when iterating all matches".
  • The query is correct: the checker's own first stage and IndexSearcher.search give the same hit and score.
  • Precedent: #13069, #13088 and #13122 fixed other COMPLETE vs TOP_SCORES differences inside CheckHits.
The full argument201 words
  • The contract. TwoPhaseIterator.java:88-90 allows any superset of the matches. checkTopScores (CheckHits.java:690) states no precondition on the query; the class javadoc says only "Utility class for asserting expected hits in tests." (:57), and the first loop calls itself a check "when iterating all matches" (:717).
  • The query is right. In the same test the checker's own first stage (doCheckTopScores, top 1 and top 10) passes, and IndexSearcher.search returns the same single hit, doc 1, score 0.5545178.
  • Precedent. In #13069 / #13088 (2024) another COMPLETE vs TOP_SCORES structural difference (a null TOP_SCORES scorer) broke doCheckMaxScores, and the change went into CheckHits; jpountz (on #13069) asked for a follow-up assertion "to make sure that both scorers produce the same hits", which became #13088. #13120 / #13122 did the same for the advance loop; on #13120 Chris Hegarty wrote "I believe that this is a test bug."
  • Latent, then reachable. The lockstep came with checkTopScores in LUCENE-8135 (2018, 8.0.0) and held while both modes used MinShouldMatchSumScorer for msm > 1. LUCENE-9346 (8.8.0) moved TOP_SCORES to WANDScorer, and suitable inputs have failed ever since. The assumption was never written down; it was simply never exercised.
The devil's-advocate check300 words

The strongest case for "intended": the lockstep is a deliberate white-box check that TOP_SCORES never visits a candidate COMPLETE skips. In the lucene-solr#2141 review (comment r550807975, 2021-01-01) jpountz kept a filter in WANDScorer's approximation "so that the approximation doesn't return documents knowing that no match is possible", so a reviewer may ask whether WANDScorer should change instead. The lockstep is also one-sided: TOP_SCORES may skip COMPLETE candidates (if they score below the threshold) but may not add its own, which reads as a coherent pruning invariant; a maintainer could call the extra TOP_SCORES candidate a minor WANDScorer inefficiency and decline to relax CheckHits (this doc's cross-model fact check).

Why we still think it is not intended: no javadoc, comment, CHANGES or MIGRATE entry states such an invariant, and TwoPhaseIterator promises only a superset. #13996 (10.1.0, by the same author) then moved that approximation-side filter into matches() on purpose ("moves work from advance(int target) to TwoPhaseIterator#matches()"). And the one scorer-side change we tried, an msm-aware tail loop, fixes the 2-doc input, but two larger inputs still fail. Ratings of the counter-case: weak (cross-model); moderate (the cross-model refute review and this doc's cross-model fact check); the independent reproduction, the intended-behaviour check and the adjudication rejected it without a strength label. Our verdict: not intended, a test-framework defect; the open question is whether checkTopScores should require equal candidate lists, or only agreeing hits, scores and score bounds.

Noise call (contested): the cross-model fact check also argues that this is noise rather than a bug, because its whole effect is a false test failure. It stays confirmed here: the defect is the behaviour of a public test-framework API that downstream test suites call directly, and it fails with JVM assertions on and off; it is not a Java assert tripping inside Lucene.

Suggested fix

Recommended B. Keep walking the candidate lists; accept extra TOP_SCORES candidates that do not match

Recommended (B): in both loops, when TOP_SCORES proposes a doc the COMPLETE list skipped, require that it does not match, instead of assertEquals(doc1, doc2). Simplest alternative (A): walk the matching docs. Both validated: the test fails before and passes after, and all 128 tests of the 8 classes that call checkTopScores pass with nightly tests on.

All options and trade-offs2 options ยท 1,171 words

Both loops need the change: with only the first loop relaxed, the advance loop fails on its own at its assertEquals(doc1, doc2) (CheckHits.java:838) for 2 of 3 seeds (DEADBEEF and 1A2B3C4D fail, 5EED1234 passes).

Not recommended:

  • Changing only WANDScorer (for example making the tail loop at WANDScorer.java:481 respect minimum-should-match): the variant we tried makes the 2-doc test pass, but larger inputs still fail. It may be a small efficiency gain on its own (the study's security probe saw 0-1 extra candidates per query); that is a separate question.
  • Documenting a precondition ("no minimumNumberShouldMatch > 1") on checkTopScores: it would exclude an ordinary query shape for no stated reason.

B. Keep walking the candidate lists; accept extra TOP_SCORES candidates that do not match (validated by the checks) (recommended)

In both loops of doCheckMaxScores, hold the COMPLETE side's next candidate across iterations; when it is already past doc2, doc2 is a candidate only TOP_SCORES proposed, so assert twoPhase2.matches() == false and move on. The advance loop advances the COMPLETE side only when it is behind target, because DocIdSetIterator#advance must not go backwards.

diff --git a/lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java b/lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java
index 5fb954b2d85..a07cc0796b4 100644
--- a/lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java
+++ b/lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java
@@ -741,14 +741,19 @@ public class CheckHits {
       int upTo = -1;
       float maxScore = 0;
       float minScore = 0;
+      int doc1 = approx1.nextDoc();
       for (int doc2 = approx2.nextDoc(); ; doc2 = approx2.nextDoc()) {
-        int doc1;
-        for (doc1 = approx1.nextDoc(); doc1 < doc2; doc1 = approx1.nextDoc()) {
+        for (; doc1 < doc2; doc1 = approx1.nextDoc()) {
           if (twoPhase1 == null || twoPhase1.matches()) {
             assertTrue(s1.score() < minScore);
           }
         }
-        assertEquals(doc1, doc2);
+        if (doc1 > doc2) {
+          // Approximations only need to be supersets of the matches: the TOP_SCORES one may stop
+          // on a candidate that the COMPLETE one skipped, as long as it does not match.
+          assertTrue(twoPhase2 != null && twoPhase2.matches() == false);
+          continue;
+        }
         if (doc2 == DocIdSetIterator.NO_MORE_DOCS) {
           break;
         }
@@ -771,6 +776,7 @@ public class CheckHits {
             s2.setMinCompetitiveScore(minScore);
           }
         }
+        doc1 = approx1.nextDoc();
       }
     }
 
@@ -802,6 +808,7 @@ public class CheckHits {
       int upTo = -1;
       float minScore = 0;
       float maxScore = 0;
+      int doc1 = -1;
       while (true) {
         int doc2 = s2.docID();
         boolean advance;
@@ -829,13 +836,19 @@ public class CheckHits {
           doc2 = approx2.nextDoc();
         }
 
-        int doc1;
-        for (doc1 = approx1.advance(target); doc1 < doc2; doc1 = approx1.nextDoc()) {
+        if (doc1 < target) {
+          doc1 = approx1.advance(target);
+        }
+        for (; doc1 < doc2; doc1 = approx1.nextDoc()) {
           if (twoPhase1 == null || twoPhase1.matches()) {
             assertTrue(s1.score() < minScore);
           }
         }
-        assertEquals(doc1, doc2);
+        if (doc1 > doc2) {
+          // See the first loop: an extra TOP_SCORES candidate must not match.
+          assertTrue(twoPhase2 != null && twoPhase2.matches() == false);
+          continue;
+        }
 
         if (doc2 == DocIdSetIterator.NO_MORE_DOCS) {
           break;

Trade-offs

  • Relaxes only the unsupported assertion. The checker still walks the raw approximations, still calls advanceShallow / getMaxScore and setMinCompetitiveScore between candidates, still requires COMPLETE matches that TOP_SCORES skipped to score below the threshold, and still fails if TOP_SCORES matches a doc that COMPLETE did not propose. One check is lost: in the first loop the continue for a candidate only TOP_SCORES proposed comes before advanceShallow / getMaxScore, so upTo >= doc2 is not checked at such a doc (the score checks apply only to matches, and the advance loop calls advanceShallow at doc2 only for matches anyway).
  • About 25 changed lines, in CheckHits.java only.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: the minimal test fails before and passes after, with -Ptests.asserts=true and false.
  • The 8 classes that call checkTopScores (TestWANDScorer, TestReqOptSumScorer, TestBlockMaxConjunction, TestDisjunctionMaxQuery, TestBayesianScoreQuery, TestLogOddsFusionQuery, TestLongDistanceFeatureQuery, TestLatLonPointDistanceFeatureQuery) plus the minimal test: 129 tests, 0 failures, assertions on and off (3 @Nightly tests skipped, so 126 ran), and 129 of 129 with -Ptests.nightly=true.
  • The study's two larger independent tests (7 docs with 4 tests; 300 docs, plain and boosted): 6 of 6 pass.
  • :lucene:test-framework:checkGoogleJavaFormat and forbiddenApisMain pass.
  • Re-run by the fact check (2026-11-01): the 1-test pass with fix B standalone, assertions on and off, and the 9-class gradle run (129 tests, 3 skipped, 0 failures).
  • Not run: the whole gradlew check. That B still catches a real extra TOP_SCORES match is by reading, not mutation-tested.

A. Walk the matching docs instead of the candidate lists (simplest) (validated by the checks)

In both loops use s1.iterator() / s2.iterator() (for WANDScorer, TwoPhaseIterator.asDocIdSetIterator, so matches() is applied inside) and leave the two-phase objects null. The same change appears again at :797-800 in the advance loop.

--- a/lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java
+++ b/lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java
@@ -734,10 +734,12 @@
         assertTrue(s1.iterator().nextDoc() == DocIdSetIterator.NO_MORE_DOCS);
         continue;
       }
-      TwoPhaseIterator twoPhase1 = s1.twoPhaseIterator();
-      TwoPhaseIterator twoPhase2 = s2.twoPhaseIterator();
-      DocIdSetIterator approx1 = twoPhase1 == null ? s1.iterator() : twoPhase1.approximation();
-      DocIdSetIterator approx2 = twoPhase2 == null ? s2.iterator() : twoPhase2.approximation();
+      // Compare matching docs only: the two-phase approximations of the COMPLETE and TOP_SCORES
+      // scorers are both valid supersets of the matches but need not be equal.
+      TwoPhaseIterator twoPhase1 = null;
+      TwoPhaseIterator twoPhase2 = null;
+      DocIdSetIterator approx1 = s1.iterator();
+      DocIdSetIterator approx2 = s2.iterator();
       int upTo = -1;
       float maxScore = 0;
       float minScore = 0;
@@ -794,10 +796,12 @@
         assertTrue(s1.iterator().nextDoc() == DocIdSetIterator.NO_MORE_DOCS);
         continue;
       }
-      TwoPhaseIterator twoPhase1 = s1.twoPhaseIterator();
-      TwoPhaseIterator twoPhase2 = s2.twoPhaseIterator();
-      DocIdSetIterator approx1 = twoPhase1 == null ? s1.iterator() : twoPhase1.approximation();
-      DocIdSetIterator approx2 = twoPhase2 == null ? s2.iterator() : twoPhase2.approximation();
+      // Compare matching docs only: the two-phase approximations of the COMPLETE and TOP_SCORES
+      // scorers are both valid supersets of the matches but need not be equal.
+      TwoPhaseIterator twoPhase1 = null;
+      TwoPhaseIterator twoPhase2 = null;
+      DocIdSetIterator approx1 = s1.iterator();
+      DocIdSetIterator approx2 = s2.iterator();
 
       int upTo = -1;
       float minScore = 0;

Trade-offs

  • Four lines per loop, and it matches the first loop's own comment ("when iterating all matches").
  • The checker no longer sees non-matching candidates: advanceShallow / getMaxScore run only on matching docs, and the interleaving of candidate steps with setMinCompetitiveScore is exercised only inside the iterator. A committer may prefer to drop the always-null twoPhase variables (cosmetic, larger diff).

What the checks validated

  • The same gradle runs as B: the minimal test fails before and passes after, assertions on and off; 129 tests with assertions on and off (3 @Nightly skipped) and 129 of 129 with -Ptests.nightly=true; checkGoogleJavaFormat passes.
  • In the study: the finder's, the independent reproduction's, the intended-behaviour check's and the cross-model reviewers' tests pass with it, and a port to 8.8.0's CheckHits passes 80 of 80 over 20 seeds.
  • Study caveat: with A, the finder's own scripted-scorer fuzz harness still reported failures on a top-level DisjunctionMaxQuery over scripted leaves, at the score-equality assertion; the finder judged that a separate checker-strictness question and did not report it. Upstream TestDisjunctionMaxQuery passes.
  • Not run: the whole gradlew check; not mutation-tested.

Downstream note

Downstream relevance only: test suites built on Lucene's test framework that call checkTopScores on minimum-should-match queries can fail falsely; nothing changes for a deployed application.

How it was found

Differential testing. A scripted-scorer fuzzer compared WANDScorer's results and score bounds with an exact brute-force reference on scripted scorers. Once CheckHits.checkTopScores was added to it, the checker failed on every query with minimum-should-match above 1 while the reference passed. It was reduced by hand to a plain term disjunction, later to two documents.

The method in full121 words
  • Harness. A hunt unit (WAND and block-max score bounds) built a scripted-scorer fuzzer with an exact dyadic brute-force oracle and a score-bound recorder. With CheckHits.checkTopScores added as a second oracle, it failed on every query with a nested or top-level minimumNumberShouldMatch > 1 WANDScorer while the exact oracle passed.
  • Reduction. The finder minimised it by hand to (f:a f:b f:c)~2 over a 300-doc pattern on the stock jars, and the hunt later reduced that to the 2-doc, one-method test (2026-10-29).
  • Verification. An independent reproducer (own tests, from the claim only), cross-model lenses (OpenAI GPT-5.6, refute and intended), an intended-behaviour devil's advocate and an adjudication all called it real; the version-history runs placed the first affected release; a credibility audit kept it.

Prior reports, history and checks

Prior reports

No prior report found in the recorded searches (2026-10-25/26: 139 queries over GitHub issues and PRs incl. the JIRA archive, commits, mailing lists, downstream projects and the web, and the changed files of all 412 open PRs; refreshed 2026-11-01). Closest: #13069, #13088 and #13122, which fixed other COMPLETE vs TOP_SCORES differences in CheckHits.

Searches4 searches

Closest, none this bug: #13069 / #13088 / #13120 / #13122 (2024, other COMPLETE vs TOP_SCORES differences fixed in CheckHits); #9473 (LUCENE-8427, a doCheckMaxScores failure that was a real BlockMaxConjunctionScorer bug); #9251 (LUCENE-8204, a TOP_SCORES-only approximation that is a subset, which the lockstep tolerates); #10386 / lucene-solr#2141 (LUCENE-9346, made the failure reachable); #13996 and #14021 (10.1.0 WANDScorer changes, benchmarks only); #16176 and #16626 (minimum-should-match dispatch and performance, no CheckHits change). Gaps: keyword search only; closed-unmerged PRs cannot be listed by touched file; private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. the migrated JIRA archive)2026-10-25, 2026-10-26 and 2026-11-01three scripted prior-report searches (46 issue/PR and 8 commit queries), manual GitHub REST and code searches; full reads of #13996, #14021, #13069, #13088, #13120, #13122, #9473, #10386, lucene-solr #2141 and #2205; refresh: checkTopScores, doCheckMaxScores, CheckHits minShouldMatch, CheckHits approximationno report; closest #13069, #13088, #13122 (precedent)
Open PRs2026-10-26 and 2026-11-01the changed files of all 412 open apache/lucene PRs; then the open PRs updated since 2026-10-26only #16805 touches CheckHits.java, in an unrelated hunk
Mailing lists (dev@, java-user@, issues@, commits@) and the JIRA archive2026-10-25 and 2026-10-2646 mailing-list and 12 JIRA-archive queriesnothing relevant
Downstream projects and the web2026-10-25 and 2026-10-266 downstream-repo queries and code search for checkTopScores in Elasticsearch, OpenSearch and Solr; GitHub-wide code search; 10 web queriesno caller of checkTopScores downstream; nothing relevant

History

The assertion dates from LUCENE-8135 (2018, 8.0.0) and became reachable with LUCENE-9346 (lucene-solr#2141, 8.8.0), which put TOP_SCORES minimum-should-match queries on WANDScorer. Every release from 8.8.0 to 10.6.0 and main fails on suitable inputs; 8.0.0-8.7.0 pass. Not fixed upstream.

Versions tested7 rows
  • The assertion (latent): 4fb7e3d02c6, LUCENE-8135 "Implement block-max WAND" (2018-02-15, 8.0.0), added checkTopScores with the lockstep. While both modes used MinShouldMatchSumScorer for msm > 1, their candidate lists were identical.
  • Made reachable: LUCENE-9346 "Support minimumNumberShouldMatch in WANDScorer" (lucene-solr#2141, by zacharymorn, merged by jpountz on 2021-01-11): c2493283a58 on main and the branch_8x backport 74c0a978148, which shipped first, in 8.8.0. Commit-level runs: 8.7.0 with the commit's scorer files fails like 8.8.0, and 8.8.0 with its parent's files passes (20 seeds each). We read it as a legitimate scorer change that exposed the checker's assumption.
  • The 2-doc input: fails from 10.1.0. #13996 (a67120e1754, 10.1.0) moved the approximation-side minimum-should-match skip into matches(); the history probe puts its plain 300-doc input's switch at the same commit; the 2-doc input itself was not bisected.
  • Upstream: upstream main f815e679bf6c (2026-11-01): the last commit to CheckHits.java is #16255 (2026-06-14), before 645d4fe14a20, so the code is unchanged.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affected2-doc test fails with assertions on and off (gradle); passes with fix A or B
10.1.0, 10.5.2, 10.6.0 affected2-doc test fails (10.6.0 also with assertions off)
10.1.0 to 10.5.2 (all releases) affectedthe study's plain and boosted 300-doc inputs fail
9.12.0 to 10.0.0 partlythe boosted input fails; the plain 300-doc input passes (the 2-doc test passes on 9.12.3 and 10.0.0)
8.8.0 to 9.11.1 (all releases) affectedboth 300-doc inputs fail (the 2-doc test passes on 9.11.1)
8.0.0 to 8.7.0 not affectedpass: both modes use MinShouldMatchSumScorer, identical candidate lists
8.8.0 + the CheckHits fix (A, ported) fix80 of 80 pass over 20 seeds

Independent checks

Every check calls the false failure real: the finder, cross-model reviews (OpenAI GPT-5.6), an independent reproducer, a devil's advocate, an adjudication, the version-history runs, a credibility audit, the minimal-repro run, this doc's gradle runs and its fact checks. The cross-model fact check also calls it noise, because its only effect is a false test failure (see the counter-case).

Checks and verdicts451 words

The study's checks ran on main ea57360912ed (2026-10-25/26) and the main refresh on a9779ef3c193; CheckHits.java, WANDScorer.java and BooleanScorerSupplier.java are unchanged from there to 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproduced300-doc test fails with assertions on and off; 10.1.0, 10.4.0 and 10.5.2 fail, 10.0.0 passes; the fix passes it and 110 upstream tests.
cross-model refute review real, highOpenAI GPT-5.6: own 24-doc test confirms both modes return the same six hits, then fails (expected:<12> but was:<0>); controls pass; the fix passes.
independent reproducer realOwn 7-doc test: only the checker test fails, 20 of 20 seeds, assertions on and off; correctness test and two controls pass; streams COMPLETE [1,3,4,6], TOP_SCORES [0,1,3,4,6].
cross-model intended lens real, highOpenAI GPT-5.6: intended argument weak; own test fails on main and 10.1.0.
devil's advocate real, highNo documented invariant; the msm-aware WANDScorer shadow still fails; the boosted input fails on 9.4.2-10.0.0; newDirectory/newSearcher fails 20 of 20.
adjudication confirmed, high, minorFresh-seed re-runs of the independent reproduction's, the finder's and the intended-behaviour check's tests; the fix passes the independent reproduction's test.
the version-history runs first affected 8.8.085 runs over 60 releases plus commit-level shadows.
credibility audit agrees2026-10-26.
minimal repro fails, passes with the fix2-doc test on 645d4fe14a20, assertions on and off, seeds DEADBEEF and 5EED1234 (5 of 5).
doc gradle runs fails before, passes with A and BScratch worktree of 645d4fe14a20; the 8 calling classes pass with each fix (125 tests run with 3 nightly skipped; all 128 with nightly tests); 10.6.0 executed.
fact check of this doc holds, after one line-range fixRe-ran the repro standalone and through gradle on 645d4fe14a20 (seeds DEADBEEF, 5EED1234, 1A2B3C4D; assertions on and off): expected:<1> but was:<0> at CheckHits.java:751 every time. Its own probe gives COMPLETE [1] and TOP_SCORES [0, 1]; both fixes pass; 129 tests with fix B; with the codec pin removed, 10 of 10 seeds fail.
cross-model fact check facts hold; disputes the framing; judges it noiseOpenAI GPT-5.6, reading only, ran no Java. Confirms the two valid but different candidate lists and the false failure with assertions on and off; the test-count error it found was fixed and the overclaims and missing caveats it raised were addressed. Rates the counter-case moderate and judges the bug noise (see the counter-case above).
diagram check passCode panels, line numbers, values and order of events match the code and the traces. The diagram was then updated to show fix B (the recommended fix, probe re-run with it) and the 2-doc results on 10.1.0-10.6.0, and re-rendered.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,127 words

Finding record

LFV-102, snapshot of 2026-11-06

Registry title
CheckHits.checkTopScores (test framework) fails on correct minimum-should-match disjunctions because it walks the COMPLETE and TOP_SCORES two-phase approximations in lockstep
Area
test-framework / search checkers (CheckHits)
Priority
Low, rank 1223: CheckHits.checkTopScores falsely fails on correct minimum-should-match disjunctions, a test-framework-only problem that no current caller exercises.
Impact class
minor
Impact note
Test code only: any test, in Lucene or downstream, that calls the public test-framework checker CheckHits.checkTopScores on a query that WANDScorer runs with minimumNumberShouldMatch > 1 (an ordinary BooleanQuery of term clauses with setMinimumNumberShouldMatch(2); a handful of docs, the default IndexWriterConfig, codec and searcher, -ea or -da suffice); search results are correct in both score modes, so nothing outside tests is affected; none of the 24 upstream checkTopScores call sites sets minimumNumberShouldMatch, so Lucene's own tests do not hit it; released 8.8.0-10.6.0.
Summary
In a test you call the test framework's CheckHits.checkTopScores on a BooleanQuery of three term clauses with setMinimumNumberShouldMatch(2), for example (f:a f:b f:c)~2. You expect it to pass, because the query returns the same matching docs with the same scores in COMPLETE and TOP_SCORES mode (checked in the same test). Instead it throws AssertionError 'expected:<1> but was:<0>' in CheckHits.doCheckMaxScores (CheckHits.java:751): the checker requires the two scorers' two-phase approximations, which only have to be supersets of the matches, to stop on the same docs, and the TOP_SCORES WANDScorer legitimately stops on doc 0 (it holds only 'c'), which matches() then rejects.
Introduced
c2493283a58 (LUCENE-9346 'Support minimumNumberShouldMatch in WANDScorer', apache/lucene-solr#2141, main) and its branch_8x backport 74c0a978148, which shipped first, in 8.8.0 [exec, commit level: 8.7.0 with 74c0a978148's Boolean2ScorerSupplier, MinShouldMatchSumScorer, ScorerUtil and WANDScorer shadowed fails like 8.8.0, and 8.8.0 with the parent's three files shadowed passes, 20 derived seeds each way; read: it is the only commit between the 8.7.0 and 8.8.0 tags that touches those classes, BooleanWeight or CheckHits]: from it on, TOP_SCORES with minimumNumberShouldMatch > 1 uses WANDScorer, whose approximation can stop on docs that the COMPLETE approximation skips. The failing lockstep assertion itself is older and was latent until then: 4fb7e3d02c6 (LUCENE-8135 'Implement block-max WAND', 2018-02-15, first released in 8.0.0) [read: git log -S, -G and -L]. LUCENE-9346 is a legitimate scorer change that exposed the checker's assumption, not a scorer bug. Later: LUCENE-9668 (7f4d4dfdbf6, backport d266a09ff4c, 8.9.0) kept the failure, and #13996 (a67120e1754, 10.1.0) re-exposed the plain unboosted input after it had passed on 9.12.0-10.0.0 [exec] (2021-01-11 (LUCENE-9346 merge and branch_8x backport); the latent assertion dates from 2018-02-15 (LUCENE-8135))
First affected version
8.8.0 [exec]; 8.7.0 passes [exec]
Affected versions
every release from 8.8.0 to 10.5.2 (44 releases: 8.8.0-8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, all 21 9.x and the 12 10.x releases before 10.6.0) and main ea57360912ed fail [exec; -da also on 8.8.0, 9.12.3, 10.5.2 and main]; both probe inputs fail on 8.8.0-9.11.1 and 10.1.0-main, while on 9.12.0-10.0.0 only the boosted (a b c^10)~2 input fails and the plain (a b c)~2 input passes (that pass is not bisected) [exec]; 8.0.0-8.7.0 pass (all 16 releases) [exec]; before 8.0.0 the API did not exist (7.x has no checkTopScores and no ScoreMode) [read]; upstream main, branch_10x and branch_10_6 have a doCheckMaxScores body byte-identical to ea57360912ed (2026-10-26), and 10.6.0 (released 2026-10-29) fails too [exec 2026-11-01: the 2-doc test on the release jars, -ea and -da]; not fixed upstream [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 agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-25 and 2026-10-26: a scripted prior-report search, 3 runs with 46 GitHub issue/PR, 8 commit, 6 downstream-repo (Elasticsearch, OpenSearch, Solr), 46 mailing-list (dev@, java-user@, issues@, commits@) and 12 JIRA-archive queries; 21 manual queries (10 web, 5 GitHub REST, 3 GitHub code search, 1 JIRA REST, git history, git grep); changed-file lists of all 412 open PRs, of which only #16805 touches CheckHits.java, in an unrelated hunk; full reads of #13996, #14021, #13069, #13088, #13120, #13122, #9473, #10386 and lucene-solr #2141 and #2205, among others; 139 queries in total, none truncated); related prior items: #13069, #13088 and #13122 (2024 precedents: other COMPLETE vs TOP_SCORES differences fixed inside CheckHits), #9473 (LUCENE-8427, a real scorer bug found by doCheckMaxScores), #9251 (LUCENE-8204, a TOP_SCORES-only approximation that is a subset, which the lockstep tolerates), #10386 / lucene-solr#2141 (LUCENE-9346, made the failure reachable), #13996 and #14021 (10.1.0 WANDScorer changes), #16176 and #16626 (msm WANDScorer dispatch and performance, no CheckHits change)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the 2-doc test through gradle with assertions on and off, before and after fixes A and B, in a scratch worktree (2026-11-01), plus the 8 classes that call checkTopScores (125 tests run and pass with 3 nightly tests skipped; all 128 pass with nightly tests on); standalone, the 2-doc test on 9.11.1, 9.12.3, 10.0.0, 10.1.0, 10.5.2 and 10.6.0. The study ran on main ea57360912ed, whose CheckHits.java, WANDScorer.java and BooleanScorerSupplier.java are identical to 645d4fe14a20, and its history probe covered every release from 8.0.0 to 10.5.2. Upstream main f815e679bf6c (2026-11-01) has the same CheckHits.java.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:57 (class javadoc 'Utility class for asserting expected hits in tests.'), :690-698 (checkTopScores, no javadoc), :711-775 (doCheckMaxScores first loop: approximations :739-740, assertEquals(doc1, doc2) :751), :777-864 (second loop: approximations :799-800, assertEquals(doc1, doc2) :838)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/TwoPhaseIterator.java:88-90 (approximation(): 'a superset of the matching documents, and each match needs to be confirmed with matches()')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:300-319 (approximation advance; TOP_SCORES calls moveToNextBlock at a block boundary, :307-312), :479-484 (updateMaxScores tail flush while tailMaxScore >= minCompetitiveScore, no minShouldMatch term), :601 (insertTailWithOverFlow honours minShouldMatch)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:582-590 (comment: WANDScorer handles any ScoreMode and any minShouldMatch; returns WANDScorer for top-level TOP_SCORES or minShouldMatch > 1)
  • grep of a Lucene main checkout lucene/ (2026-10-26): 24 checkTopScores call sites in 8 test classes (TestBayesianScoreQuery, TestReqOptSumScorer, TestBlockMaxConjunction, TestLogOddsFusionQuery, TestWANDScorer, TestDisjunctionMaxQuery, TestLongDistanceFeatureQuery, TestLatLonPointDistanceFeatureQuery), none sets minimumNumberShouldMatch (the independent reproduction, the intended-behaviour check, the prior-report record); no main source outside test-framework refers to CheckHits except a comment at BM25Similarity.java:306
The finding in 30 seconds
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:57, :690-698, :711-775, :777-864 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/TwoPhaseIterator.java:86-91 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:582-592 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:163, :195, :300-319, :340-344, :436-486, :599-620, :674-687 (at 645d4fe14a20)
Who is affected
  • lucene/test-framework/src/java/overview.html:22-26 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/TwoPhaseIterator.java:86-91 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:57, :690-698, :717 (at 645d4fe14a20)

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

Repro files

About the diagrams

It was checked against the code and the traces.

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.