All findings · Lucene finding · LFV-060 · maxscore-required-clause-rounding

MaxScoreBulkScorer can skip a document whose score equals the minimum competitive score

With 4 or more SHOULD clauses whose scores span a huge range, top-k search can skip a competitive document, here one float step above the k-th hit: search(q, 1) returns doc 0 (1.87501478), not doc 5000 (1.87501490), and searchAfter paging never returns doc 5000. Every release since 9.9.0; contrived trigger.

Summary

Priority
Low, rank 1160MaxScoreBulkScorer 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
silent wrong results
Since
9.9.0 (#12589, 2023-10-24)introduced 2023-10-24 · about 3 years
Affected
9.9.0 to 10.5.2 (all 22 releases run), 10.6.0 (by reading) and main
Status
documented
Found by
a failed Dafny proof, then a Z3 floating-point counterexample

AI-assisted page: this finding was first signalled by a formal tool's own output 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

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.

Summary card of the MaxScoreBulkScorer rounding bug, end-to-end example on one segment: 4 SHOULD clauses BoostQuery(ConstantScoreQuery(TermQuery)) with boosts e 1.8749894, a 2.5451183E-5, t 1.1102315E-16 and j 1.0038807. Step 1: docs 0 to 1099 match e and a and score 1.8750148; doc 5000 matches e, a and t and scores 1.8750149; after 1001 hits TopScoreDocCollector sets the minimum competitive score to Math.nextUp(1.8750148f) = 1.8750149. Step 2: MaxScoreBulkScorer re-partitions at doc 5000: t, a and j are non-essential (max scores sum to 1.0039062), e is the only essential clause, so the required-clause test of #12589 runs at line 540. Step 3: (float) (e + (t + a)) = 1.8750148 < 1.8750149, so j becomes required; the double sum is exactly a float rounding midpoint and rounds down, with no MathUtil.sumUpperBound. Step 4: doc 5000 has no j, so ScorerUtil.applyRequiredClause drops it, yet its score (e + a) + t = 1.8750149 is not less than the bar. Expected doc 5000 with 1.8750149 (explain and the COMPLETE search agree); actual doc 0 with 1.8750148. A float grid shows the bound going from the midpoint e + a down to 1.8750148 and doc 5000's sum going from just above the midpoint up to 1.8750149. Notes: rare (4+ SHOULD clauses, one essential clause, scores spanning more than 2^29, a sum at a float midpoint, 1 in 64M trials in a simulation); affected 9.9.0 through 10.5.2 and main, this example from 10.1.0; a 2-document test where setMinCompetitiveScore(1.0000001f) skips a document that scores exactly 1.0000001; the executed fix wraps line 540 in MathUtil.sumUpperBound. The contract quotes of Scorable.setMinCompetitiveScore, IndexSearcher.search, TopScoreDocCollectorManager and MathUtil.sumUpperBound are at the bottom.
The end-to-end example step by step: after 1,001 hits the bar is 1.8750149, exactly doc 5000's score; the line-540 bound e + (t + a) rounds down to 1.8750148, so j becomes required and doc 5000, which lacks j, is dropped; search(q, 1) returns doc 0 (1.8750148). Select the still to open it at full size. Generated with AI, checked against the code.
  1. Run a SHOULD query of 4 constant-score clauses, e = 0.75, a = 0.25000006, t = 1.1e-16 and j = 0.5, over doc 0 (terms e, a, t) and doc 1 (term j). Doc 0 scores 1.00000012, the float just above 1.0, and explain agrees.
  2. 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.
  3. Why: to decide whether clause j is required, MaxScoreBulkScorer checks whether e + a + t can still reach the minimum, but adds e + (t + a). That lands exactly halfway between 1.0 and 1.00000012 and rounds down to 1.0, so j becomes required and doc 0, which lacks j, is dropped. Doc 0's own score, (e + a) + t, rounds up.
  4. 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 and explain rank doc 5000 first (1.87501490). Paging with searchAfter returns 1,101 hits and never doc 5000.
  5. 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.

Who is affected

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.

Terms

  • Minimum competitive score: once a TopScoreDocCollector has counted more than totalHitsThreshold hits (1,000 for IndexSearcher#search(q, n)), it calls setMinCompetitiveScore(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 of n values 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).

The bug (code at ea57360912ed)

  1. A TOP_SCORES query with 2 or more SHOULD clauses, no MUST or FILTER clause and minShouldMatch <= 1 gets a MaxScoreBulkScorer (BooleanScorerSupplier.java:210, :308-328). So does SHOULD + FILTER with no MUST clause and minShouldMatch exactly 1 (:233-234, :340-364), for example after BooleanQuery.rewrite inlines a single MUST disjunction next to a FILTER (BooleanQuery.java:591-607); FILTER + SHOULD with the default minShouldMatch 0 does not. With sparse MUST_NOT clauses (cost at most 0.3% of maxDoc) the Scorer-based path runs instead (:203-207).
  2. partitionScorers sorts the clauses by maxWindowScore / Math.max(1L, cost) and makes each clause non-essential whose max score keeps (float) MathUtil.sumUpperBound(runningSum, count) below minCompetitiveScore; 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 in maxScoreSums (:505).
  3. With a single essential clause (:524), the required-clause loop (:535-547) builds maxRequiredScore + maxScoreSums[firstRequiredScorer - 2] (:533, :536-539) and tests if ((float) maxPossibleScoreWithoutPreviousClause >= scorable.minCompetitiveScore) break; (:540), without sumUpperBound.
  4. A document's score starts from the essential clause (:343-348) and adds clauses i = n-2 .. 0 one at a time (:452-466); required clauses go through ScorerUtil.applyRequiredClause, which drops every buffered document that does not match (ScorerUtil.java:173-190). Float cast at :469.
  5. 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.

The numbers of the contract test

Clauses sorted t, a, j (non-essential) and e (essential) at minimum nextUp(1f):

quantitydoublefloat
exact sum e + a + t = 1 + 2^-24 + 2^-53 + 2^-76just above the midpoint 1 + 2^-24would be nextUp(1f)
bound e + (t + a) (line 540)0x1.000001p0, exactly the midpoint1.0f (tie to even), below the minimum: j becomes required
doc 0 (e + a) + t0x1.0000010000001p00x1.000002p0 = the minimum
bound with sumUpperBound(., 4)0x1.000002p0: j stays optional

What it takes

  • 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 a TopFieldCollector whose 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 on MaxScoreBulkScorer: a pure SHOULD disjunction with minShouldMatch <= 1, or SHOULD + FILTER with no MUST clause and minShouldMatch 1 (the FILTER case read in code, not executed). Codec-independent. The repro pins the default codec, one segment, NoMergePolicy and 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 searchAfter never 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).

Minimal repro

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.

TestMaxScoreRequiredClauseRounding.java (JUnit test)97 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 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)));
      }
    }
  }
}
Command
# 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 -ea and -da.

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

