MaxScoreBulkScorer runs top-k SHOULD disjunctions (minShouldMatch <= 1) by default. It skips documents that cannot reach the minimum competitive score; a rounding slip in one of its tests lets it skip a document that reaches the minimum exactly.
search(q, 1) returns doc 0 (1.8750148). Select the still to open it at full size. Generated with AI, checked against the code.- Run a SHOULD query of 4 constant-score clauses,
e= 0.75,a= 0.25000006,t= 1.1e-16 andj= 0.5, over doc 0 (termse,a,t) and doc 1 (termj). Doc 0 scores 1.00000012, the float just above 1.0, andexplainagrees. - Tell the scorer to skip only documents scoring below 1.00000012 (
setMinCompetitiveScore). You expect doc 0 back: its score is not below the minimum. You get nothing. With a minimum of 1.0, doc 0 comes back with 1.00000012. - Why: to decide whether clause
jis required,MaxScoreBulkScorerchecks whethere + a + tcan still reach the minimum, but addse + (t + a). That lands exactly halfway between 1.0 and 1.00000012 and rounds down to 1.0, sojbecomes required and doc 0, which lacksj, is dropped. Doc 0's own score,(e + a) + t, rounds up. - End to end, on 6,001 documents:
searcher.search(q, 1)returns doc 0 (1.87501478), while the same search counting all 1,102 hits, the exhaustive search andexplainrank doc 5000 first (1.87501490). Paging withsearchAfterreturns 1,101 hits and never doc 5000. - The class's other bounds that add the maxima in a different order from the score go through
MathUtil.sumUpperBound, which exists because float sums depend on the order; this one (MaxScoreBulkScorer.java:540) does not. Wrapping it too fixes both tests.
Default top-k searches (IndexSearcher#search(q, n)) of 4 or more SHOULD clauses that run on MaxScoreBulkScorer, but only when clause scores span more than about 2^29 and a sum sits on a float rounding midpoint: 1 hit in 64M random trials in a model. The cost is one document lost near rank k, also across searchAfter pages.
Mechanism, terms and numbers935 words
For a top-k (TOP_SCORES) disjunction of 4 or more SHOULD clauses that runs on MaxScoreBulkScorer (a pure SHOULD disjunction with minShouldMatch <= 1, or SHOULD + FILTER with no MUST clause and minShouldMatch 1), the scorer may make the best non-essential clause required: if the essential clause plus every other non-essential clause cannot reach the minimum competitive score, documents that lack that clause are dropped. The test that decides this (MaxScoreBulkScorer.java:540) casts a double sum to float without MathUtil.sumUpperBound, and it adds the clause maxima in a different order from the document's own score. When clause scores span more than about 2^29 and the exact sum sits next to a float rounding midpoint, the bound rounds one float step below a document that lacks the clause, so that document is skipped although its score equals the minimum. Through IndexSearcher#search(q, n) the k-th hit becomes one float step worse than a skipped document, and with searchAfter paging the skipped document appears on no page. In this class and the other disjunction scorers, the floating-point bounds that add the maxima in a different order from the score use sumUpperBound; line 540 does not. Introduced by #12589 (9.9.0); every release from 9.9.0 to 10.5.2, 10.6.0 (by reading) and main is affected. No upstream report was found.
- Minimum competitive score: once a
TopScoreDocCollectorhas counted more thantotalHitsThresholdhits (1,000 forIndexSearcher#search(q, n)), it callssetMinCompetitiveScore(nextUp(k-th score))(TopScoreDocCollector.java:154-164). The scorer "may safely ignore all documents whose score is less than" it (Scorable.java:45-47). - Essential, non-essential and required clauses (MAXSCORE): the clauses whose summed maximum scores stay below the minimum are non-essential; a competitive document must match an essential one. With a single essential clause, #12589 added one more step: if the essential clause plus every non-essential clause but the best one cannot reach the minimum, the best one becomes required, and documents without it are dropped.
MathUtil.sumUpperBound(sum, n): inflates a double sum ofnvalues by a relative 2(n-1)·2^-52 so that it bounds the sum in any order: "The error of sums depends on the order in which values are summed up" (MathUtil.java:184-186).
- A TOP_SCORES query with 2 or more SHOULD clauses, no MUST or FILTER clause and
minShouldMatch <= 1gets aMaxScoreBulkScorer(BooleanScorerSupplier.java:210,:308-328). So does SHOULD + FILTER with no MUST clause andminShouldMatchexactly 1 (:233-234,:340-364), for example afterBooleanQuery.rewriteinlines a single MUST disjunction next to a FILTER (BooleanQuery.java:591-607); FILTER + SHOULD with the defaultminShouldMatch0 does not. With sparse MUST_NOT clauses (cost at most 0.3% ofmaxDoc) the Scorer-based path runs instead (:203-207). partitionScorerssorts the clauses bymaxWindowScore / Math.max(1L, cost)and makes each clause non-essential whose max score keeps(float) MathUtil.sumUpperBound(runningSum, count)belowminCompetitiveScore; the others become essential, so the non-essential set need not be a prefix of the sorted order (MaxScoreBulkScorer.java:486-511). The left fold of the non-essential maxima is stored inmaxScoreSums(:505).- With a single essential clause (
:524), the required-clause loop (:535-547) buildsmaxRequiredScore + maxScoreSums[firstRequiredScorer - 2](:533,:536-539) and testsif ((float) maxPossibleScoreWithoutPreviousClause >= scorable.minCompetitiveScore) break;(:540), withoutsumUpperBound. - A document's score starts from the essential clause (
:343-348) and adds clausesi = n-2 .. 0one at a time (:452-466); required clauses go throughScorerUtil.applyRequiredClause, which drops every buffered document that does not match (ScorerUtil.java:173-190). Float cast at:469. - So the bound adds the tail of the maxima as one pre-summed group, the document adds it one term at a time. With 3 clauses the orders coincide; with 4 or more they can differ by one double rounding step, and on a float rounding midpoint the bound casts one float below the document.
Clauses sorted t, a, j (non-essential) and e (essential) at minimum nextUp(1f):
| quantity | double | float |
|---|---|---|
exact sum e + a + t = 1 + 2^-24 + 2^-53 + 2^-76 | just above the midpoint 1 + 2^-24 | would be nextUp(1f) |
bound e + (t + a) (line 540) | 0x1.000001p0, exactly the midpoint | 1.0f (tie to even), below the minimum: j becomes required |
doc 0 (e + a) + t | 0x1.0000010000001p0 | 0x1.000002p0 = the minimum |
bound with sumUpperBound(., 4) | 0x1.000002p0: j stays optional |
- 4 or more SHOULD clauses and a single essential clause in the window.
- Clause scores spanning more than about 2^29: if every term is at least about 2^-29 of the total, every double addition is exact and the bound is sound. Examples: a tiny tie-break boost beside normal scores, or a BM25 term present in almost every document of a very large index.
- An exact sum within a few double ulps of a float rounding midpoint. A model simulation with random scores and an adversarial minimum found 1 hit in 64M trials.
- The skipped document scores exactly the minimum (the study verifier's argument for realistic clause counts). In the executed path, a single
TopScoreDocCollector, that is one float step above the k-th hit. In concurrent search, when the shared minimum comes from a later slice (TopScoreDocCollector.java:137-151), and with aTopFieldCollectorwhose first sort is relevance (TopFieldCollector.java:380-392), the minimum can be the k-th score itself, so a document that ties it and would win on doc ID could be lost (read in code, not executed).
Exposure and likelihood299 words
- Who is exposed. The ordinary public TOP_SCORES path, no experimental or opt-in feature:
IndexSearcher#search(q, n)or another TOP_SCORES collection of a query with 4 or more SHOULD clauses that runs onMaxScoreBulkScorer: a pure SHOULD disjunction withminShouldMatch <= 1, or SHOULD + FILTER with no MUST clause andminShouldMatch1 (the FILTER case read in code, not executed). Codec-independent. The repro pins the default codec, one segment,NoMergePolicyand no query cache only to be deterministic; only the single-segment, no-executor path was executed. - What they see. In the executed path, a document whose score is exactly one float step above the k-th returned hit is missing from the top k, and a hit one float step lower takes its place. With an executor or a relevance-first
TopFieldCollector, a document that ties the k-th score exactly and would win on doc ID could be lost instead (read in code, not executed). The returned scores are correct, and nothing that does not match is added. No exception, with assertions on or off. - Paging loses it for good. The skipped document ranks above the last hit of its page, so
searchAfternever returns it: on the repro index, 111 pages of 10 hold 1,101 of the 1,102 hits and miss the best one. With the fix, page 1 starts with it. - How likely. Contrived (registry likelihood "contrived"): the 2^29 spread plus a sum at a rounding midpoint; the study's model simulation saw 1 hit in 64M adversarial trials. For relevance alone a one-float-step swap is negligible; it matters where exact top-k is relied on, such as paging or comparing pruned and unpruned results.
- Scale. None: 2 documents for the contract test, 6,001 for the end-to-end test.
- Long-standing. Every release since 9.9.0 (released 2023-12-04). Severity low (registry priority low).
The test shows that MaxScoreBulkScorer skips a document whose score equals minCompetitiveScore: for 4 SHOULD clauses its required-clause test rounds the bound without MathUtil.sumUpperBound to 4f, below doc 0's score nextUp(4f), so a clause doc 0 does not match becomes required.
/*
* 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 java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestMaxScoreRequiredClauseRounding extends LuceneTestCase {
private static Query clause(String term, float boost) {
return new BoostQuery(new ConstantScoreQuery(new TermQuery(new Term("f", term))), boost);
}
/** Scores of the docs that the query's bulk scorer collects under a fixed min score. */
private static List<Float> collect(IndexSearcher searcher, Query query, float minScore)
throws IOException {
List<Float> scores = new ArrayList<>();
Weight weight = searcher.createWeight(searcher.rewrite(query), ScoreMode.TOP_SCORES, 1f);
BulkScorer bulkScorer = weight.bulkScorer(searcher.getIndexReader().leaves().get(0));
bulkScorer.score(
new LeafCollector() {
Scorable scorer;
@Override
public void setScorer(Scorable scorer) throws IOException {
this.scorer = scorer;
scorer.setMinCompetitiveScore(minScore);
}
@Override
public void collect(int doc) throws IOException {
scores.add(scorer.score());
}
},
null,
0,
DocIdSetIterator.NO_MORE_DOCS);
return scores;
}
public void testDocScoringMinCompetitiveScoreIsCollected() throws IOException {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc0 = new Document();
doc0.add(new StringField("f", "e", Field.Store.NO));
doc0.add(new StringField("f", "a", Field.Store.NO));
doc0.add(new StringField("f", "t", Field.Store.NO));
w.addDocument(doc0);
Document doc1 = new Document();
doc1.add(new StringField("f", "j", Field.Store.NO));
w.addDocument(doc1);
try (DirectoryReader reader = DirectoryReader.open(w)) {
IndexSearcher searcher = new IndexSearcher(reader);
searcher.setQueryCache(null);
Query query =
new BooleanQuery.Builder()
.add(clause("e", 3f), Occur.SHOULD)
.add(clause("j", 2f), Occur.SHOULD)
.add(clause("a", 1f + 0x1p-22f), Occur.SHOULD)
.add(clause("t", 0x1p-51f + 0x1p-70f), Occur.SHOULD)
.build();
List<Float> doc0Score = collect(searcher, query, 4f);
// expected: doc 0 (score nextUp(4f)) is still collected at minScore = its score; actual: []
assertEquals(
"doc 0 must be collected at minCompetitiveScore = its own score",
doc0Score,
collect(searcher, query, doc0Score.get(0)));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-060/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.search.TestMaxScoreRequiredClauseRounding
- Expected vs actual
- With
setMinCompetitiveScore(nextUp(4f)), expected doc 0 to be collected ([4.0000005], as with min score 4f); actual[], with-eaand-da.
More files, environment, full output44 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: doc 0 must be collected at minCompetitiveScore = its own score expected:<[4.0000005]> but was:<[]>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.apache.lucene.search.TestMaxScoreRequiredClauseRounding.testDocScoringMinCompetitiveScoreIsCollected(TestMaxScoreRequiredClauseRounding.java:90)Scorable.setMinCompetitiveScoreallows skipping only documents "whose score is less than the given minScore"; doc 0's score equals it.- Bounds summed in a different order from the score use
MathUtil.sumUpperBound, which exists because sums depend on the order; #12589 wrapped its other new comparisons, not this one. - #13397 fixed the same kind of slip in another scorer as a bug.
The full argument373 words
The contract, in its own words (at ea57360912ed):
Scorable#setMinCompetitiveScore: "Tell the scorer that its iterator may safely ignore all documents whose score is less than the givenminScore" (Scorable.java:45-47).IndexSearcher#search(Query, int): "Finds the topnhits forquery." (IndexSearcher.java:617-623).TopScoreDocCollectorManager:totalHitsThresholdis "the number of docs to count accurately" (:35-55).MathUtil.sumUpperBound: "The error of sums depends on the order in which values are summed up. In order to avoid this issue, we compute an upper bound of the value that the sum may take." (MathUtil.java:184-186).- The comment above the loop presents the step as exact: "we know that hits must match both the essential clause and the best non-essential clause" (
MaxScoreBulkScorer.java:525-531). - Lucene's test oracle asserts that every document a pruned scorer skips scores strictly below
minScore(CheckHits.java:744-749).
Cross-order bounds. Where a floating-point bound adds the maxima in a different order from the score, Lucene wraps it in sumUpperBound: the partition loop above line 540 (MaxScoreBulkScorer.java:500-502), ScorerUtil.minRequiredScore (ScorerUtil.java:131-142), WANDScorer.java:192 and :591, and DisjunctionSumScorer.java:68. The conjunction scorers cast a plain double sum (BlockMaxConjunctionBulkScorer.java:74-83, compared at :163; BlockMaxConjunctionScorer.java:225-231), which is sound because they add in the score's own order (MathUtil.java:188-191: for conjunctions "the order in which scores are summed up is predictable"). Line 540 adds in a different order and is not wrapped. #12589 wrapped its two new per-document comparisons but not the new required-clause test (diff lines 169, 187 and 284 of git show 611bbbd9512 -- lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java).
History. #12589 ("Sometimes intersect the essential clause and the best non-essential clause.", 2023-10-24): no discussion of rounding in the body or its 6 comments; the PR has no reviews. #13397 (2024-05-21) fixed a bound summed in float in BlockMaxConjunctionBulkScorer as a bug: "Otherwise, max scores may be returned that are less than actual scores." In the #14751 review a maintainer suggested (float) MathUtil.sumUpperBound(maxRemainingScore, numScorers) >= minCompetitiveScore, the shape of the fix here.
Why the tests miss it. TestMaxScoreBulkScorer.testPartition checks the required-clause partition with 3 clauses, where both orders coincide; its other tests use small integer scores with Math.nextUp thresholds, where every sum is exact.
The devil's-advocate check288 words
Lucene accepts ulp-level score differences between execution strategies: BooleanWeight.explain pulls a scorer because "Replicating the same floating-point errors as the scorer does is quite complex" (BooleanWeight.java:121-125), and a 2021 maintainer comment on #10375 says top hits that differ "in the order of one ulp" can come from summation order. So a one-float-step swap at rank k could be called within tolerance. A read-only cross-model review recorded "not real, low confidence".
Why it fails:
- That tolerance is about different strategies disagreeing in the last bit. Here one scorer, under one partition, skips a document it itself scores exactly at the minimum (
explainagrees), and two TOP_SCORES runs on the same searcher that differ only intotalHitsThresholdreturn different top hits. - The class wraps its other cross-order bounds in
sumUpperBound, the same commit did so for its other new comparisons, and #13397 treated the same kind of error as a bug. - The fix adds a few floating-point operations per iteration of the required-clause loop (at most n-1 per partition), never per document.
- The read-only reviewer could not run code; it wrote that its verdict is "not a substantive refutation" and that static inspection "strongly supports a genuine, rare wrong-results defect". Its earlier, code-executing attempt wrote its own test, which fails on main and passes with the fix.
With other numbers the exhaustive BooleanScorer and explain() score the affected document one float lower, so an exact TOP_SCORES-vs-COMPLETE check can fail even after the fix: over the 24 clause orders of the end-to-end index, COMPLETE returns doc 5000 in only 12, before and after the fix, while the pruned search returns doc 0 in all 24 on stock and doc 5000 in all 24 with the fix.
Recommended Wrap the bound in MathUtil.sumUpperBound
Wrap the bound at MaxScoreBulkScorer.java:540 in MathUtil.sumUpperBound(..., allScorers.length), like the partition loop above it. One line, off the per-document path. Both tests pass, and so do 157 nearby tests (assertions on and off) and all 2,332 tests of the search package.
// MaxScoreBulkScorer.partitionScorers, required-clause loop
// (was: if ((float) maxPossibleScoreWithoutPreviousClause >= scorable.minCompetitiveScore) {)
if ((float) MathUtil.sumUpperBound(maxPossibleScoreWithoutPreviousClause, allScorers.length)
>= scorable.minCompetitiveScore) {
break;
}All options and trade-offs3 options · 507 words
One option was validated with gradle in a scratch worktree at ea57360912ed (removed after the runs); a second was validated with the repro runner only; the third was not run.
The same pattern as the partition loop above it (:500-502) and ScorerUtil.minRequiredScore.
// MaxScoreBulkScorer.partitionScorers, required-clause loop
// (was: if ((float) maxPossibleScoreWithoutPreviousClause >= scorable.minCompetitiveScore) {)
if ((float) MathUtil.sumUpperBound(maxPossibleScoreWithoutPreviousClause, allScorers.length)
>= scorable.minCompetitiveScore) {
break;
}Trade-offs
- Sound for any summation order, now or after later refactorings: the study's Dafny model proves the fixed test sound on an abstract real-number rounding model with 5 axioms, for clause counts with n·2^-53 <= 1/16 (any realistic count); not a bit-level binary64 or bytecode proof. An independent replication fails the proof when the bug is re-introduced.
- The inflation is a relative 2(n-1)·2^-52 (about 1.3e-15 for 4 clauses), so it only changes the decision next to a rounding midpoint; the per-document filter already applies the same inflation.
- Cost: a few floating-point operations (
Math.scalb, three multiplies, an add) per iteration of the required-clause loop, at most n-1 per partition; never per document; no benchmark run.allScorers.lengthis conservative; any count at least the number of summed terms is sound.
What the checks validated
- Gradle, stock: the repro fails 2 of 2 with assertions on and off.
- Gradle with the fix: the repro plus
TestMaxScoreBulkScorer,TestBoolean2,TestBooleanQuery,TestBooleanMinShouldMatch,TestBooleanOr,TestBooleanScorer,TestBooleanScorerSupplier,TestWANDScorer,TestScorerUtil,TestTopDocsCollectorandTestMathUtil: 157 tests, 0 failures (seedDEADBEEF, assertions on; seedCAFEBABE, assertions off). - Gradle with the fix: all of
org.apache.lucene.search.*in:lucene:core, 2,332 tests, 0 failures, 49 skipped. ./gradlew :lucene:core:tidyleaves the fix line unchanged.- The repro runner: the three independent tests and the paging probe pass; 106 upstream tests pass stock and patched on seeds
DEADBEEFandCAFEBABE, and 121 on seed7E57AB1E. - Not run: the rest of
:lucene:core, other modules, a performance benchmark.
Sum the bound in the scorer's own order (the repro runner only; not recommended) (validated by the checks)
Replace + maxScoreSums[firstRequiredScorer - 2] by adding allScorers[i].maxWindowScore for i = firstRequiredScorer - 2 .. 0 one at a time onto maxRequiredScore, the order scoreNonEssentialClauses uses.
double maxPossibleScoreWithoutPreviousClause = maxRequiredScore;
for (int i = firstRequiredScorer - 2; i >= 0; --i) {
maxPossibleScoreWithoutPreviousClause += allScorers[i].maxWindowScore;
}Trade-offs
Exact rather than inflated, because rounded addition is monotone (an argument, not proved). But it ties correctness to the summation order in scoreNonEssentialClauses, which has been refactored before; a later change there would silently bring the bug back. O(n) per loop iteration instead of O(1).
What the checks validated
The repro passes 2 of 2 with -ea and -da, the 5-clause test 3 of 3, the reviewer's test 3 of 3, and 121 upstream tests pass (seed DEADBEEF). Not run with gradle.
Return to the 9.8.0 partition without required clauses.
Trade-offs
Loses the #12589 speedup for single-essential-clause windows. Not recommended.
An AI tool trying to prove Lucene's top-k pruning sound in Dafny could not prove this one comparison; the same comparison with sumUpperBound proved. A Z3 floating-point model then found a scorer-level witness in 7.4 seconds and the end-to-end boosts of the JUnit in 6.0 seconds. Verifiers reproduced it with their own numbers.
The method in full276 words
Method: deductive proof (Dafny) plus IEEE-754 SMT (Z3), the study's formal tier, run 3, experiment "topk-pruning-proof" (2026-10-22, main 7cecf79523).
- An AI tool modelled Lucene's dynamic top-k pruning (the partition, the per-document filter and the required-clause test) and the document score fold, each mapped to Java lines. The obligation that the required-clause test as coded never drops a competitive document was not provable from the IEEE axioms (
RequiredClauseUnsound.dfy); the same statement withsumUpperBound(., n)was proved. - A Z3 model with exact binary32/binary64 round-to-nearest-even semantics then produced a witness for the scorer-level failure in 7.4 s and, adding cost-feasibility for the clause sort and the measured
BooleanScorerorder, an end-to-end witness in 6.0 s: the boosts of the end-to-end test. The 7.4 s witness has scores of about 1e-23; the contract test's round numbers (0.75, 0.25 + 2^-24, 2^-53 + 2^-76, 0.5) follow the same pattern, and the study's Dafny file lists them as an example, but no Z3 log records how they were chosen. A third query (every one of the 6 summation orders reaching the minimum) was UNSAT, so the bug needs an order-dependent score. - The AI tool reported seeing no hint about this area. A separate AI verifier reproduced it with its own numbers and layout and found the COMPLETE/explain caveat; an independent Dafny replication fails when the bug is re-introduced into the fixed test.
- On 2026-10-26 the hunt re-ran it on
ea57360912edand the release jars, built the history, searched for prior reports, ran a cross-model review and a second adjudication with a third independent test, and for this page validated the fix with gradle and ran the paging probe.
No report found in GitHub issues and PRs, commits, the dev@, java-user@, issues@, commits@ and builds@ lists, the JIRA archive, three downstream trackers or the web (2026-10-26). Not fixed upstream. Closest: #13397, the same kind of slip in another scorer, fixed as a bug.
Searches5 searches
No report of this bug was found, and upstream main, branch_10x and branch_10_6 still have the plain cast; no open PR changes the line (#15081, #14968, #14024 and #12446 modify MaxScoreBulkScorer.java elsewhere, and #15346 only adds a MaxScoreBulkScorer test). Closest items, none of them this bug: #12589 (introduced the loop; no rounding discussion), #13397 (closes #13371 and #13396; the same kind of error in BlockMaxConjunctionBulkScorer, fixed as a bug), the #14751 review (a maintainer suggested the fix's exact shape for another comparison), #14827 (rounding of the per-document filter, which uses sumUpperBound), #972 / LUCENE-10480 (a maintainer asked for scoreSumUpperBound in the predecessor 2-clause scorer), #11464 / LUCENE-10428 (an earlier rounding bug in MaxScoreSumPropagator), #10375 (ulp-level differences from summation order, background), and Jenkins "docnumbers don't match" threads since 2023-10 (other tests, each explained or fixed).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene), via a scripted prior-report search | 2026-10-26 | 31 queries on class and field names (MaxScoreBulkScorer, MathUtil, ScorerUtil, sumUpperBound, partitionScorers, firstRequiredScorer, maxScoreSums, minCompetitiveScore, checkTopScores) and symptoms (rounding, floating point, ulp, "skips a document", "rank inversion", "docnumbers don't match", "required clause" maxscore) | no report; #12589 and #13397 are the closest |
| Commits, downstream, mailing lists, JIRA, web (same tool) | 2026-10-26 | 3 commit queries, 4 downstream (Elasticsearch, OpenSearch, Solr), 35 on dev@, java-user@, issues@, commits@ and builds@, 5 in the JIRA archive, 10 web; 88 queries in total, 310 distinct hits | nothing about this bug |
| Manual searches | 2026-10-26 | 27 quota-gated GitHub API searches, separate list and JIRA passes, 11 web searches | unrelated items only |
| Open PRs and branches | 2026-10-26 | changed files of all 413 open PRs; all 288 issues and PRs updated since 2026-10-01; the file on main, branch_10x and branch_10_6 | no change to line 540 (#15081, #14968, #14024, #12446 modify the file elsewhere; #15346 adds a test only); last commit on the file #16520 (2026-09-18) |
| Earlier searches | 2026-10-22 to 2026-10-26 | the study's GitHub searches and the candidate step (51 queries) | no report |
Introduced by #12589 (merged 2023-10-24; its branch_9x backport shipped in 9.9.0). All 22 releases from 9.9.0 to 10.5.2 and main fail the contract test; 9.8.0 passes. The end-to-end input fails from 10.1.0, where #13800 repartitions sooner.
Versions tested8 rows
#12589 ("Sometimes intersect the essential clause and the best non-essential clause."; 9.9.0 CHANGES: "Disjunctions now sometimes run as conjunctions when the minimum competitive score requires multiple clauses to match.") was committed on 2023-10-24 as 611bbbd9512 on main (first released in 10.0.0) and as the backport 59e0a192097 on branch_9x, which shipped in 9.9.0. Since then only three commits touched the line, each renaming minCompetitiveScore to or from scorable.minCompetitiveScore (#14046, its revert, #14852).
Every release row ran the repro on the release's Maven Central jars, SHA-1 checked; the commit rows swap only MaxScoreBulkScorer.java onto a release's jars. All rows ran on OpenJDK 25.0.3, Linux aarch64, not on each release's minimum supported JDK.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed (unreleased 11.0) | affected | both tests fail, with -ea and -da |
| 10.1.0 to 10.5.2 | affected | both tests fail (-da checked on 10.1.0 and 10.5.2) |
| 9.9.0 to 9.12.3, 10.0.0 | affected | the contract test fails; this end-to-end input does not reach the bug |
| 9.8.0 | not affected | both pass (MaxScoreBulkScorer without the required-clause test) |
| 9.7.0 | not affected | no MaxScoreBulkScorer |
59e0a192097 vs its parent 1865ffebce5 (on 9.8.0 and 9.9.0 jars); 611bbbd9512 vs 5bca0aa3914 (on 10.0.0 jars) | bisect | the parent passes, #12589 fails, on both lines |
59e0a192097 + the one-line fix (on 9.9.0 jars) | fix | passes |
#13800 8ad66899bc9 vs parent b8bfffa3681 (on 10.0.0 jars) | end-to-end input | the end-to-end test starts failing with #13800, which re-runs the partition as soon as the minimum rises |
Tests written independently by the finder, the study's verifier, a cross-model reviewer and a second adjudication (4 or 5 clauses, different numbers) all fail on main and pass with the fix. A read-only cross-model review recorded a procedural "not real" and called it no substantive refutation.
Checks and verdicts408 words
Each check worked from the code or its own runs; none found a reason to call it intended.
| Check | Verdict | What it found |
|---|---|---|
| Study verifier (top-k pruning proof) | new bug, severity low | Its own numbers (3.0, 2.0, 1 + 2^-22, 2^-51 + 2^-70) and layout; the fix passes its repro and 88 upstream tests. Found that TOP_SCORES vs COMPLETE is not a reliable signature (the caveat above) and ran the 1-in-64M simulation. |
| Independent Dafny replication | replicated with caveat | The required-clause test as coded fails to verify; re-introducing the bug into the fixed test fails the proof. |
| Hunt verification | real | Both tests fail on main and 10.5.2 with -ea and -da, on 5 seeds and 20 of 20 iterations; the fix passes them and 106 upstream tests. |
| Cross-model refute review | not real, low confidence (procedural) | An independent reviewer from another model family (OpenAI GPT-5.6). Its code-executing first attempt wrote its own test, which fails on main and passes with the fix; its read-only second attempt could not run code, recorded "not real" and called that "not a substantive refutation"; 9 of its 10 evidence items support the bug, and the 10th records that it could not run a command. |
| Second adjudication | real, high confidence | A third test with 5 clauses where the bad rounding is in the second loop iteration: 30 of 30 iterated runs fail, all pass with the fix; 121 upstream tests pass stock and patched. |
| History and prior-report follow-ups | introduced by #12589 (9.9.0); new | Every release 9.7.0 to 10.5.2, one-file swaps on both branches; 88 tool queries plus manual searches found nothing. |
| This page's own runs | reproduced; fix validated | Gradle: the repro fails 2 of 2 stock (assertions on and off); with the fix 157 nearby tests and the 2,332 tests of the search package pass. The paging probe misses doc 5000 on main and 10.5.2 and finds it with the fix. A second fix variant passes all repros. |
| Fact checks of this page and its diagram | bug confirmed; text corrected | A fact check re-ran the gradle repro (assertions on and off), the fix, the runner and the paging probe and recomputed the arithmetic; a second came from an independent reviewer from another model family (OpenAI GPT-5.6) with read-only file access; a diagram check re-ran the diagram's probe. All confirmed the bug; their corrections of scope, counts, dates and wording are applied. |
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 574 KB
Finding record, Lucene and upstream sources, repro files and diagram notes566 words
- Registry title
- MaxScoreBulkScorer can skip a document whose score equals the minimum competitive score (1-ulp rank inversion)
- Area
- search / scoring
- Priority
- Low, rank 1160: MaxScoreBulkScorer can swap a doc exactly tied with the k-th score on a float-rounding midpoint (about 1 in 64M adversarial trials), silent but a 1-ulp tie with no real relevance loss.
- Impact class
- silent wrong results
- Impact note
- Default scoring, but only disjunctions of 4+ SHOULD clauses with one essential clause, clause scores spanning more than 2^29 and a sum at a float midpoint (about 1 in 64M in simulation); released since 9.9.0.
- Summary
- You run a top-k disjunction of 4 or more SHOULD clauses whose clause scores span more than 2^29. You expect every document that is competitive by its own score to be collected. Instead the required-clause test skips MathUtil.sumUpperBound, so a document whose score equals minCompetitiveScore can be dropped and the top hits change by one rank.
- Introduced
- #12589 (611bbbd9512) (2023-10-24)
- First affected version
- 9.9.0 [read]
- Affected versions
- 9.9.0+ [read]; main [exec]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that could only read the code (its low-confidence “not real” is procedural); not yet triaged by a person.
- Found by
- formal: Dafny
- Upstream and prior reports
- none found (study searches 2026-10-22); precedent #13397
- Lucene commit tested
- main
ea57360912ed(commit 2026-10-24; tested 2026-10-26): the JUnit repro with gradle in a scratch worktree, stock (assertions on and off) and with the fix, plus 157 nearby tests (assertions on and off) and the wholeorg.apache.lucene.searchtest package of:lucene:core(2,332 tests) with the fix (the worktree was removed after the runs); the repro through the repro runner with-eaand-daon main and 10.5.2 and on the Maven Central jars of every release from 9.7.0 to 10.5.2 (SHA-1 checked); a paging probe on main (-ea,-da), 10.5.2 and the fix; a second fix variant through the repro runner. The study reproduced it on7cecf79523and the public-list rerun onb83101cdd2; upstream main, branch_10x and branch_10_6 still had the same line on 2026-10-26.
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java at ea57360912ed (lines 166, 337-353, 447-472, 474-551); lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:340-364 (filtered disjunction)lucene/core/src/java/org/apache/lucene/search/ScorerUtil.java:131-142, 151-164, 173-190 at ea57360912ed; lucene/core/src/java/org/apache/lucene/util/MathUtil.java:162-194; lucene/core/src/java/org/apache/lucene/search/Scorable.java:45-52; lucene/core/src/java/org/apache/lucene/search/BooleanScorerSupplier.java:198-267 (routing: 203-207, 210, 233-234, 308-328, 340-364); lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:591-607; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, 599; lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:137-151, 154-164; lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:342-346, 380-392
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/Scorable.java:45-52; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:617-623; lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:35-55; lucene/core/src/java/org/apache/lucene/util/MathUtil.java:184-191; lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java:500-502, 525-531, 540 at ea57360912edlucene/core/src/java/org/apache/lucene/search/ScorerUtil.java:131-142; lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java:74-83, 163; lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionScorer.java:225-231 at ea57360912ed (same-order sums, no sumUpperBound)lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:192, 591; lucene/core/src/java/org/apache/lucene/search/DisjunctionSumScorer.java:68; lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:121-125; lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:744-749; lucene/core/src/test/org/apache/lucene/search/TestMaxScoreBulkScorer.java:264-490, 849 at ea57360912edread-only GitHub API, 2026-10-26: pulls/12589/reviews (0) and pulls/12589/comments (none)
- TestMaxScoreRequiredClauseRounding.java (JUnit test):
repro/LFV-060/src/org/apache/lucene/search/TestMaxScoreRequiredClauseRounding.java(97 lines)
The PNG is the summary card. Every value on screen was printed by an executed probe on the main jars (public API only, plus a prints-only copy of MaxScoreBulkScorer for the trace), or comes from the repro or the code. The diagram notes list each part with its sources.
assets/LFV-060/bug.png(574 KB)
