All findings · Lucene finding · LFV-104 · minrequiredscore-infinite-nan

A +Infinity minimum competitive score makes ScorerUtil.minRequiredScore return NaN, so top-k search can drop documents scoring +Infinity

When the k-th best score of a top-k search is Float.MAX_VALUE, the pruning cut-off ScorerUtil.minRequiredScore comes out as NaN, so conjunctions and disjunctions drop every remaining candidate: search(q, 1) returns doc 0 at Float.MAX_VALUE instead of doc 1001 at +Infinity. A rare regression in 10.3.0, still in 10.6.0.

Summary

Priority
Low, rank 951A +Inf minimum competitive score turns minRequiredScore into NaN so block-max and MaxScore scorers drop +Inf docs, which only Float.MAX-scale pin-first boosts or overflowing FeatureField values produce.
Impact
silent wrong results
Since
10.3.0 (#14827 and a same-day follow-up, 2025-07-03)introduced 2025-07-03 · about 1 year
Affected
10.3.0 to 10.6.0 and main (all 8 releases run)
Status
documented
Found by
semi-formal: modelling the block-max pruning proof obligations

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

Pruning keeps a document only if its partial score can still reach the minimum competitive score. For a minimum of +Infinity the cut-off comes out as NaN, and no score is >= NaN.

Background: how top-k pruning works

  1. A top-k search keeps the k best hits; ties go to the lower doc id. After counting 1,000 hits, the collector tells the scorer the minimum competitive score: what a later document must reach to get in.
  2. Conjunctions and disjunctions score one clause for a batch of documents first, then drop those whose partial score cannot reach that minimum even if the other clauses add their maximum.
  3. That cut-off is ScorerUtil.minRequiredScore: the minimum competitive score minus the other clauses' maximum, stepped down one float ulp at a time until it is safe.
Diagram of the bug: doc 0 scores Float.MAX_VALUE and becomes the top hit; after 1,000 hits the collector sets the minimum competitive score to nextUp(Float.MAX_VALUE) = +Infinity; ScorerUtil.minRequiredScore computes +Infinity - ulp(+Infinity) = NaN; filterCompetitiveHits keeps only scores >= NaN, so the batch holding doc 1001 (score +Infinity) is emptied and search(q, 1) returns doc 0 instead of doc 1001. The fix starts the cut-off from Float.MAX_VALUE.
How a k-th score of Float.MAX_VALUE turns into a +Infinity minimum, a NaN cut-off and an empty batch, so doc 1001 at +Infinity is lost. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 1,002 small documents with two FeatureField features and run a plain searcher.search(q, 1) for the conjunction +linear(a, 1.9999999) +linear(b, 1). Doc 0 scores exactly Float.MAX_VALUE (3.4028235E38); on doc 1001 the feature times the weight overflows, so it scores +Infinity. The rest score about 3.
  2. You expect doc 1001, the only +Infinity hit. An exhaustive search and Lucene 10.2.2 return it.
  3. After 1,000 hits the collector starts pruning. Ties go to the lower doc id, so a later document must beat 3.4028235E38: the minimum competitive score becomes nextUp(Float.MAX_VALUE), which is +Infinity.
  4. The bulk scorer turns that into a cut-off for partial scores: +Infinity - ulp(+Infinity) is NaN, and keeping only partial scores >= NaN keeps nothing. Doc 1001 is dropped and you get doc 0, with no error.
  5. Concurrent searches lose +Infinity ties the same way (docs 100, 101, 102 instead of 0, 1, 2). Every release from 10.3.0 to 10.6.0 fails the test; 10.2.2 passes.

Who is affected

Top-k conjunctions and disjunctions that reach the block-max filter step, even a plain IndexSearcher#search(q, n) with default settings, over documents whose scores overflow to +Infinity (large FeatureField values, huge finite boosts). Single-threaded it needs the k-th score to be exactly Float.MAX_VALUE; concurrent, k +Infinity hits in a later slice. Rare input; only hits scoring Float.MAX_VALUE or +Infinity are affected.

Mechanism, terms and numbers653 words

Lucene's top-k conjunctions (BlockMaxConjunctionBulkScorer) and disjunctions (MaxScoreBulkScorer) prune candidates with ScorerUtil.filterCompetitiveHits, which keeps a document only if its partial score is at least minRequiredScore(maxRemainingScore, minCompetitiveScore, n). When the minimum competitive score is +Infinity, that helper computes +Inf - R = +Inf, subtracts Math.ulp(+Inf) = +Inf and returns NaN; nothing is >= NaN, so every candidate is dropped, including documents that already score +Infinity.

TopScoreDocCollector asks for +Infinity itself: Math.nextUp(Float.MAX_VALUE) when the k-th best score is Float.MAX_VALUE, and the raw +Infinity from another slice in a concurrent search. So a plain single-threaded search(q, 1) returns doc 0 at Float.MAX_VALUE instead of doc 1001 at +Infinity, and a concurrent search can return the wrong documents among hits tied at +Infinity. No error is raised. It is a regression from a 10.3.0 speed-up (#14827 and a same-day follow-up); 10.2.2 is correct. The trigger is rare: scores must overflow to +Infinity, for example from very large FeatureField values or boosts near Float.MAX_VALUE. Starting the threshold from Float.MAX_VALUE for a +Infinity minimum fixes it.

The bug, step by step (main 645d4fe14a20)

  1. The collector asks for +Infinity. TopScoreDocCollector breaks ties by ascending doc id, so once it is pruning, a later document must score strictly more than the k-th best: it calls setMinCompetitiveScore(Math.nextUp(topScore)) (TopScoreDocCollector.java:154-164, nextUp at :160). For a k-th score of Float.MAX_VALUE that is +Infinity. In a concurrent search, a leaf with lower doc ids gets the raw score from another slice, without nextUp, so that its tied documents stay competitive (:137-152, :145); after k hits at +Infinity in that slice, that is +Infinity too.
  2. The bulk scorers turn it into a cut-off. Before adding each further clause, BlockMaxConjunctionBulkScorer.scoreWindowScoreFirst calls ScorerUtil.filterCompetitiveHits(buffer, sumOfOtherClauses[i], minCompetitiveScore, n) (BlockMaxConjunctionBulkScorer.java:182-191); MaxScoreBulkScorer.scoreNonEssentialClauses does the same (MaxScoreBulkScorer.java:452-458). These are the only callers.
  3. The cut-off is NaN. minRequiredScore (ScorerUtil.java:131-142) starts from minCompetitiveScore - maxRemainingScore = +Inf - R = +Inf (:133) and steps by Math.ulp(minCompetitiveScore) = +Inf (:135). The loop test minRequiredScore > 0 && (float) sumUpperBound(minRequiredScore + R, n) >= minCompetitiveScore is true (:136-138), so it computes +Inf - +Inf = NaN (:139); NaN > 0 is false, and NaN is returned (:141).
  4. NaN keeps nothing. filterCompetitiveHits returns early only if (minRequiredScore <= 0) (:158), which is false for NaN, and then VectorUtil.filterByScore keeps a document only if score >= NaN (:162-163; VectorUtil.java:601-620), which is never true. The batch is emptied and none of it is collected (BlockMaxConjunctionBulkScorer.java:194-197).

The numbers in the repro

documentfeature ascore of +linear(a, nextDown(2)) +linear(b, 1)
doc 02^1272^127 x 1.9999999 = Float.MAX_VALUE (3.4028235E38); + 1 still rounds to it
docs 1-10001about 3
doc 10011.5 x 2^127overflows to +Infinity

The collector collects doc by doc until it has counted more than 1,000 hits (IndexSearcher.java:100, TopScoreDocCollector.java:113-120). At doc 1000 the minimum competitive score becomes nextUp(3.4028235E38) = +Infinity, and from doc 1001 on the pruning path runs (BlockMaxConjunctionBulkScorer.java:92-107). With the +Infinity document at doc 1000 or lower, the test passes.

Other executed forms

searchexpectedmain returns
the repro: one segment, no executor, search(q, 1), conjunctiondoc 1001, +Infinitydoc 0, Float.MAX_VALUE
the same idea with 3,001 docs, conjunction and disjunctiondoc 3000, +Infinitydoc 0, Float.MAX_VALUE (both)
concurrent search, the slice with docs 100-199 finishes firstdocs 0, 1, 2 (all +Infinity)docs 100, 101, 102
stock slicing, caller-runs executor, two Float.MAX_VALUE boostsdocs 0, 1, 2docs 10, 11, 12
a custom collector that sets a +Infinity minimum before any hit300 +Infinity documents0 collected
classic QueryParser, a finite 38-digit boost on BM25 text, cross-slicedocs 0, 1, 2docs 3103, 3104, 3105

All fail with assertions on and off; all pass with the fix.

Exposure and likelihood291 words
  • Who can be hit. Top-k searches (ScoreMode.TOP_SCORES; IndexSearcher#search(Query, int) is enough) that run a top-level conjunction on BlockMaxConjunctionBulkScorer or a top-level disjunction on MaxScoreBulkScorer, over documents whose scores overflow to +Infinity, when the search reaches the filter step (the conjunction scorer skips it before a clause when two consecutive remaining-score bounds are equal, BlockMaxConjunctionBulkScorer.java:184-189). The inputs pass every check: FeatureField accepts any finite positive normal value (FeatureField.java:152-172) and linear weights in (0, 64] (:593-597); BoostQuery rejects only non-finite or negative boosts (BoostQuery.java:38-43), and the classic QueryParser accepts a 38-digit boost.
  • When. Single-threaded: the k-th best score is exactly Float.MAX_VALUE and a later document scores +Infinity. Concurrent: a slice with higher doc ids collects k hits at +Infinity first, so a slice with lower doc ids drops its +Infinity ties. A single-threaded search whose k-th score is already +Infinity shows nothing wrong.
  • Defaults. Default IndexWriterConfig and codec, an IndexSearcher without an executor, 1,002 documents. The rare part is the input.
  • What goes wrong. A silently wrong top-k, with assertions on and off: a +Infinity document loses to a Float.MAX_VALUE one, or the documented doc-id tie-break among +Infinity hits breaks. Hits below Float.MAX_VALUE rank correctly; no extra, deleted or filtered-out document ever appears (22 executed checks). With the stock collector the result is never empty: it raises the minimum to +Infinity only once a queue holds k hits whose k-th best scores Float.MAX_VALUE or more (TopScoreDocCollector.java:137-171). A custom collector that sets a +Infinity minimum before any hit collects nothing (0 of 300 in the first verifier's test).
  • Plausible patterns (API reachability only; no real-world use was observed). Boosts near Float.MAX_VALUE used as a "pin this first" sentinel, or unbounded FeatureField values.

Minimal repro

One JUnit test on the public API: 1,002 documents in one segment, a two-clause FeatureField conjunction and a plain search(q, 1). Deterministic and single-threaded; the codec is pinned to the default because the test framework randomizes it. Put it in core's test tree and run it with gradle.

TestMinRequiredScoreInfinite.java (one test, public API)56 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.FeatureField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
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;
import org.apache.lucene.tests.util.TestUtil;

public class TestMinRequiredScoreInfinite extends LuceneTestCase {

  public void testInfiniteScoreAfterMaxValueBottom() throws Exception {
    int lateDoc = 1001;
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (int i = 0; i <= lateDoc; i++) {
          Document doc = new Document();
          doc.add(new FeatureField("f", "a", i == 0 ? 0x1p127f : i == lateDoc ? 0x1.8p127f : 1f));
          doc.add(new FeatureField("f", "b", 1f));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query q =
            new BooleanQuery.Builder()
                .add(FeatureField.newLinearQuery("f", "a", Math.nextDown(2f)), Occur.MUST)
                .add(FeatureField.newLinearQuery("f", "b", 1f), Occur.MUST)
                .build();
        ScoreDoc top = new IndexSearcher(reader).search(q, 1).scoreDocs[0];
        // expected: doc 1001 scoring +Infinity; actual: doc 0 scoring Float.MAX_VALUE
        assertEquals("top hit " + top.doc + " score " + top.score, lateDoc, top.doc);
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20
cp $REPRO/TestMinRequiredScoreInfinite.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestMinRequiredScoreInfinite
# assertions off fails the same way: add -Ptests.asserts=false
Expected
The top hit is doc 1001, the only document scoring +Infinity (as in Lucene 10.2.2).
Observed
The top hit is doc 0 scoring 3.4028235E38: top hit 0 score 3.4028235E38 expected:<1001> but was:<0>, with assertions on and off.

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

More files, environment, full output212 words

The test sits in org.apache.lucene.search, the package of the upstream TestScorerUtil, but uses only public API. Its own code draws no random values and pins the codec; it extends LuceneTestCase, whose environment is still randomized (similarity, locale, time zone), and seeds DEADBEEF and 5EED1234 and 40 random-seed iterations give the same failure, with assertions on and off. new IndexSearcher(reader) has no executor. The test does not assert a single segment; a probe that indexes the same 1,002 documents with a default IndexWriterConfig reports segments=1. All runs were on Linux aarch64 with OpenJDK 25.0.3; no other JVM or architecture was tried (the mechanism is plain IEEE 754 float arithmetic).

It exercises the conjunction path (BlockMaxConjunctionBulkScorer). The disjunction path (MaxScoreBulkScorer) shares the helper and fails the same way in the 3,001-document variant, so one test method is enough. As a regression test it is the same test: it fails on stock and passes with either fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same failure on every release from 10.3.0 to 10.6.0; 10.2.2 and 9.12.3 pass
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

TestMinRequiredScoreInfinite > testInfiniteScoreAfterMaxValueBottom FAILED
    java.lang.AssertionError: top hit 0 score 3.4028235E38 expected:<1001> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:9E3F8F3100331A09]: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.apache.lucene.search.TestMinRequiredScoreInfinite.testInfiniteScoreAfterMaxValueBottom(TestMinRequiredScoreInfinite.java:52)

Why it looks unintended

  • Scorable#setMinCompetitiveScore lets a scorer skip only documents whose score is less than the minimum. +Infinity is not less than +Infinity.
  • +Infinity is not declared invalid: TopScoreDocCollector lists only NaN and -Infinity as invalid, and it passes +Infinity as the minimum itself.
  • 10.2.2 returns the right hit. The change was a speed-up whose javadoc says a loose threshold "doesn't hurt correctness".
The full argument247 words
  • The contract. A scorer "may safely ignore all documents whose score is less than the given minScore" (Scorable.java:45-52). The minRequiredScore javadoc promises a value with (float) sumUpperBound(minRequiredScore + maxRemainingScore, n) <= minCompetitiveScore and says a non-optimal value "doesn't hurt correctness, it just means that these docs will be filtered out later" (ScorerUtil.java:123-130). NaN is no threshold, and here the documents are lost, not filtered later.
  • +Infinity is not declared invalid. TopScoreDocCollector names only Float.NaN and Float.NEGATIVE_INFINITY as invalid (TopScoreDocCollector.java:30-31); +Infinity was taken off that list in LUCENE-2271 (2010). That is an omission, not an affirmative guarantee, but the collector produces +Infinity itself (next point). The test framework's AssertingScorer never checks for infinity: it asserts not-NaN, >= 0 and <= the max score for scores (AssertingScorer.java:129-131), and not-NaN and non-decreasing from 0 for minimum scores (:63, :92-93). TestWANDScorer covers +Infinity max scores (testRandomWithInfiniteMaxScore, @Nightly, :828-836).
  • Lucene produces the +Infinity minimum itself, at TopScoreDocCollector.java:160 (nextUp) and :145 (the raw score that keeps lower-doc-id ties competitive).
  • A regression from a speed-up. 10.2.2 and 9.12.3 pass the same test. #14827 is listed under Optimizations in CHANGES 10.3.0; its review and the two same-day follow-ups discuss finite rounding only, and no comment, commit message, test or CHANGES entry mentions infinite or NaN scores. Upstream TestScorerUtil.testMinRequiredScore draws the minimum from random().nextFloat(), in [0, 1) (TestScorerUtil.java:100-104), so it never reaches this case.
The devil's-advocate check190 words

The strongest case for "intended": overflowing scores are degenerate. LUCENE-8016 (#9064, open since 2017) calls the infinite scores from boosts near Float.MAX_VALUE "trappy" and proposes bounding boosts; WANDScorer.scaleMinScore asserts Float.isFinite(minScore) (WANDScorer.java:112); FeatureField's linear function is "Expert", with "an expectation that a feature value is encoded in the index in a way that makes sense for scoring" (FeatureField.java:78-81); and the filterCompetitiveHits javadoc speaks of scores that "exceed" the minimum.

Why it fails: none of these is a documented precondition. LUCENE-8016 was never implemented, and BoostQuery and FeatureField accept the trigger inputs. The WANDScorer assert is an internal assumption that TopScoreDocCollector itself breaks (a separate bug, LFV-113). "Exceed" cannot mean that a +Infinity document must be dropped when the collector asks for exactly +Infinity, and 10.2.2 kept it. The study's reviewers rated the argument weak; it is about how rare the input is, which the low priority reflects. A later cross-model fact check of this doc (OpenAI GPT-5.6) rated it moderate: upstream could treat the fix as low-priority hardening, given the extreme inputs and no known production report, though it still judged the behaviour a real defect.

Suggested fix

Recommended Start the cut-off from Float.MAX_VALUE for a +Infinity minimum

In minRequiredScore, start from Float.MAX_VALUE when the minimum competitive score is +Infinity. The loop still compares against +Infinity, so it returns a finite cut-off below which no document can reach +Infinity. It passes the repro, the 2,340 tests of the core search package and :lucene:core:check.

-    double minRequiredScore = minCompetitiveScore - maxRemainingScore;
+    // With minCompetitiveScore = +Infinity, starting from it would give +Inf - ulp(+Inf) = NaN.
+    // Start from the largest finite float instead; the loop below still compares against +Inf.
+    float start =
+        minCompetitiveScore == Float.POSITIVE_INFINITY ? Float.MAX_VALUE : minCompetitiveScore;
+    double minRequiredScore = start - maxRemainingScore;
     // note: we want the float ulp in order to converge faster, not the double ulp
-    double subtraction = Math.ulp(minCompetitiveScore);
+    double subtraction = Math.ulp(start);
All options and trade-offs3 options ยท 604 words

Two fixes were run through gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF). The recommended one is the hunt's candidate fix, with the infinity test narrowed to +Infinity.

Start the cut-off from Float.MAX_VALUE for a +Infinity minimum (validated by the checks) (recommended)

In ScorerUtil.minRequiredScore (ScorerUtil.java:133-135). Finite minimums are unchanged; for an infinite maximum of the other clauses the start is -Infinity and nothing is filtered, as today when that maximum exceeds the minimum.

-    double minRequiredScore = minCompetitiveScore - maxRemainingScore;
+    // With minCompetitiveScore = +Infinity, starting from it would give +Inf - ulp(+Inf) = NaN.
+    // Start from the largest finite float instead; the loop below still compares against +Inf.
+    float start =
+        minCompetitiveScore == Float.POSITIVE_INFINITY ? Float.MAX_VALUE : minCompetitiveScore;
+    double minRequiredScore = start - maxRemainingScore;
     // note: we want the float ulp in order to converge faster, not the double ulp
-    double subtraction = Math.ulp(minCompetitiveScore);
+    double subtraction = Math.ulp(start);

Trade-offs

  • A top-k saturated at +Infinity now keeps and scores its +Infinity candidates, as the contract requires, so it costs about as much as exhaustive scoring: 13.9-17.3 ms against 12.2-17.8 ms for COMPLETE on 400,000 documents that all score +Infinity, where the buggy path took 3.5-3.8 ms because it discarded them (rough, best of 5 on a shared host).
  • Normal queries are unaffected: the new branch runs only for a +Infinity minimum.
  • The candidate fix used Float.isInfinite(minCompetitiveScore), which is also true for -Infinity; then the loop would step down from Float.MAX_VALUE about 2^24 times. Lucene's collectors never pass -Infinity, but == Float.POSITIVE_INFINITY keeps today's behaviour for it. From 0 to +Infinity the two forms are identical.

What the checks validated

  • Stock: the repro fails with assertions on and off.
  • Fix: the repro passes with assertions on and off.
  • The closest suites (TestScorerUtil, TestMaxScoreBulkScorer, TestBlockMaxConjunction, TestBlockMaxConjunctionBulkScorer, TestWANDScorer, TestBooleanQuery, TestBoolean2, TestBooleanMinShouldMatch and the repro): 94 tests, 0 failures, 4 skipped.
  • :lucene:core:test --tests 'org.apache.lucene.search.*': 215 classes, 2,340 tests, 0 failures, 49 skipped.
  • :lucene:core:check -x test with the repro in the tree passes (google-java-format, ecjLint main and test, forbiddenApis, licenses, checksums). The full core suite was not run.
  • Earlier, the candidate's Float.isInfinite form, shadowed onto the stock jars, passed the finder's, the intended-behaviour check's and the cross-model reviewers' tests and the 19 history tests over 10.3.0, and the safety-property probe's bug detector fired in 0 of 22 checks.

Skip filtering when the cut-off is NaN (validated by the checks)

In ScorerUtil.filterCompetitiveHits (ScorerUtil.java:158).

-    if (minRequiredScore <= 0) {
+    // also skip filtering when minRequiredScore is NaN (an infinite minCompetitiveScore)
+    if (!(minRequiredScore > 0)) {
       return;
     }

Trade-offs

One line, and it guards any future NaN. But minRequiredScore still returns NaN, against its own javadoc, and for a +Infinity minimum this step then prunes nothing (still correct: the collector rejects non-competitive hits). It can be combined with the recommended fix.

What the checks validated

Gradle: the repro passes, and the same closest suites pass (94 tests, 0 failures, 4 skipped). A NaN guard of the same kind also made the first verifier's 5 public-API tests pass. Not run with the whole core search package, so the recommended fix is the more strongly validated one; neither fix ran the full core suite.

Cap the minimum at Float.MAX_VALUE in TopScoreDocCollector (not run)

Pass Float.MAX_VALUE instead of +Infinity at TopScoreDocCollector.java:145 and :160.

Trade-offs

Fixes only the stock collector: any other collector may still call setMinCompetitiveScore(+Infinity) (a custom collector in the checks collected 0 of 300 +Infinity documents), and minRequiredScore would still return NaN. Not recommended.

Downstream note

Not a Lucene security issue; downstream, an application that passes user-typed boosts to the classic QueryParser lets users reach the concurrent form, which only changes which of the hits tied at +Infinity come back.

How it was found

Semi-formal: while modelling the proof obligations of block-max pruning, an AI tool saw that the lemma "a partial score below minRequiredScore cannot reach the minimum" assumes a finite minimum. Evaluating the helper by hand at +Infinity gave NaN; a unit test on the stock jar confirmed it, and independent checkers then reproduced it with their own tests.

The method in full134 words

Method: semi-formal (a hypothesis formed while modelling, then witnessed by a tool), a hunt unit on WAND and block-max bounds.

  • While modelling WAND and block-max pruning bounds (SMT lemmas for WANDScorer's scaled-integer test, which assume finite scores), an AI tool reviewed the block-max filter by code reading: a document dropped by filterCompetitiveHits must not be able to reach the minimum competitive score. That obligation, like the earlier study's proof of the two helpers, presumes a finite minimum.
  • Evaluating minRequiredScore at +Infinity by hand gives +Inf - ulp(+Inf) = NaN, and filterByScore(NaN) keeps nothing. The signal came from code reading while modelling.
  • A JUnit on the stock main jar witnessed it (the helper and a forced-order concurrent search); an independent verifier found the simpler single-threaded form that the minimal test is cut down from.

Prior reports, history and checks

Prior reports

No prior report: 7 search-tool runs (GitHub, mailing lists, JIRA, downstream projects, web), full reads of the PRs that built this code and of related JIRA issues, and the changed files of all 412 open PRs, on 2026-10-25 and 2026-10-26; re-checked 2026-10-29.

Searches5 searches

Closest items: #14827 introduced the threshold (its review discusses finite rounding only); #9064 / LUCENE-8016, an open 2017 proposal to bound boosts because huge boosts give infinite scores; #11589 / LUCENE-10553 is a precedent, a WANDScorer overflow to +Infinity that was fixed.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-25, 2026-10-2650 tool queries, e.g. minRequiredScore, filterCompetitiveHits, ScorerUtil, MaxScoreBulkScorer, BlockMaxConjunctionBulkScorer, "infinite score", setMinCompetitiveScore Infinity; a title and body grep of the 646 issues and PRs updated since 2026-08-01only the PRs that built the code path: #14701, #14751, #14827, #14896, #14906, #14970 (reverted), #15081 (open, does not touch minRequiredScore)
Open PRs2026-10-26; re-checked 2026-10-29changed files of all 412 open PRs; new items on minRequiredScore and BlockMaxConjunctionBulkScorernone changes minRequiredScore or filterCompetitiveHits; #16855 / #16856 (window past maxDoc, performance) only
Mailing lists (dev@, java-user@, issues@, commits@)2026-10-25, 2026-10-2671 queries: infinite scores, minCompetitiveScore NaN, score overflow, huge boost infinity, the class namesonly mirrors of the GitHub items and release threads
JIRA archive2026-10-2614 queries; full reads of LUCENE-2271, LUCENE-8006, LUCENE-8016, LUCENE-10428, LUCENE-10503, LUCENE-4100, LUCENE-8197, LUCENE-8992, LUCENE-8759contract history only: LUCENE-2271 took +Infinity off the collector's invalid-score list; LUCENE-8016 proposes bounding boosts
Elasticsearch, OpenSearch, Solr; GitHub code search; web2026-10-26issue and code search; 9 of 18 planned web queries plus a manual batchno report; no fork with an infinity guard

History

A regression from the 10.3.0 speed-up #14827 (2025-07-03), widened the same day by the follow-up 3f71b54c47a, which gave today's NaN loop. The minimal test fails on all 8 releases from 10.3.0 to 10.6.0 and on main, and passes on 10.2.2 and 9.12.3. Not fixed upstream.

Versions tested8 rows

Introduced on 2025-07-03 in two steps, both first released in 10.3.0:

  1. #14827 "Pre-calculate minRequiredScore to speedup filterCompetitiveHits" (main 62e02760321, branch_10x 5687ed5e8a0; CHANGES 10.3.0, Optimizations) replaced the per-document test (float) sumUpperBound(score + R, n) >= minCompetitiveScore, which kept these documents, with a precomputed threshold. For a +Infinity minimum it returned +Infinity, so a document whose finite partial score would overflow to +Infinity was already dropped.
  2. The same-day follow-up "Improve test and address more test failures." (main 3f71b54c47a, branch_10x d0a4b104214, no PR) gave today's loop, which returns NaN, so documents already at +Infinity are dropped too. The minimal test fails from here on.

The commit in between, bf848a9a3ab "Fix test failures.", does not change the result for this input; commit-level runs (each commit's ScorerUtil and BlockMaxConjunctionBulkScorer over the 10.3.0 jars) show 13/13 correct before #14827, 7 of 19 wrong at #14827 and bf848a9a3ab, and 16 of 19 wrong from 3f71b54c47a on, as on every release. Upstream main 2f424fd428da (2026-10-30) and the 10.6.0 tag have the same method bodies; #16855 (in 10.6.0) only clamps the window to maxDoc.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affecteddoc 0 instead of doc 1001, assertions on and off (gradle and the standalone runner)
10.6.0 affectedthe same failure, assertions on and off
10.5.0, 10.5.1, 10.5.2 affectedthe same failure (10.5.2 also with assertions off)
10.3.0, 10.3.1, 10.3.2, 10.4.0 affectedthe same failure (10.3.0 also with assertions off)
10.2.2, 9.12.3 not affectedthe test passes: top hit doc 1001
#14827 5687ed5e8a0 (commit level) bisectthe single-threaded probe the minimal test is cut down from passes; a test whose finite partial scores overflow to +Infinity fails (7 of 19 history tests fail)
3f71b54c47a / d0a4b104214 (commit level) bisect16 of 19 history tests fail, as on 10.3.0
fix on 645d4fe14a20 not affectedthe minimal test passes; 2,340 core search tests pass

Independent checks

Confirmed with high confidence by the study's judge after an independent reproducer, an intended-behaviour devil's advocate and reviews by another model family (OpenAI GPT-5.6) that ran their own tests, with no dissent; re-verified on 645d4fe14a20 and the 10.6.0 jars.

Checks and verdicts278 words

Each checker wrote its own tests from the claim; the adjudication re-ran two of them with new seeds.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateHelper test (NaN threshold) and forced-order concurrent conjunction and disjunction: 3 of 3 fail on main, 10.5.2 and 10.3.0, with assertions on and off; 20 of 20 over 10 iterations; the fix passes.
refute review (OpenAI GPT-5.6) real, highIts own forced-tie conjunction test returns doc 137 instead of doc 0 and its plain IndexSearcher.search test returns 113 instead of 0; a finite control passes; 10.2.2 passes, 10.3.0 fails; both pass with the fix.
Independent reproducer realOwn 8 tests with a different trigger (Float.MAX_VALUE boosts) and stock slicing: 6 fail on main, 10.5.2 and 10.3.0; on 10.2.2 and 9.12.3 its 5 public-API tests pass (the 3 helper tests do not compile there); a NaN guard makes them pass.
Works-as-designed review (OpenAI GPT-5.6) real, highIntended argument weak; its own 3 tests (helper, conjunction, disjunction) fail on main and pass with the fix.
Intended-behaviour devil's advocate realNone of five counter-arguments survives; wrote the single-threaded public-API probe, which corrected the reach.
adjudication confirmed, highRe-ran the independent reproduction with seed 5EEDF00D (6 of 8 fail, assertions on and off) and the intended-behaviour check's probe (fails 2 of 2, passes with the fix).
re-verification (2026-10-29) still reproduces16 of 19 history tests fail on 645d4fe14a20 and on the 10.6.0 RC1 jars, assertions on and off.
this doc (2026-10-31) real, fix validatedThe minimal test on the release jars (10.3.0-10.6.0 fail, 10.2.2 and 9.12.3 pass) and two fixes through gradle.

Diagram file

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

Sources and provenance

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

Finding record

LFV-104, snapshot of 2026-11-06

Registry title
ScorerUtil.minRequiredScore returns NaN when the minimum competitive score is +Infinity, so block-max conjunctions and MaxScore disjunctions drop documents that score +Infinity
Area
search / top-k dynamic pruning (block-max conjunction and MaxScore bulk scorers)
Priority
Low, rank 951: A +Inf minimum competitive score turns minRequiredScore into NaN so block-max and MaxScore scorers drop +Inf docs, which only Float.MAX-scale pin-first boosts or overflowing FeatureField values produce.
Impact class
silent wrong results
Impact note
Applications whose top-k searches (a plain IndexSearcher#search(Query, int) is enough) run a top-level conjunction on BlockMaxConjunctionBulkScorer or a top-level disjunction on MaxScoreBulkScorer, with at least one clause whose maximum score is finite so that the filter step runs, over documents whose scores overflow to +Infinity (FeatureField linear queries on very large feature values, or very large finite boosts, including ones typed into the classic QueryParser): a single-threaded search goes wrong when the k-th best score is exactly Float.MAX_VALUE, a concurrent search when a slice with higher doc ids collects k +Infinity hits first; default IndexWriterConfig, codec and searcher; ordinary scale (a few thousand documents); rare input shape; released 10.3.0-10.6.0.
Summary
You run an ordinary top-k search, for example IndexSearcher#search(query, 1), with a two-clause conjunction or disjunction whose scores can overflow to +Infinity (two FeatureField linear queries over very large feature values, or two clauses with very large finite boosts). You expect the best documents first, +Infinity before Float.MAX_VALUE and ties by ascending doc id, as Lucene 10.2.2 returns them. Instead, once the minimum competitive score reaches +Infinity, ScorerUtil.minRequiredScore returns NaN and filterCompetitiveHits discards every remaining candidate, so the search silently returns doc 0 scoring Float.MAX_VALUE instead of doc 3000 scoring +Infinity (one thread, one segment), or docs 100, 101, 102 instead of the equally scored docs 0, 1, 2 (a concurrent search in which the slice with the higher doc ids finishes first).
Introduced
62e02760321 (#14827 'Pre-calculate minRequiredScore to speedup filterCompetitiveHits', main; branch_10x backport 5687ed5e8a0) starts the regression: it replaced the per-document bound test with a precomputed threshold that is +Infinity for a +Infinity minimum (NaN when the remaining clauses' max is infinite), so documents whose finite partial score would overflow to +Infinity are dropped [exec]; today's form (NaN for every +Infinity minimum, so even documents already at +Infinity are dropped) comes from the same-day follow-up 3f71b54c47a 'Improve test and address more test failures.' (main; branch_10x d0a4b104214; pushed without a PR) [exec]; the follow-up in between, bf848a9a3ab 'Fix test failures.' (branch_10x 678b54f9147), does not change the behaviour for this input [exec]; the commits were located with git log -L and git tag --contains [read], and the commit-level runs compile each branch_10x commit's ScorerUtil.java and BlockMaxConjunctionBulkScorer.java over the 10.3.0 jars; a regression from a performance change, absent in 10.2.2 (2025-07-03 (#14827 merged 11:26Z on main; follow-ups bf848a9a3ab at 16:15Z and 3f71b54c47a at 21:05Z the same day))
First affected version
10.3.0 [exec]
Affected versions
10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main ea57360912ed through 645d4fe14a2 [exec 2026-10-31: the minimal test fails on the release jars of all eight releases with -ea, and also with -da on 10.3.0, 10.5.2 and 10.6.0; exec, -ea; 10.3.0, 10.5.2 and main also -da; 16 of the same 19 tests fail on each]; not affected: 10.2.2 and 9.12.3 [exec, -ea and -da]; the helpers (minRequiredScore, filterCompetitiveHits, DocAndScoreAccBuffer) do not exist before 10.3.0 [exec: the helper-level repro does not compile on 10.2.2 or 9.12.3; read]; upstream main and branch_10_6 (10.6.0, released 2026-10-29) have minRequiredScore and filterCompetitiveHits bodies byte-identical to ea57360912ed [read, 2026-10-26], and 10.6.0 RC1 is affected [exec 2026-10-29: the combined 6-class run fails 16 of 19 on the branch_10_6 RC1 4ae946e5a9b snapshot jars, -ea and -da, the same tests as on main; read: branch_10x 657014b3c57 has ScorerUtil, BlockMaxConjunctionBulkScorer and MaxScoreBulkScorer byte-identical to RC1]; 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
semi-formal: code reading
Upstream and prior reports
none found (2026-10-25 and 2026-10-26: a scripted prior-report search, 7 runs with 50 GitHub issue/PR, 6 commit, 4 downstream-repo, 71 mailing-list (dev@, java-user@, issues@, commits@) and 14 JIRA-archive queries, 9 of 18 planned web queries plus a manual web batch; full reads of the #14827 thread and of #14896, #14906, #14970, #15081, #14751, #14856, #14701, #15004 and #16069; changed-file lists of all 412 open PRs; a title and body grep of the 646 issues and PRs updated since 2026-08-01; GitHub code search; JIRA reads of LUCENE-2271, LUCENE-8006, LUCENE-8016, LUCENE-10428, LUCENE-10503, LUCENE-4100, LUCENE-8197, LUCENE-8992 and LUCENE-8759); related prior items: #14827 (introduced minRequiredScore; its review discusses finite rounding only), #14701 and #14751 (built filterCompetitiveHits with the per-document test), #14906 and #14896 (later rewrites that kept the NaN-blind guard), #14970 (reverted before 10.3.0), #15081 (the only open PR on filterCompetitiveHits; does not touch minRequiredScore), #9064 (LUCENE-8016, open 2017 proposal to bound boosts because huge boosts give infinite scores; not implemented), #3347 (LUCENE-2271, kept +Infinity a valid score), #11589 (LUCENE-10553, precedent: WANDScorer max-score overflow to +Infinity fixed), #11464 (LUCENE-10428, a different ulp loop that did not converge on finite inputs)
Lucene commit tested
main 645d4fe14a20 (gradle and the standalone runner, assertions on and off); the release jars of 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0 (fail) and of 10.2.2 and 9.12.3 (pass); two fixes through gradle in a scratch worktree of 645d4fe14a20. All runs on Linux aarch64 with OpenJDK 25.0.3. Upstream main 2f424fd428da (2026-10-30) has the same code.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/ScorerUtil.java:123-130 (minRequiredScore javadoc: a value such that '(float) MathUtil.sumUpperBound(minRequiredScore + maxRemainingScore, numScorers) <= minCompetitiveScore'), :131-142 (start value, ulp loop, return), :144-150 (filterCompetitiveHits javadoc), :151-164 ('if (minRequiredScore <= 0) return' at :158, VectorUtil.filterByScore at :163)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java:181-190 (filterCompetitiveHits call at :186); search/MaxScoreBulkScorer.java:447-462 (call at :457)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/Scorable.java:45-53 (setMinCompetitiveScore: a scorer 'may safely ignore all documents whose score is less than the given minScore')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:25-31 (ties by doc id ascending; only NaN and NEGATIVE_INFINITY are invalid scores), :145 (raw global minimum score for leaves with smaller doc ids), :160 (Math.nextUp(topScore))
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:600-620 (filterByScore keeps pairs whose score is >= minScoreInclusive); internal/vectorization/DefaultVectorUtilSupport.java:463-476
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:976-983 (the query cache wraps only weights that do not need scores)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/ScorerUtil.java:123-142 (at 645d4fe14a20) (minRequiredScore and its javadoc), :144-164 (filterCompetitiveHits; '<= 0' guard at :158, filterByScore at :162-163)
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:25-31, (at 645d4fe14a20) :113-120, :137-152 (raw global score at :145), :154-172 (nextUp at :160)
  • lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java:86-110, (at 645d4fe14a20) :116-151, :161-198 (filterCompetitiveHits call at :187-188); search/MaxScoreBulkScorer.java:447-472 (call at :457)
  • lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:601-620; (at 645d4fe14a20) internal/vectorization/DefaultVectorUtilSupport.java:463-476; lucene/core/src/java25/org/apache/lucene/internal/vectorization/PanamaVectorUtilSupport.java:1589-1614
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100 (at 645d4fe14a20) (TOTAL_HITS_THRESHOLD = 1000); document/FeatureField.java:262-274 (linear score w * f in float)
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java:142, (at 645d4fe14a20) :152-172, :593-597; search/BoostQuery.java:38-43
  • lucene/core/src/java/org/apache/lucene/search/BlockMaxConjunctionBulkScorer.java:184-189 (at 645d4fe14a20) (filter skipped for equal consecutive bounds); search/TopScoreDocCollector.java:137-171 (minimum raised only from a full queue)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Scorable.java:45-52; (at 645d4fe14a20) search/ScorerUtil.java:123-130, :144-150; search/TopScoreDocCollector.java:25-31, :145, :160
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:63, (at 645d4fe14a20) :89-95, :124-132
  • lucene/core/src/test/org/apache/lucene/search/TestScorerUtil.java:100-132 (at 645d4fe14a20) (nextFloat at :104); lucene/core/src/test/org/apache/lucene/search/TestWANDScorer.java:51-62, :828-836
  • lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:112; (at 645d4fe14a20) document/FeatureField.java:78-81
Suggested fix
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:63, (at 645d4fe14a20) :93; lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:81, :146, :161 (minimum raised from 0 only)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.

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.