More files, environment, full output44 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: 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)

Why it looks unintended

  • Scorable.setMinCompetitiveScore allows 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 given minScore" (Scorable.java:45-47).
  • IndexSearcher#search(Query, int): "Finds the top n hits for query." (IndexSearcher.java:617-623). TopScoreDocCollectorManager: totalHitsThreshold is "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:

  1. 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 (explain agrees), and two TOP_SCORES runs on the same searcher that differ only in totalHitsThreshold return different top hits.
  2. 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.
  3. The fix adds a few floating-point operations per iteration of the required-clause loop (at most n-1 per partition), never per document.
  4. 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.

Suggested 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.

Wrap the bound in MathUtil.sumUpperBound (validated by the checks) (recommended)

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.length is 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, TestTopDocsCollector and TestMathUtil: 157 tests, 0 failures (seed DEADBEEF, assertions on; seed CAFEBABE, 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:tidy leaves 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 DEADBEEF and CAFEBABE, and 121 on seed 7E57AB1E.
  • 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.

Remove the required-clause optimization (not run)

Return to the 9.8.0 partition without required clauses.

Trade-offs

Loses the #12589 speedup for single-essential-clause windows. Not recommended.

How it was found

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 with sumUpperBound(., 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 BooleanScorer order, 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 ea57360912ed and 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.

Prior reports, history and checks

Prior reports

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).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), via a scripted prior-report search2026-10-2631 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-263 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 hitsnothing about this bug
Manual searches2026-10-2627 quota-gated GitHub API searches, separate list and JIRA passes, 11 web searchesunrelated items only
Open PRs and branches2026-10-26changed 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_6no 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 searches2026-10-22 to 2026-10-26the study's GitHub searches and the candidate step (51 queries)no report

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed (unreleased 11.0) affectedboth tests fail, with -ea and -da
10.1.0 to 10.5.2 affectedboth tests fail (-da checked on 10.1.0 and 10.5.2)
9.9.0 to 9.12.3, 10.0.0 affectedthe contract test fails; this end-to-end input does not reach the bug
9.8.0 not affectedboth pass (MaxScoreBulkScorer without the required-clause test)
9.7.0 not affectedno MaxScoreBulkScorer
59e0a192097 vs its parent 1865ffebce5 (on 9.8.0 and 9.9.0 jars); 611bbbd9512 vs 5bca0aa3914 (on 10.0.0 jars) bisectthe parent passes, #12589 fails, on both lines
59e0a192097 + the one-line fix (on 9.9.0 jars) fixpasses
#13800 8ad66899bc9 vs parent b8bfffa3681 (on 10.0.0 jars) end-to-end inputthe end-to-end test starts failing with #13800, which re-runs the partition as soon as the minimum rises

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (top-k pruning proof) new bug, severity lowIts 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 caveatThe required-clause test as coded fails to verify; re-introducing the bug into the fixed test fails the proof.
Hunt verification realBoth 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 confidenceA 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); newEvery 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 validatedGradle: 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 correctedA 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.

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 notes566 words

Finding record

LFV-060, snapshot of 2026-11-06

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 whole org.apache.lucene.search test package of :lucene:core (2,332 tests) with the fix (the worktree was removed after the runs); the repro through the repro runner with -ea and -da on 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 on 7cecf79523 and the public-list rerun on b83101cdd2; upstream main, branch_10x and branch_10_6 still had the same line on 2026-10-26.

Lucene and upstream sources

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 ea57360912ed
  • lucene/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 ea57360912ed
  • read-only GitHub API, 2026-10-26: pulls/12589/reviews (0) and pulls/12589/comments (none)

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

Repro files

About the diagrams

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.

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.