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.
- 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.
- 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. CheckHits.checkTopScoresis a public test-framework helper that runs a query in both modes and checks the top hits, the scores and the score bounds.- For a
BooleanQuerywithminimumNumberShouldMatchabove 1, both modes useWANDScorer. 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.
(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.- In a test, index two docs, doc 0 =
cand doc 1 =a b, and query(f:a f:b f:c)withsetMinimumNumberShouldMatch(2). Only doc 1 matches, with score 0.5545178. - 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. - 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 onlyc. - You get
AssertionError: expected:<1> but was:<0>atCheckHits.java:751, with assertions on and off, although a candidate list only has to contain all the matches and both do.
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).
// 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, andcheckTopScores(:690) has no javadoc.
BooleanScorerSupplier.java:589-590returns aWANDScorerfor anyminShouldMatch > 1, in both modes. That is the scorer pathdoCheckMaxScoresuses (CheckHits.java:719-727); nothing here is claimed about the bulk scorersIndexSearcher.searchmay use.WANDScorerparks clauses in its "tail" (WANDScorer.java:601): up tominShouldMatch - 1of 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 parksc;aandbsit 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 neversetMinCompetitiveScore) 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 asdoCheckMaxScoresdoes, prints the same candidate lists.
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 ordinaryBooleanQueryofTermQuerySHOULD clauses withsetMinimumNumberShouldMatch(2). - Ordinary settings. Default
IndexWriterConfigand similarity, a plainIndexSearcher, and Lucene's standard codec pinned withTestUtil.getDefaultCodec(); assertions on or off. With the pin removed, the test failed 10 of 10 seeds underLuceneTestCase's randomized codecs (fact check), and an independent run withnewDirectory/newSearcherfailed 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 ofcheckTopScoreswould 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
checkTopScorescall sites (8 classes inlucene/coretests) setsminimumNumberShouldMatch;TestWANDScorerhas 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.
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.
/*
* 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));
}
}
}
}
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
checkTopScorespasses: doc 1 is the only match, with score 0.5545178 in both modes.- Observed
AssertionError: expected:<1> but was:<0>atCheckHits.java:751, with assertions on and off.
More files, environment, full output259 words
- Inputs.
ByteBuffersDirectory, defaultIndexWriterConfig(standard analyzer, BM25) withTestUtil.getDefaultCodec(), a plainIndexSearcher: nothing else is randomized. Therandom()passed tocheckTopScoresis not reached before the failing assertion (seedsDEADBEEFand5EED1234fail the same way). - Assertions. Pass
-Ptests.asserts=trueorfalseexplicitly; 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).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.1.0, 10.5.2 and 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)
# 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.5545178TwoPhaseIterator#approximationpromises only "a superset of the matching documents";checkTopScoreshas 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.searchgive 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-90allows 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, andIndexSearcher.searchreturns 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 intoCheckHits; 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
checkTopScoresin LUCENE-8135 (2018, 8.0.0) and held while both modes usedMinShouldMatchSumScorerfor msm > 1. LUCENE-9346 (8.8.0) moved TOP_SCORES toWANDScorer, 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.
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 atWANDScorer.java:481respect 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/getMaxScoreandsetMinCompetitiveScorebetween 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 thecontinuefor a candidate only TOP_SCORES proposed comes beforeadvanceShallow/getMaxScore, soupTo >= doc2is not checked at such a doc (the score checks apply only to matches, and the advance loop callsadvanceShallowatdoc2only for matches anyway). - About 25 changed lines, in
CheckHits.javaonly.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seedDEADBEEF: the minimal test fails before and passes after, with-Ptests.asserts=trueandfalse. - 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@Nightlytests 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:checkGoogleJavaFormatandforbiddenApisMainpass.- 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.
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/getMaxScorerun only on matching docs, and the interleaving of candidate steps withsetMinCompetitiveScoreis exercised only inside the iterator. A committer may prefer to drop the always-nulltwoPhasevariables (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
@Nightlyskipped) and 129 of 129 with-Ptests.nightly=true;checkGoogleJavaFormatpasses. - 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
CheckHitspasses 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
DisjunctionMaxQueryover scripted leaves, at the score-equality assertion; the finder judged that a separate checker-strictness question and did not report it. UpstreamTestDisjunctionMaxQuerypasses. - Not run: the whole
gradlew check; not mutation-tested.
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.
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.checkTopScoresadded as a second oracle, it failed on every query with a nested or top-level minimumNumberShouldMatch > 1WANDScorerwhile the exact oracle passed. - Reduction. The finder minimised it by hand to
(f:a f:b f:c)~2over 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, incl. the migrated JIRA archive) | 2026-10-25, 2026-10-26 and 2026-11-01 | three 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 approximation | no report; closest #13069, #13088, #13122 (precedent) |
| Open PRs | 2026-10-26 and 2026-11-01 | the changed files of all 412 open apache/lucene PRs; then the open PRs updated since 2026-10-26 | only #16805 touches CheckHits.java, in an unrelated hunk |
| Mailing lists (dev@, java-user@, issues@, commits@) and the JIRA archive | 2026-10-25 and 2026-10-26 | 46 mailing-list and 12 JIRA-archive queries | nothing relevant |
| Downstream projects and the web | 2026-10-25 and 2026-10-26 | 6 downstream-repo queries and code search for checkTopScores in Elasticsearch, OpenSearch and Solr; GitHub-wide code search; 10 web queries | no caller of checkTopScores downstream; nothing relevant |
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), addedcheckTopScoreswith the lockstep. While both modes usedMinShouldMatchSumScorerfor 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):
c2493283a58on main and the branch_8x backport74c0a978148, 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 intomatches(); 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 toCheckHits.javais #16255 (2026-06-14), before645d4fe14a20, so the code is unchanged.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | 2-doc test fails with assertions on and off (gradle); passes with fix A or B |
| 10.1.0, 10.5.2, 10.6.0 | affected | 2-doc test fails (10.6.0 also with assertions off) |
| 10.1.0 to 10.5.2 (all releases) | affected | the study's plain and boosted 300-doc inputs fail |
| 9.12.0 to 10.0.0 | partly | the 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) | affected | both 300-doc inputs fail (the 2-doc test passes on 9.11.1) |
| 8.0.0 to 8.7.0 | not affected | pass: both modes use MinShouldMatchSumScorer, identical candidate lists |
| 8.8.0 + the CheckHits fix (A, ported) | fix | 80 of 80 pass over 20 seeds |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | 300-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, high | OpenAI 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 | real | Own 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, high | OpenAI GPT-5.6: intended argument weak; own test fails on main and 10.1.0. |
| devil's advocate | real, high | No 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, minor | Fresh-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.0 | 85 runs over 60 releases plus commit-level shadows. |
| credibility audit | agrees | 2026-10-26. |
| minimal repro | fails, passes with the fix | 2-doc test on 645d4fe14a20, assertions on and off, seeds DEADBEEF and 5EED1234 (5 of 5). |
| doc gradle runs | fails before, passes with A and B | Scratch 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 fix | Re-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 noise | OpenAI 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 | pass | Code 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 469 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,127 words
- 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 callcheckTopScores(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 mainea57360912ed, whoseCheckHits.java,WANDScorer.javaandBooleanScorerSupplier.javaare identical to645d4fe14a20, and its history probe covered every release from 8.0.0 to 10.5.2. Upstream mainf815e679bf6c(2026-11-01) has the sameCheckHits.java.
- 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)
- TestCheckTopScoresMinShouldMatch.java:
repro/LFV-102/src/org/apache/lucene/search/TestCheckTopScoresMinShouldMatch.java(57 lines)
It was checked against the code and the traces.
assets/LFV-102/bug.png(469 KB)
